This commit is contained in:
Wyle.Gong-巩文昕
2025-04-23 11:21:08 +08:00
parent fc643727f3
commit 7a1aae1e2f
135 changed files with 221483 additions and 1 deletions
+159
View File
@@ -0,0 +1,159 @@
// ExecutionUtils.js
class ExecutionUtils {
/**
* 重置所有节点的执行状态 (纯函数思想:接收旧状态,返回新状态,但不直接修改)
* 注意:这个函数实际上会直接修改传入的 nodes 数组中的对象,
* 因为 JS 对象是引用传递。如果想完全纯函数化会更复杂。
* 但它本身不存储状态。
* @param {Array} nodes - 当前的 graphNodes 数组
* @returns {void} - 直接修改传入的 nodes 数组
*/
static resetExecutionState(nodes) {
if (!nodes) return;
nodes.forEach(node => {
if (!node._executionState) {
node._executionState = { status: 'IDLE', inputData: {}, outputData: null, error: null, startTime: null, endTime: null };
} else {
node._executionState.status = 'IDLE';
node._executionState.inputData = {};
node._executionState.outputData = null;
node._executionState.error = null;
node._executionState.startTime = null;
node._executionState.endTime = null;
}
// 思考:UI 更新应由调用者 (主脚本) 负责
// console.log(`Resetting node ${node.instanceId}`);
});
console.log("Execution state reset requested.");
}
/**
* 找到起始节点并返回它们的 ID 列表以及更新后的节点状态
* @param {Array} nodes - 当前的 graphNodes 数组
* @param {Array} links - 当前的 graphLinks 数组
* @param {Object} initialData - 初始数据
* @returns {Array} - 返回准备好的起始节点的 instanceId 列表
*/
static findAndPrepareStartNodes(nodes, links, initialData = {}) {
const readyNodeIds = [];
const nodesWithIncomingLinks = new Set(links.map(l => l.target.instanceId));
const startNodes = nodes.filter(n =>
!nodesWithIncomingLinks.has(n.instanceId) || n.type === 'start'
);
if (startNodes.length === 0) {
console.error("No start nodes found.");
return []; // 返回空数组
}
console.log('start nodes:', startNodes);
console.log("all nodes", nodes);
startNodes.forEach(node => {
console.log(`Marking start node ${node.name} as READY.`);
node._executionState.status = 'READY';
node._executionState.inputData['_start'] = initialData;
readyNodeIds.push(node.instanceId);
// 思考:UI 更新由主脚本负责
});
return readyNodeIds;
}
/**
* 处理单个执行步骤 (接收当前状态,返回执行结果和更新后的状态信息)
* @param {string} nodeIdToProcess - 要处理的节点 ID
* @param {Array} nodes - 当前的 graphNodes 数组
* @param {Array} links - 当前的 graphLinks 数组
* @returns {{processed: boolean, errorOccurred: boolean, nextReadyIds: Array}}
* processed: 是否成功处理了一个节点
* errorOccurred: 处理过程中是否发生错误
* nextReadyIds: 处理后新变为 Ready 状态的下游节点 ID 列表
*/
static async processSingleStep(nodeIdToProcess, nodes, links) {
const node = nodes.find(n => n.instanceId === nodeIdToProcess);
console.log(`Processing nodeS:`, nodes);
const result = { processed: false, errorOccurred: false, nextReadyIds: [] };
if (!node || node._executionState.status !== 'READY') {
console.warn(`Node ${nodeIdToProcess} not found or not ready. Skipping.`);
return result; // 没有处理
}
console.log(`Processing node: ${node.name} (${node.type})`);
node._executionState.status = 'RUNNING';
node._executionState.startTime = Date.now();
// 思考:UI 更新由主脚本负责 ('RUNNING')
try {
const executor = NODE_EXECUTORS[node.type] || NODE_EXECUTORS.default;
if (!executor) throw new Error(`No executor for type: ${node.type}`);
const inputDataMap = node._executionState.inputData;
const outputData = executor(node, inputDataMap); // 同步执行
node._executionState.status = 'COMPLETED';
node._executionState.outputData = outputData;
node._executionState.endTime = Date.now();
console.log(`Node ${node.name} completed. Output:`, outputData);
// 思考:UI 更新由主脚本负责 ('COMPLETED')
const outgoingLinks = links.filter(link => link.source.instanceId === node.instanceId);
console.log(`Outgoing links:`, outgoingLinks);
console.log(`Nodes:`, nodes);
outgoingLinks.forEach(link => {
const targetNode = nodes.find(n => n.instanceId === link.target.instanceId);
console.log(`Processing link to target node: targetNode`, targetNode);
if (targetNode) {
console.log(`Propagating output to ${targetNode.name}`);
targetNode._executionState.inputData[node.instanceId] = outputData;
if (targetNode._executionState.status == 'IDLE') {
targetNode._executionState.status = 'READY';
result.nextReadyIds.push(targetNode.instanceId); // 记录新 Ready 的节点
console.log(`Node ${targetNode.name} is now READY.`);
// 思考:UI 更新由主脚本负责 ('READY')
}
}
});
result.processed = true;
} catch (error) {
node._executionState.status = 'ERROR';
node._executionState.error = error.message;
node._executionState.endTime = Date.now();
console.log(`Node ${node.name} failed:`, error);
result.errorOccurred = true;
// 思考:UI 更新由主脚本负责 ('ERROR')
}
console.log("result", result);
return result;
}
// --- 新增/占位:为第 6 步准备的 checkNodeReadiness ---
/**
* 检查节点是否满足执行条件
* @param {Object} node - 要检查的节点
* @param {Array} links - 全局 links 数组
* @returns {boolean} - 是否准备就绪
*/
static checkNodeReadiness(node, links) {
const state = node._executionState;
if (['RUNNING', 'COMPLETED', 'ERROR'].includes(state.status)) return false;
const receivedInputSources = Object.keys(state.inputData);
if (receivedInputSources.length === 0 && node.type !== 'start') return false; // 非起始节点必须有输入
if (node.type === 'and') {
const incomingLinks = links.filter(link => link.target.instanceId === node.instanceId);
const requiredSourceIds = new Set(incomingLinks.map(link => link.source.instanceId));
// 检查是否所有必需的源 ID 都已在 inputData 中
return requiredSourceIds.size > 0 && [...requiredSourceIds].every(id => state.inputData.hasOwnProperty(id));
} else { // OR 和其他大部分节点
return receivedInputSources.length > 0 || node.type === 'start';
}
}
// --- checkNodeReadiness 结束 ---
}
// 挂载到 window 或使用 export (如果环境支持)
window.ExecutionUtils = ExecutionUtils;
+573
View File
@@ -0,0 +1,573 @@
// GraphOperations.js - 图形操作类
console.log("GraphOperations.js");
// const d3=require("../../d3.js");
class GraphOperations {
static initGraph(graphContainer) {
const width = graphContainer.clientWidth;
const height = graphContainer.clientHeight;
const svg = d3
.select(graphContainer)
.append("svg")
.attr("width", width)
.attr("height", height);
svg
.append("defs")
.append("marker")
.attr("id", "arrowhead")
.attr("viewBox", "0 -5 10 10")
.attr("refX", 8)
.attr("refY", 0)
.attr("markerWidth", 8)
.attr("markerHeight", 8)
.attr("orient", "auto")
.append("path")
.attr("d", "M0,-5L10,0L0,5")
.attr("fill", "#cacaca");
svg
.append("defs")
.append("marker")
.attr("id", "arrowhead-selected")
.attr("viewBox", "0 -5 10 10")
.attr("refX", 8)
.attr("refY", 0)
.attr("markerWidth", 8)
.attr("markerHeight", 8)
.attr("orient", "auto")
.append("path")
.attr("d", "M0,-5L10,0L0,5")
.attr("fill", "#ff4d4f");
// ... 保持箭头定义不变 ...
const mainGroup = svg.append("g");
const zoomBehavior = d3
.zoom()
.scaleExtent([0.1, 4])
.on("zoom", (event) => {
mainGroup.attr("transform", event.transform);
});
svg.call(zoomBehavior);
svg.on("dblclick.zoom", null);
// 优化力导向模拟参数
const simulation = d3
.forceSimulation()
.force(
"link",
d3
.forceLink()
.id((d) => d.instanceId || d.id)
.distance(200) // 增加节点间距
.strength(0.5) // 减小连接强度,使布局更灵活
)
.force("charge", d3.forceManyBody().strength(-1500)) // 增加排斥力
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collision", d3.forceCollide().radius(100)) // 添加碰撞检测
.alphaDecay(0.1) // 调整衰减速度
.velocityDecay(0.4); // 调整阻尼
return { svg, mainGroup, zoomBehavior, simulation }
}
static updateGraph(
svg,
mainGroup,
graphNodes,
graphLinks,
graphContainer,
simulation,
selected,
dragStatus,
cardPosition,
endpointStatus
) {
console.log("eeeeeeeee", endpointStatus);
// 更新连接
/* const links = GraphManager.getmainGroup
.selectAll(".link")
.data(graphLinks)
.join("path")
.attr("class", "link")
.attr("marker-end", "url(#arrowhead)")
.attr("d", (d) => {
return `M${d.source.x},${d.source.y}L${d.target.x},${d.target.y}`;
})
.on("click", handleLinkClick);*/
console.log("mainGroup", mainGroup);
const nodes = mainGroup
.selectAll(".node")
.data(graphNodes, (d) => d.instanceId || d.id)
.join("g")
.attr("class", "node")
.attr("transform", (d) => `translate(${d.x || 0},${d.y || 0})`)
.call(
d3
.drag()
.on("start", (event, d) =>
NodeOperations.dragStarted(event, d, simulation)
)
.on("drag", (event, d) => NodeOperations.dragged(event, d, mainGroup))
.on("end", (event, d) =>
NodeOperations.dragEnded(event, d, simulation)
)
)
.on("click", (event, d) =>
NodeOperations.handleNodeClick(
event,
d,
graphNodes,
mainGroup,
selected,
cardPosition,
endpointStatus
)
)
.on("contextmenu", function (event, d) {
event.preventDefault();
event.stopPropagation();
selected.element = null;
NodeOperations.handleNodeClick(
event,
d,
graphNodes,
mainGroup,
selected,
cardPosition,
endpointStatus
);
// createEndpointCard(d);
});
const linkGroups = mainGroup
.selectAll(".link-group")
.data(graphLinks)
.join("g")
.attr("class", "link-group");
// 添加透明的宽线条作为点击区域
linkGroups
.selectAll(".link-hitbox")
.data((d) => [d])
.join("path")
.attr("class", "link-hitbox")
.attr("d", (d) => {
//console.log("d", d, d.source);
const dx = d.target.x - d.source.x;
const dy = d.target.y - d.source.y;
const angle = Math.atan2(dy, dx);
// 节点的尺寸
const nodeWidth = 120;
const nodeHeight = 40;
// 计算起点和终点的偏移
const sourceX = d.source.x + Math.cos(angle) * (nodeWidth / 2);
const sourceY = d.source.y + Math.sin(angle) * (nodeHeight / 2);
const targetX = d.target.x - Math.cos(angle) * (nodeWidth / 2);
const targetY = d.target.y - Math.sin(angle) * (nodeHeight / 2);
return `M${sourceX},${sourceY}L${targetX},${targetY}`;
// return `M${d.source.x},${d.source.y}L${d.target.x},${d.target.y}`;
})
.on("click", (event, d) => {
// console.log("evet", event);
// console.log("this", this);
// console.log("d", d);
LinkOperations.handleLinkClick(
event,
d,
graphNodes,
mainGroup,
event.target,
cardPosition,
selected
);
});
// 添加实际显示的连接线
linkGroups
.selectAll(".link")
.data((d) => [d])
.join("path")
.attr("class", (d) => `link ${d.selected ? "selected" : ""}`)
// .attr("marker-end", "url(#arrowhead)")
.attr("marker-end", (d) => {
return d.selected ? "url(#arrowhead-selected)" : "url(#arrowhead)";
})
.attr("d", (d) => calculateLinkPath(d))
.on("click", (event, d) => {
console.log("this", this);
LinkOperations.handleLinkClick(
event,
d,
graphNodes,
mainGroup,
event.target,
cardPosition,
selected
);
});
// 更新节点
// 清除旧的连接点
nodes.selectAll(".connection-point").remove();
nodes.each(function (d) {
NodeOperations.renderNode(
d,
d3.select(this),
mainGroup,
graphContainer,
graphLinks,
dragStatus,
svg,
graphNodes,
simulation,
selected,
cardPosition,
endpointStatus
);
});
// 添加节点主体
/* nodes
.selectAll("rect")
.data((d) => [d])
.join("rect")
.attr("width", 120)
.attr("height", 40)
.attr("transform", "translate(-60, -20)")
.attr("class", (d) => (d.selected ? "selected" : ""));
// 添加文本
nodes
.selectAll("text")
.data((d) => [d])
.join("text")
.text((d) => d.name || "未命名")
.attr("transform", "translate(0, 0)")
// .attr("x", 15) // 向上偏移文本位置,避免被箭头遮挡
.attr("text-anchor", "middle") // 确保文本水平居中
.attr("dominant-baseline", "middle"); // 确保文本垂直居中
*/
// 添加连接点
// const connectionPoints = [
// { x: 0, y: -20, type: "top" }, // 上
// { x: 60, y: 0, type: "right" }, // 右
// { x: 0, y: 20, type: "bottom" }, // 下
// { x: -60, y: 0, type: "left" }, // 左
// ];
// console.log("connectionPoints", connectionPoints);
// nodes.each(function (d) {
// const node = d3.select(this);
// node
// .selectAll(".connection-point")
// .data(connectionPoints)
// .join("circle")
// .attr("class", "connection-point point-hidden")
// .attr("cx", (p) => p.x)
// .attr("cy", (p) => p.y)
// .attr("r", 8)
// .on("mouseenter", function () {
// d3.select(this).classed("point-hidden", false);
// })
// .on("mouseleave", function () {
// d3.select(this).classed("point-hidden", true);
// // }
// })
// .call(
// d3
// .drag()
// .on("start", (event, point) => {
// // console.log("this", this);
// // console.log("point", point);
// LinkOperations.startLinkDrag(
// event,
// point,
// mainGroup,
// dragStatus,
// this
// );
// })
// .on("drag", (event) =>
// LinkOperations.dragLink(event, svg, graphContainer, dragStatus)
// )
// .on("end", (event) =>
// LinkOperations.endLinkDrag(
// event,
// mainGroup,
// graphLinks,
// dragStatus,
// svg,
// graphNodes,
// graphContainer,
// simulation,
// selected,
// cardPosition
// )
// )
// );
// });
simulation.on("tick", () => {
nodes.attr("transform", (d) => `translate(${d.x},${d.y})`);
mainGroup.selectAll(".link").attr(
"d",
(d) => {
const dx = d.target.x - d.source.x;
const dy = d.target.y - d.source.y;
const angle = Math.atan2(dy, dx);
// 节点的尺寸
const nodeWidth = 120;
const nodeHeight = 40;
// 计算起点和终点的偏移
const sourceX = d.source.x + Math.cos(angle) * (nodeWidth / 2);
const sourceY = d.source.y + Math.sin(angle) * (nodeHeight / 2);
const targetX = d.target.x - Math.cos(angle) * (nodeWidth / 2);
const targetY = d.target.y - Math.sin(angle) * (nodeHeight / 2);
// console.log(targetX, targetY);
return `M${sourceX},${sourceY}L${targetX},${targetY}`;
}
//`M${d.source.x},${d.source.y}L${d.target.x},${d.target.y}`
);
});
simulation.nodes(graphNodes);
simulation.force("link").links(graphLinks);
simulation.on("tick", () => {
nodes.attr("transform", (d) => `translate(${d.x},${d.y})`);
linkGroups.selectAll("path").attr("d", (d) => calculateLinkPath(d));
});
}
static initMousePositionTracker(svg, mainGroup, graphContainer) {
const clientX = document.getElementById("client-x");
const clientY = document.getElementById("client-y");
const pageX = document.getElementById("page-x");
const pageY = document.getElementById("page-y");
const offsetX = document.getElementById("offset-x");
const offsetY = document.getElementById("offset-y");
const layerX = document.getElementById("layer-x");
const layerY = document.getElementById("layer-y");
const graphX = document.getElementById("graph-x");
const graphY = document.getElementById("graph-y");
document.addEventListener("mousemove", function (event) {
// 更新基本坐标信息
clientX.textContent = event.clientX;
clientY.textContent = event.clientY;
pageX.textContent = event.pageX;
pageY.textContent = event.pageY;
offsetX.textContent = event.offsetX;
offsetY.textContent = event.offsetY;
layerX.textContent = event.layerX;
layerY.textContent = event.layerY;
// 计算图形坐标系中的位置(考虑缩放和平移)
if (svg && mainGroup) {
try {
const transform = d3.zoomTransform(svg.node());
const rect = graphContainer.getBoundingClientRect();
const x = (event.clientX - rect.left - transform.x) / transform.k;
const y = (event.clientY - rect.top - transform.y) / transform.k;
graphX.textContent = Math.round(x);
graphY.textContent = Math.round(y);
} catch (e) {
console.error("计算图形坐标出错:", e);
}
}
});
// 监听整个文档的鼠标移动
// 添加切换显示/隐藏的功能
const mousePosition = document.getElementById("mouse-position");
mousePosition.addEventListener("dblclick", function () {
if (this.style.opacity === "0.1") {
this.style.opacity = "1";
} else {
this.style.opacity = "0.1";
}
});
}
// 其他图形操作方法...
}
window.GraphOperations = GraphOperations;
// 计算连接线路径,考虑不同节点形状
function calculateLinkPath(d) {
const source = d.source;
const target = d.target;
// 获取源节点和目标节点的类型配置
const sourceType = source.type || "demand";
const targetType = target.type || "demand";
const sourceConfig =
window.NODE_TYPES[sourceType] || window.NODE_TYPES.demand;
const targetConfig =
window.NODE_TYPES[targetType] || window.NODE_TYPES.demand;
// 计算节点之间的角度
const dx = target.x - source.x;
const dy = target.y - source.y;
const angle = Math.atan2(dy, dx);
// 根据不同形状计算连接点偏移
let sourceX = source.x;
let sourceY = source.y;
let targetX = target.x;
let targetY = target.y;
// 源节点连接点计算
switch (sourceConfig.shape) {
case "rect":
// 矩形:根据角度判断从哪个边出发
if (Math.abs(Math.cos(angle)) > Math.abs(Math.sin(angle))) {
// 从左/右边出发
const sign = Math.cos(angle) >= 0 ? 1 : -1;
sourceX = source.x + sign * (sourceConfig.width / 2);
sourceY = source.y + Math.tan(angle) * sign * (sourceConfig.width / 2);
// 如果超出高度范围,则从顶部/底部出发
if (Math.abs(sourceY - source.y) > sourceConfig.height / 2) {
const sign2 = Math.sin(angle) >= 0 ? 1 : -1;
sourceY = source.y + sign2 * (sourceConfig.height / 2);
sourceX = source.x + (sourceY - source.y) / Math.tan(angle);
}
} else {
// 从上/下边出发
const sign = Math.sin(angle) >= 0 ? 1 : -1;
sourceY = source.y + sign * (sourceConfig.height / 2);
sourceX =
source.x + (1 / Math.tan(angle)) * sign * (sourceConfig.height / 2);
// 如果超出宽度范围,则从左/右边出发
if (Math.abs(sourceX - source.x) > sourceConfig.width / 2) {
const sign2 = Math.cos(angle) >= 0 ? 1 : -1;
sourceX = source.x + sign2 * (sourceConfig.width / 2);
sourceY = source.y + Math.tan(angle) * (sourceX - source.x);
}
}
break;
case "circle":
// 圆形:直接根据角度和半径计算
const radius = sourceConfig.width / 2; // 使用宽度作为直径
sourceX = source.x + Math.cos(angle) * radius;
sourceY = source.y + Math.sin(angle) * radius;
break;
case "diamond":
// 菱形:需要考虑菱形的四个角
const halfSize = sourceConfig.width / 2;
// 根据角度确定从哪个角出发
// 四个象限分别处理
if (angle >= -Math.PI / 4 && angle < Math.PI / 4) {
// 右侧点
sourceX = source.x + halfSize;
sourceY = source.y;
} else if (angle >= Math.PI / 4 && angle < (3 * Math.PI) / 4) {
// 底部点
sourceX = source.x;
sourceY = source.y + halfSize;
} else if (
(angle >= (3 * Math.PI) / 4 && angle <= Math.PI) ||
(angle >= -Math.PI && angle < (-3 * Math.PI) / 4)
) {
// 左侧点
sourceX = source.x - halfSize;
sourceY = source.y;
} else {
// 顶部点
sourceX = source.x;
sourceY = source.y - halfSize;
}
break;
default:
// 默认情况,使用简单的偏移
sourceX = source.x + Math.cos(angle) * (sourceConfig.width / 2);
sourceY = source.y + Math.sin(angle) * (sourceConfig.height / 2);
}
// 目标节点连接点计算(与源节点相反的角度)
const targetAngle = Math.atan2(-dy, -dx);
switch (targetConfig.shape) {
case "rect":
if (Math.abs(Math.cos(targetAngle)) > Math.abs(Math.sin(targetAngle))) {
const sign = Math.cos(targetAngle) >= 0 ? 1 : -1;
targetX = target.x + sign * (targetConfig.width / 2);
targetY =
target.y + Math.tan(targetAngle) * sign * (targetConfig.width / 2);
if (Math.abs(targetY - target.y) > targetConfig.height / 2) {
const sign2 = Math.sin(targetAngle) >= 0 ? 1 : -1;
targetY = target.y + sign2 * (targetConfig.height / 2);
targetX = target.x + (targetY - target.y) / Math.tan(targetAngle);
}
} else {
const sign = Math.sin(targetAngle) >= 0 ? 1 : -1;
targetY = target.y + sign * (targetConfig.height / 2);
targetX =
target.x +
(1 / Math.tan(targetAngle)) * sign * (targetConfig.height / 2);
if (Math.abs(targetX - target.x) > targetConfig.width / 2) {
const sign2 = Math.cos(targetAngle) >= 0 ? 1 : -1;
targetX = target.x + sign2 * (targetConfig.width / 2);
targetY = target.y + Math.tan(targetAngle) * (targetX - target.x);
}
}
break;
case "circle":
const radius = targetConfig.width / 2;
targetX = target.x + Math.cos(targetAngle) * radius;
targetY = target.y + Math.sin(targetAngle) * radius;
break;
case "diamond":
const halfSize = targetConfig.width / 2;
if (targetAngle >= -Math.PI / 4 && targetAngle < Math.PI / 4) {
targetX = target.x + halfSize;
targetY = target.y;
} else if (
targetAngle >= Math.PI / 4 &&
targetAngle < (3 * Math.PI) / 4
) {
targetX = target.x;
targetY = target.y + halfSize;
} else if (
(targetAngle >= (3 * Math.PI) / 4 && targetAngle <= Math.PI) ||
(targetAngle >= -Math.PI && targetAngle < (-3 * Math.PI) / 4)
) {
targetX = target.x - halfSize;
targetY = target.y;
} else {
targetX = target.x;
targetY = target.y - halfSize;
}
break;
default:
targetX = target.x + Math.cos(targetAngle) * (targetConfig.width / 2);
targetY = target.y + Math.sin(targetAngle) * (targetConfig.height / 2);
}
return `M${sourceX},${sourceY}L${targetX},${targetY}`;
}
+300
View File
@@ -0,0 +1,300 @@
// LinkOperations.js - 连接操作类
class LinkOperations {
static renderLinks(links, mainGroup) {
// 连接渲染逻辑
}
static handleLinkClick(
event,
d,
graphNodes,
mainGroup,
thisLink,
cardPosition,
selected
) {
// console.log("link click");
event.stopPropagation();
NodeOperations.clearSelection(cardPosition, graphNodes, mainGroup);
// 取消所有节点的选中状态
graphNodes.forEach((node) => {
node.selected = false;
});
// 更新节点视图
// GraphManager.getMainGroup().selectAll(".node").selectAll("rect").attr("class", "");
// 设置当前连接为选中状态
// selected.element = Link;
mainGroup.selectAll(".link").classed("selected", false);
// console.log(thisLink);
if (thisLink.classList.contains("link-hitbox")) {
selected.element = d3.select(thisLink.parentNode).select(".link").node();
d3.select(thisLink.parentNode).select(".link").classed("selected", true);
} else {
selected.element = d3.select(thisLink).node();
d3.select(thisLink).classed("selected", true);
}
// 可以添加视觉效果表示连接被选中
// d3.select(this).classed("selected", true);
}
static startLinkDrag(event, point, mainGroup, dragStatus, thisNode) {
console.log(
"event",
event,
"mainGroup",
mainGroup,
"dragStatus",
dragStatus,
"thisNode",
thisNode
);
dragStatus.isDragging = true;
// TODO thisƒ
// console.log("thisNode", thisNode);
const sourceNode = d3.select(thisNode).datum();
// console.log("sourceNode", sourceNode);
dragStatus.dragStartNode = sourceNode;
dragStatus.dragLine = mainGroup
.append("path")
.attr("class", "link dragline")
.attr("marker-end", "url(#arrowhead)")
.attr("pointer-events", "none");
mainGroup.selectAll(".node").each(function () {
d3.select(thisNode).property("leaveTimerId", null);
});
// console.log("startLinkDrag", event, point);
mainGroup
.selectAll(".node")
.on("mouseenter", function () {
d3.select(thisNode).select("rect").classed("highlight", true);
})
.on("mouseleave", function (event) {
const relatedTarget = event.relatedTarget;
d3.select(thisNode).select("rect").classed("highlight", false);
// d3.select(this).select("rect").classed("highlight", false);
});
/*.on("mouseover", function () {
// 获取当前的缩放和平移状态
console.log("mouse over");
const timerId = d3.select(this).property("leaveTimerId");
if (timerId) {
clearTimeout(timerId);
d3.select(this).property("leaveTimerId", null);
}
if (isDragging && dragStatus.dragStartNode !== d3.select(this).datum()) {
console.log(d3.select(this).datum());
d3.select(this).select("rect").classed("highlight", true);
}
// d3.select(this).select("rect").classed("highlight", true);
});*/
}
static dragLink(event, svg, graphContainer, dragStatus) {
if (!dragStatus.dragLine) return;
// 获取当前的缩放和平移状态
const transform = d3.zoomTransform(svg.node());
// 计算鼠标在实际坐标系中的位置
const mouseX =
event.sourceEvent.clientX - graphContainer.getBoundingClientRect().left;
const mouseY =
event.sourceEvent.clientY - graphContainer.getBoundingClientRect().top;
// 应用逆变换获取实际坐标
const actualX = (mouseX - transform.x) / transform.k;
const actualY = (mouseY - transform.y) / transform.k;
// 更新拖拽线的路径
dragStatus.dragLine.attr(
"d",
`M${dragStatus.dragStartNode.x},${dragStatus.dragStartNode.y}L${actualX},${actualY}`
);
}
static endLinkDrag(
event,
mainGroup,
graphLinks,
dragStatus,
svg,
graphNodes,
graphContainer,
simulation,
selected,
cardPosition,
endpointStatus
) {
dragStatus.isDragging = false;
if (dragStatus.dragLine) {
dragStatus.dragLine.remove();
dragStatus.dragLine = null;
}
// 如果source和target是同一个节点,直接退出
mainGroup.selectAll(".node rect").classed("highlight", false);
// 恢复节点的原始事件处理
mainGroup
.selectAll(".node")
.on("mouseenter", null)
.on("mouseleave", null)
.on("mouseover", null);
mainGroup.selectAll(".connection-point").classed("point-hidden", true);
const targetElement = event.sourceEvent.target;
const targetNode = d3.select(event.sourceEvent.target);
if (
dragStatus.dragStartNode === targetElement ||
dragStatus.dragStartNode === targetNode
) {
return;
}
if (
targetNode.classed("node") ||
targetNode.classed("connection-point") ||
targetNode.classed("selected") ||
targetNode.node().parentNode?.classList.contains("node")
) {
/* const sourceNode = dragStatus.dragStartNode;
const targetNodeData = d3
.select(targetNode.node().parentNode)
.datum();
if (sourceNode !== targetNodeData) {
// 创建新连接
graphLinks.push({
source: sourceNode,
target: targetNodeData,
id: `${sourceNode.id}-${targetNodeData.id}`,
});
updateGraph();
}*/
let targetNodeData;
if (targetNode.classed("node")) {
targetNodeData = targetNode.datum();
} else if (targetNode.classed("connection-point")) {
targetNodeData = d3.select(targetNode.node().parentNode).datum();
} else {
// 如果是节点内的其他元素(如rect或text)
const parentNode = d3.select(targetNode.node().parentNode);
if (parentNode.classed("node")) {
targetNodeData = parentNode.datum();
} else {
// 尝试再往上一级查找
const grandParent = d3.select(parentNode.node().parentNode);
if (grandParent.classed("node")) {
targetNodeData = grandParent.datum();
}
}
}
if (targetNodeData && dragStatus.dragStartNode !== targetNodeData) {
// 检查是否已存在反向连接
// console.log("graphLinks", graphLinks);
// console.log("targretNodeData", targetNodeData);
// console.log("dragStatus.dragStartNode", dragStatus.dragStartNode);
const existingReverseLink = graphLinks.find(
(link) =>
link.source.instanceId === targetNodeData.instanceId &&
link.target.instanceId === dragStatus.dragStartNode.instanceId
);
if (existingReverseLink) {
showError("不能创建循环连接");
return;
}
if (hasCycle(graphLinks, dragStatus.dragStartNode, targetNodeData)) {
showError("不能创建会形成环路的连接");
return;
}
// 创建新连接
const linkId = `${dragStatus.dragStartNode.instanceId}-${targetNodeData.instanceId}`;
graphLinks.push({
source: dragStatus.dragStartNode,
target: targetNodeData,
id: linkId,
});
GraphOperations.updateGraph(
svg,
mainGroup,
graphNodes,
graphLinks,
graphContainer,
simulation,
selected,
dragStatus,
cardPosition,
endpointStatus
);
}
}
dragStatus.dragStartNode = null;
}
// 其他连接操作方法...
}
function hasCycle(links, source, target) {
// 创建一个临时的连接数组,包含新连接
const tempLinks = [...links, { source, target }];
// 构建邻接表
const adjacencyList = {};
tempLinks.forEach((link) => {
const sourceId = link.source.instanceId || link.source.id;
const targetId = link.target.instanceId || link.target.id;
if (!adjacencyList[sourceId]) {
adjacencyList[sourceId] = [];
}
adjacencyList[sourceId].push(targetId);
});
// 使用DFS检测环
const visited = {};
const recStack = {};
function dfsHasCycle(nodeId) {
// 如果节点不在邻接表中,说明它没有出边,不可能形成环
if (!adjacencyList[nodeId]) return false;
// 标记当前节点为已访问
visited[nodeId] = true;
recStack[nodeId] = true;
// 检查所有邻居
for (const neighbor of adjacencyList[nodeId]) {
// 如果邻居未访问,递归检查
if (!visited[neighbor]) {
if (dfsHasCycle(neighbor)) return true;
}
// 如果邻居在当前递归栈中,说明找到了环
else if (recStack[neighbor]) {
return true;
}
}
// 回溯时从递归栈中移除
recStack[nodeId] = false;
return false;
}
// 对每个未访问的节点进行DFS
for (const nodeId in adjacencyList) {
if (!visited[nodeId]) {
if (dfsHasCycle(nodeId)) return true;
}
}
return false;
}
window.LinkOperations = LinkOperations;
+148
View File
@@ -0,0 +1,148 @@
// node-editor.js - 节点编辑器
class NodeEditor {
static openEditor(node) {
// 获取类型配置
const typeConfig = NODE_TYPES[node.type] || NODE_TYPES.demand;
// 设置模态框标题
document.getElementById(
"node-edit-title"
).textContent = `编辑 ${typeConfig.name}`;
// 设置节点名称
document.getElementById("node-name").value = node.name || "";
// 清空并填充元数据字段
const metaFieldsContainer = document.getElementById("node-meta-fields");
metaFieldsContainer.innerHTML = "";
// 解析元数据
let meta = {};
try {
if (node.meta) {
meta = JSON.parse(node.meta);
}
} catch (e) {
console.error("解析元数据失败", e);
}
// 创建元数据编辑字段
if (typeConfig.metaFields) {
typeConfig.metaFields.forEach((field) => {
if (field.type === "hidden") return; // 跳过隐藏字段
const fieldGroup = document.createElement("div");
fieldGroup.className = "form-group";
const label = document.createElement("label");
label.className = "form-label";
label.htmlFor = `meta-${field.name}`;
label.textContent = field.label;
fieldGroup.appendChild(label);
let input;
switch (field.type) {
case "select":
input = document.createElement("select");
input.className = "form-input";
(field.options || []).forEach((option) => {
const optEl = document.createElement("option");
optEl.value = option;
optEl.textContent = option;
optEl.selected = meta[field.name] === option;
input.appendChild(optEl);
});
break;
case "number":
input = document.createElement("input");
input.type = "number";
input.className = "form-input";
input.value = meta[field.name] || 0;
break;
case "code":
case "json":
input = document.createElement("textarea");
input.className = "form-input";
input.rows = 5;
input.value = meta[field.name] || "";
break;
default: // string
input = document.createElement("input");
input.type = "text";
input.className = "form-input";
input.value = meta[field.name] || "";
}
input.id = `meta-${field.name}`;
input.name = field.name;
fieldGroup.appendChild(input);
metaFieldsContainer.appendChild(fieldGroup);
});
}
// 显示模态框
document.getElementById("node-edit-modal").style.display = "flex";
// 设置表单提交处理
const form = document.getElementById("node-edit-form");
form.onsubmit = (e) => {
e.preventDefault();
// 更新节点名称
node.name = document.getElementById("node-name").value;
// 收集元数据
if (typeConfig.metaFields) {
for (const field of typeConfig.metaFields) {
// 包括隐藏字段的默认值
if (field.type === "hidden") {
meta[field.name] = field.defaultValue;
} else {
const input = document.getElementById(`meta-${field.name}`);
if (input) {
meta[field.name] = input.value;
}
}
}
}
// 保存元数据
node.meta = JSON.stringify(meta);
// 更新图形
GraphOperations.updateGraph(
svg,
mainGroup,
graphNodes,
graphLinks,
graphContainer,
simulation,
selected,
dragStatus,
cardPosition,
endpointStatus
);
// 关闭模态框
document.getElementById("node-edit-modal").style.display = "none";
};
// 设置取消按钮
document.getElementById("cancel-node-edit-btn").onclick = () => {
document.getElementById("node-edit-modal").style.display = "none";
};
// 设置关闭按钮
document.getElementById("close-node-edit-modal").onclick = () => {
document.getElementById("node-edit-modal").style.display = "none";
};
}
}
window.NodeEditor = NodeEditor;
+32
View File
@@ -0,0 +1,32 @@
// NodeExecutors.js
const NODE_EXECUTORS = {
// 默认执行器 (用于不执行特殊逻辑的节点,如 'demand')
default: (node, inputData) => {
console.log(`Executing DEFAULT logic for node ${node.name} (${node.type}) with input:`, inputData);
// 最简单的逻辑:直接把输入作为输出返回
return inputData;
},
// '开始' 节点的逻辑
start: (node, inputData) => {
console.log(`Executing START logic for node ${node.name}`);
// 初始可能没有输入,或者接收来自 startExecution 的初始数据
return inputData || {}; // 返回一个初始数据对象
},
// '结束' 节点的逻辑
end: (node, inputData) => {
console.log(`Executing END logic for node ${node.name}. Final data received:`, inputData);
// 结束节点通常不产生有意义的输出给下游,可以返回 null 或最终数据
return null;
},
// HTTP 和 Condition 等暂时也指向 default,后续步骤再实现
httpRequest: (node, inputData) => NODE_EXECUTORS.default(node, inputData),
condition: (node, inputData) => NODE_EXECUTORS.default(node, inputData),
// 其他逻辑节点也先指向 default
and: (node, inputData) => NODE_EXECUTORS.default(node, inputData),
or: (node, inputData) => NODE_EXECUTORS.default(node, inputData),
};
+461
View File
@@ -0,0 +1,461 @@
// NodeOperations.js - 节点操作类
class NodeOperations {
// 静态方法,接收状态作为参数
static renderNode(
node,
selection, // The <g> element for the node
mainGroup, graphContainer, graphLinks, dragStatus, svg,
graphNodes, simulation, selected, cardPosition, endpointStatus
) {
const typeConfig = window.NODE_TYPES[node.type || "demand"] || window.NODE_TYPES.demand;
const nodeWidth = typeConfig.width || 120;
const nodeHeight = typeConfig.height || 40;
// --- 清除旧形状 ---
selection.selectAll("rect, circle, polygon, .node-body, .node-icon-group").remove();
let iconCircleRadius = 0; // 图标圆半径,只在跑道形时计算
let iconCircleCenterX = 0; // 图标圆心 X,只在跑道形时计算
// --- 根据形状绘制 ---
if (typeConfig.shape === "circle") {
// --- 保留 Circle 的绘制逻辑 ---
const radius = nodeWidth / 2; // 通常 circle 的 width 代表直径
selection
.selectAll(".node-main-shape") // 使用统一的类名
.data((d) => [d])
.join("circle")
.attr("class", "node-main-shape node-body")
.attr("r", radius)
.attr("fill", "white")
.attr("stroke", typeConfig.color || '#1890ff')
.attr("stroke-width", 1.5)
.classed("selected", (d) => d.selected);
// 圆形文本居中
selection.selectAll(".node-text").remove();
selection.append("text")
.attr("class", "node-text") /* ... 设置 text 属性 ... */
.attr("x", 0).attr("y", 0) /* ... */
.text(node.name || "未命名");
} else if (typeConfig.shape === "diamond") {
// --- 保留 Diamond 的绘制逻辑 ---
const diamondSize = nodeWidth / 2;
selection
.selectAll(".node-main-shape")
.data((d) => [d])
.join("polygon")
.attr("class", "node-main-shape node-body")
.attr("points", `0,-${diamondSize} ${diamondSize},0 0,${diamondSize} -${diamondSize},0`)
.attr("fill", "white")
.attr("stroke", typeConfig.color || '#1890ff')
.attr("stroke-width", 1.5)
.classed("selected", (d) => d.selected);
// 菱形文本居中
selection.selectAll(".node-text").remove();
selection.append("text")
.attr("class", "node-text") /* ... 设置 text 属性 ... */
.attr("x", 0).attr("y", 0) /* ... */
.text(node.name || "未命名");
} else { // 默认视为跑道形 (原 rect 类型)
// --- 绘制跑道形 ---
// **调整圆角半径:** 可以使用固定值,或基于高度但有上限
const borderRadius = nodeHeight / 2; // 确保圆角不超过高度一半
iconCircleRadius = borderRadius; // 左侧图标圆半径,可以根据新的 borderRadius 调整
iconCircleCenterX = -nodeWidth / 2 + iconCircleRadius; // 图标圆心 X,放在主体左侧,稍微分开点
// 绘制跑道形主体
selection
.selectAll(".node-body")
.data((d) => [d])
.join("path")
.attr("class", "node-body node-main-shape") // 添加 node-main-shape 类
.attr("d", (d) => {
// 修正后的跑道形路径,使用调整后的 borderRadius
const pathData =
`M ${-nodeWidth / 2 + borderRadius},${-nodeHeight / 2}` + // 左上角后,直线起点
` L ${nodeWidth / 2 - borderRadius},${-nodeHeight / 2}` + // 上直线
` A ${borderRadius},${borderRadius} 0 0 1 ${nodeWidth / 2},${-nodeHeight / 2 + borderRadius}` + // 右上圆弧
` L ${nodeWidth / 2},${nodeHeight / 2 - borderRadius}` + // 右直线 (如果 borderRadius < height/2)
` A ${borderRadius},${borderRadius} 0 0 1 ${nodeWidth / 2 - borderRadius},${nodeHeight / 2}` + // 右下圆弧
` L ${-nodeWidth / 2 + borderRadius},${nodeHeight / 2}` + // 下直线
` A ${borderRadius},${borderRadius} 0 0 1 ${-nodeWidth / 2},${nodeHeight / 2 - borderRadius}` + // 左下圆弧
` L ${-nodeWidth / 2},${-nodeHeight / 2 + borderRadius}` + // 左直线 (如果 ...)
` A ${borderRadius},${borderRadius} 0 0 1 ${-nodeWidth / 2 + borderRadius},${-nodeHeight / 2}` + // 左上圆弧
` Z`;
return pathData;
})
.attr("fill", "white") // 默认白色填充
.attr("stroke", typeConfig.color || '#1890ff')
.attr("stroke-width", 1.5)
.classed("selected", (d) => d.selected)
// --- 绘制左侧标识圆 (仅跑道形需要) ---
const iconGroup = selection.append("g") // 直接 append,因为我们清除了旧的
.attr("class", "node-icon-group")
.attr("transform", `translate(${iconCircleCenterX}, 0)`);
iconGroup.append("circle")
.attr("class", "icon-circle-bg node-main-shape") // 添加 node-main-shape
.attr("r", iconCircleRadius)
.attr("fill", typeConfig.color || '#1890ff')
.classed("selected", (d) => d.selected); // 让图标圆也响应选中状态
// .style("filter", "url(#dropshadow)"); // 图标圆也可以加阴影
iconGroup.append("text")
.attr("class", "icon-text")
/* ... 设置 text 属性 ... */
.attr("font-size", `${Math.min(iconCircleRadius * 0.8, 18)}px`) // 调整字体大小
/* ... */
.text(/* ... 获取标识 ... */);
let nodeName = node.name || "未命名";
if (nodeName.length > 10) {
nodeName = nodeName.substring(0, 8) + "..."; // 限制长度
}
// --- 跑道形文本位置 ---
selection.selectAll(".node-text").remove();
selection.append("text")
.attr("class", "node-text")
.attr("x", iconCircleRadius / 2 + 9) // 让文本在跑道形主体部分的中心
.attr("y", 0)
/* ... 设置 text 属性 ... */
.text(nodeName);
if (typeConfig.icon_path) {
const iconSize = iconCircleRadius * 1.3; // 图标显示大小,可以调整以适应圆圈
const iconPath = typeConfig.icon_path; // **确认这是正确的路径**
iconGroup.append("image")
.attr("class", "node-icon-image") // 给个类名
.attr("href", iconPath) // 或 xlink:href 用于兼容旧浏览器
.attr("width", iconSize)
.attr("height", iconSize)
// 定位图像中心到圆心(0,0)
.attr("x", -iconSize / 2)
.attr("y", -iconSize / 2)
.style("pointer-events", "none");
}
}
// --- 渲染连接点 (需要根据形状决定调用哪个版本) ---
if (typeConfig.shape === "circle" || typeConfig.shape === "diamond") {
// 对 Circle 和 Diamond 调用原始的连接点计算逻辑
this.renderConnectionPoints( // 假设这是你之前的版本
selection, typeConfig, mainGroup, graphLinks, dragStatus, svg, graphNodes, graphContainer, simulation, selected, cardPosition, endpointStatus
);
} else {
this.renderConnectionPoints( // 假设这是你之前的版本
selection, typeConfig, mainGroup, graphLinks, dragStatus, svg, graphNodes, graphContainer, simulation, selected, cardPosition, endpointStatus
);
// 对跑道形调用调整后的连接点计算逻辑
}
}
static renderConnectionPointsAdjusted(
selection, typeConfig, nodeWidth, nodeHeight, iconCircleCenterX, iconCircleRadius, // 接收新尺寸
mainGroup, graphLinks, dragStatus, svg, graphNodes, graphContainer, simulation, selected, cardPosition, endpointStatus
) {
const ports = typeConfig.ports || []; // 'top', 'right', 'bottom', 'left'
const bodyHalfWidth = nodeWidth / 2;
const bodyHalfHeight = nodeHeight / 2;
const borderRadius = nodeHeight / 2;
const getPortPosition = (port) => {
switch (port) {
case "top":
// 放在跑道形顶部中间直线段
return { x: 0, y: -bodyHalfHeight };
case "right":
// 放在跑道形最右侧半圆的顶点
return { x: bodyHalfWidth, y: 0 };
case "bottom":
// 放在跑道形底部中间直线段
return { x: 0, y: bodyHalfHeight };
case "left":
// 放在左侧图标圆的最左侧顶点
return { x: iconCircleCenterX - iconCircleRadius, y: 0 };
// 你可以添加更多端口,例如跑道形左右直线段的中点:
// case "body-left":
// return { x: -bodyHalfWidth + borderRadius, y: 0 };
// case "body-right":
// return { x: bodyHalfWidth - borderRadius, y: 0 };
default:
return { x: 0, y: 0 }; // 默认在中心
}
};
// 创建连接点 (这部分逻辑与之前类似,只是调用 getPortPosition)
ports.forEach((port) => {
const position = getPortPosition(port);
selection
.append("circle")
.attr("class", "connection-point point-hidden")
.attr("cx", position.x)
.attr("cy", position.y)
.attr("r", 8) // 连接点大小
.attr("data-port", port)
.style("fill", "#1890ff") // 连接点颜色
.style("stroke", "white")
.style("stroke-width", 1.5)
.style("cursor", "crosshair")
.style("opacity", 0.05) // 默认几乎透明
.on("mouseenter", function () {
d3.select(this).transition().duration(100).style("opacity", 1); // 悬停时不透明
})
.on("mouseleave", function () {
// 如果不是正在拖拽连接线,则恢复透明
if (!dragStatus || !dragStatus.isDragging) {
d3.select(this).transition().duration(100).style("opacity", 0.05);
}
})
.call(
d3.drag()
.on("start", (event, d) => { // 注意:这里的 d 是 port 数据,但我们通常需要节点数据
const nodeData = selection.datum(); // 获取 G 元素绑定的节点数据
LinkOperations.startLinkDrag(event, { ...position, portType: port }, mainGroup, dragStatus, nodeData, selection.node()); // 传递节点数据和元素
})
.on("drag", (event) => LinkOperations.dragLink(event, svg, graphContainer, dragStatus))
.on("end", (event) => LinkOperations.endLinkDrag(
event, mainGroup, graphLinks, dragStatus, svg, graphNodes, graphContainer,
simulation, selected, cardPosition, endpointStatus, /* UI Update Callback? */ updateNodeUIVisuals
// 注意:endLinkDrag 可能也需要调整以处理新的 startLinkDrag 参数
))
);
});
}
static renderConnectionPoints(
selection,
typeConfig,
mainGroup,
graphLinks,
dragStatus,
svg,
graphNodes,
graphContainer,
simulation,
selected,
cardPosition,
endpointStatus
) {
const ports = typeConfig.ports || [];
const width = typeConfig.width;
const height = typeConfig.height;
// 不同形状的端口位置计算
const getPortPosition = (port) => {
if (typeConfig.shape === "rect") {
switch (port) {
case "top":
return { x: 0, y: -height / 2 };
case "right":
return { x: width / 2, y: 0 };
case "bottom":
return { x: 0, y: height / 2 };
case "left":
return { x: -width / 2, y: 0 };
}
} else if (typeConfig.shape === "diamond") {
const size = width / 2;
switch (port) {
case "top":
return { x: 0, y: -size };
case "right":
return { x: size, y: 0 };
case "bottom":
return { x: 0, y: size };
case "left":
return { x: -size, y: 0 };
}
} else if (typeConfig.shape === "circle") {
const radius = width / 2;
switch (port) {
case "top":
return { x: 0, y: -radius };
case "right":
return { x: radius, y: 0 };
case "bottom":
return { x: 0, y: radius };
case "left":
return { x: -radius, y: 0 };
}
}
return { x: 0, y: 0 }; // 默认位置
};
// 创建连接点
ports.forEach((port) => {
const position = getPortPosition(port);
console.log("port", port);
console.log("position", position);
selection
.append("circle")
.attr("class", "connection-point point-hidden")
.attr("cx", position.x)
.attr("cy", position.y)
.attr("r", 8)
.attr("data-port", port)
.on("mouseenter", function () {
d3.select(this).classed("point-hidden", false);
})
.on("mouseleave", function () {
d3.select(this).classed("point-hidden", true);
})
.call(
d3
.drag()
.on("start", (event, point) => {
// console.log("this", this);
// console.log("point", point);
LinkOperations.startLinkDrag(
event,
point,
mainGroup,
dragStatus,
event.sourceEvent.target.parentNode
);
})
.on("drag", (event) =>
LinkOperations.dragLink(event, svg, graphContainer, dragStatus)
)
.on("end", (event) =>
LinkOperations.endLinkDrag(
event,
mainGroup,
graphLinks,
dragStatus,
svg,
graphNodes,
graphContainer,
simulation,
selected,
cardPosition,
endpointStatus
)
)
);
});
}
static handleNodeClick(
event,
d,
graphNodes,
mainGroup,
selected,
cardPosition,
endpointStatus
) {
console.log("node click", event);
event.stopPropagation();
// 如果正在拖拽,不处理点击事件
if (d.isDragging) {
d.isDragging = false;
return;
}
if (event.detail === 2) {
// 检测双击
NodeEditor.openEditor(d);
return;
}
// 取消其他节点的选中状态
graphNodes.forEach((node) => {
node.selected = false;
});
this.clearSelection(cardPosition, graphNodes, mainGroup);
// 设置当前节点的选中状态
// d.selected = false
console.log("d1", d.selected);
d.selected = true;
selected.element = event.target;
console.log("d2", d.selected);
// 更新视图但不重启模拟
mainGroup
.selectAll(".node")
.selectAll("rect")
.attr("class", (d) => (d.selected ? "selected node-body node main-shape" : "node-body node main-shape"));
mainGroup
.selectAll(".node")
.selectAll("polygon")
.attr("class", (d) => (d.selected ? "selected node-body node main-shape" : "node-body node main-shape"));
mainGroup
.selectAll(".node")
.selectAll("circle")
.classed("selected", (d) => d.selected);
if (event.button && event.button === 2) {
console.log("right click");
console.log("d", d);
console.log("event", event);
event.preventDefault();
event.stopPropagation();
//console.log("d", d);
// 右键点击时,显示Endpoint测试卡片
// 在此处添加创建Endpoint测试卡片的逻辑
// 例如,你可以创建一个新的HTML元素来显示卡片
// 创建Endpoint测试卡片
if (d.type == "httpRequest") {
console.log(endpointStatus);
EndpointOperations.createEndpointCard(
event,
d,
cardPosition,
endpointStatus,
);
}
}
}
static dragStarted(event, d, simulation) {
event.sourceEvent.stopPropagation();
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
// 标记正在拖拽
d.isDragging = true;
}
static dragged(event, d, mainGroup) {
d.fx = event.x;
d.fy = event.y;
// 更新连接线位置
mainGroup.selectAll(".link").attr("d", (l) => {
return `M${l.source.x},${l.source.y}L${l.target.x},${l.target.y}`;
});
}
static dragEnded(event, d, simulation) {
if (!event.active) simulation.alphaTarget(0);
// 保持节点位置固定
d.isDragging = false;
}
static clearSelection(cardPosition, graphNodes, mainGroup) {
cardPosition.y = -500;
// 清除节点选中状态
graphNodes.forEach((node) => {
node.selected = false;
});
mainGroup.selectAll(".node").selectAll("rect").attr("class", "");
// 清除连接线选中状态
mainGroup.selectAll(".link").classed("selected", false);
// TODO
}
// 其他节点操作方法...
}
window.NodeOperations = NodeOperations;
+332
View File
@@ -0,0 +1,332 @@
// nodeTypes.js - 扩展的节点类型定义系统
NODE_CATEGORIES = {
demand: { name: "需求节点", open: true },
logic: { name: "逻辑节点", open: true },
validate: { name: "验证节点", open: true },
request: { name: "功能验证节点", parentCategory: "validate", open: true },
perform: { name: "性能验证节点", parentCategory: "validate", open: true },
safety: { name: "安全性验证节点", parentCategory: "validate", open: true },
compliance: { name: "合规性验证节点", parentCategory: "validate", open: true },
reliability: { name: "可靠性验证节点", parentCategory: "validate", open: true },
ui: { name: "UI验证节点", parentCategory: "validate", open: true },
};
NODE_TYPES = {
// 需求节点
demand: {
name: "需求节点",
category: "demand",
color: "#1890ff",
icon: "demand-icon",
icon_path: "/assets/icons/demand.svg",
shape: "rect",
width: 180,
height: 50,
ports: ["top", "right", "bottom", "left"],
metaFields: [
{ name: "description", type: "string", label: "描述" },
{
name: "priority",
type: "select",
label: "优先级",
options: ["高", "中", "低"],
},
],
},
// 逻辑节点 - 起止节点
start: {
name: "开始",
category: "logic",
color: "#722ed1",
icon: "play-circle",
shape: "circle",
width: 60,
height: 60,
ports: ["right", "bottom", "top", "left"],
metaFields: [{ name: "description", type: "string", label: "描述" }],
},
end: {
name: "结束",
category: "logic",
color: "#722ed1",
icon: "stop-circle",
shape: "circle",
width: 60,
height: 60,
ports: ["left", "top", "right", "bottom"],
metaFields: [{ name: "description", type: "string", label: "描述" }],
},
// 逻辑节点 - 条件控制
condition: {
name: "条件",
category: "logic",
color: "#fa8c16",
icon: "question-circle",
shape: "diamond",
width: 80,
height: 80,
ports: ["top", "right", "bottom", "left"],
metaFields: [{ name: "condition", type: "code", label: "条件表达式" }],
},
// 逻辑运算节点
and: {
name: "AND",
category: "logic",
color: "#722ed1",
icon: "and-icon",
shape: "diamond",
width: 80,
height: 80,
ports: ["top", "right", "bottom", "left"],
metaFields: [{ name: "operation", type: "hidden", defaultValue: "AND" }],
},
or: {
name: "OR",
category: "logic",
color: "#eb2f96",
icon: "or-icon",
shape: "diamond",
width: 80,
height: 80,
ports: ["top", "right", "bottom", "left"],
metaFields: [{ name: "operation", type: "hidden", defaultValue: "OR" }],
},
// 请求节点 - 与Endpoint组件集成
httpRequest: {
name: "HTTP验证",
category: "request",
color: "#52c41a",
icon: "api",
icon_path: "/assets/icons/httpFlow.svg",
shape: "rect",
width: 150,
height: 50,
ports: ["top", "right", "bottom", "left"],
metaFields: [
{
name: "method",
type: "select",
label: "请求方法",
options: ["GET", "POST", "PUT", "DELETE", "PATCH"],
},
{ name: "path", type: "string", label: "请求路径" },
{ name: "params", type: "json", label: "查询参数", defaultValue: "{}" },
{
name: "headers",
type: "json",
label: "请求头",
defaultValue: '{"Content-Type": "application/json"}',
},
{
name: "body",
type: "json",
label: "请求体",
defaultValue: '{"type": "json", "content": "{}"}',
},
{ name: "response", type: "hidden", defaultValue: null },
{ name: "endpointId", type: "hidden", defaultValue: "" },
],
// 点击打开Endpoint调试组件
onNodeDblClick: (node, event, cardPosition) => {
// 设置Endpoint调试卡片位置
cardPosition.x = event.clientX + 50;
cardPosition.y = event.clientY - 50;
// 从节点元数据中提取需要的值
const meta =
typeof node.meta === "string" ? JSON.parse(node.meta) : node.meta;
// 如果没有endpointId,创建一个新的
if (!meta.endpointId) {
createEndpoint(node);
} else {
// 打开现有endpoint
openEndpointCard(meta.endpointId, meta);
}
},
},
loadTestHttpRequest: {
name: "HTTP 负载测试",
category: "perform",
color: "#722ed1", // 深紫色
icon_path: "/assets/icons/load-test.svg", // 需要创建或替换
shape: "rect", // 跑道形
width: 180, // 可以宽一点显示更多信息
height: 50,
ports: ["top", "right", "bottom", "left"],
metaFields: [
// --- 包含 httpRequest 的所有配置 ---
{ name: "endpointId", type: "hidden", defaultValue: "" }, // 关联或直接配置
{ name: "method", type: "select", label: "请求方法", options: ["GET", "POST", "PUT", "DELETE", "PATCH"], defaultValue: "GET" },
{ name: "path", type: "string", label: "请求路径" },
{ name: "params", type: "json", label: "查询参数", defaultValue: "{}" },
{ name: "headers", type: "json", label: "请求头", defaultValue: '{"Content-Type": "application/json"}' },
{ name: "body", type: "json", label: "请求体", defaultValue: '{"type": "json", "content": "{}"}' },
// --- 负载测试特定配置 ---
{ name: "concurrentUsers", type: "number", label: "并发用户数", defaultValue: 10 },
{ name: "testDurationSec", type: "number", label: "测试时长(秒)", defaultValue: 30 },
// 或 { name: "requestsPerUser", type: "number", label: "每用户请求数", defaultValue: 100 },
{ name: "rampUpTimeSec", type: "number", label: "启动时长(秒)", defaultValue: 0, optional: true },
// { name: "thinkTimeMs", type: "number", label: "思考时间(ms)", defaultValue: 0, optional: true },
{ name: "resultsSummary", type: "textarea", label: "测试结果摘要", readonly: true, rows: 4 }, // 用于显示结果
{ name: "detailedResults", type: "hidden", defaultValue: null }, // 存储详细结果
],
// onNodeDblClick: (node, event, cardPosition) => { /* 可能打开负载测试配置/结果面板 */ }
},
httpLatencyTest: {
name: "HTTP 延迟测试",
category: "perform",
color: "#b37feb", // 可以用浅紫色区分负载测试
icon_path: "/assets/icons/latency.svg", // 需要创建或替换,例如用带有秒表或直方图的 HTTP 图标
shape: "rect", // 跑道形
width: 180, // 与负载测试类似
height: 50,
ports: ["top", "right", "bottom", "left"],
metaFields: [
// --- 包含 httpRequest 的所有配置 ---
// (复用或重新定义 method, path, params, headers, body, endpointId)
{ name: "endpointId", type: "hidden", defaultValue: "" },
{ name: "method", type: "select", label: "请求方法", options: ["GET", "POST", "PUT", "DELETE", "PATCH"], defaultValue: "GET" },
{ name: "path", type: "string", label: "请求路径" },
{ name: "params", type: "json", label: "查询参数", defaultValue: "{}" },
{ name: "headers", type: "json", label: "请求头", defaultValue: '{"Content-Type": "application/json"}' },
{ name: "body", type: "json", label: "请求体", defaultValue: '{"type": "json", "content": "{}"}' },
// --- 延迟测试特定配置 ---
{ name: "iterations", type: "number", label: "迭代次数", defaultValue: 10, description: "连续发送请求的次数" },
{ name: "delayBetweenMs", type: "number", label: "迭代间隔(ms)", defaultValue: 0, optional: true, description: "每次请求之间的等待时间" },
// --- 结果展示 ---
{ name: "latencyStats", type: "textarea", label: "延迟统计", readonly: true, rows: 4, description: "例如: Min, Max, Avg, P95, P99, Errors" },
{ name: "detailedLatencies", type: "hidden", defaultValue: null }, // 存储每次迭代的延迟和状态
],
// onNodeDblClick: (node, event, cardPosition) => { /* 可能打开配置或结果详情 */ }
},
// ==== 安全性验证节点 (safety) ====
securityScanTrigger: {
name: "安全扫描",
category: "safety",
color: "#f5222d", // 红色
icon_path: "/assets/icons/safety.svg", // 需要创建或替换
shape: "rect",
width: 180,
height: 50,
ports: ["top", "right", "bottom", "left"],
metaFields: [
{ name: "scanTarget", type: "string", label: "扫描目标", description: "例如 URL, IP, 服务名" },
{ name: "scanProfile", type: "select", label: "扫描配置/策略", options: ["快速扫描", "全面扫描", "OWASP Top10"], defaultValue: "快速扫描" },
{ name: "apiKeyRef", type: "string", label: "扫描工具API密钥引用", optional: true, description: "引用环境变量或配置" },
{ name: "waitForResult", type: "boolean", label: "等待扫描结果", defaultValue: false },
{ name: "scanId", type: "string", label: "扫描任务ID", readonly: true },
{ name: "scanStatus", type: "string", label: "扫描状态", readonly: true },
{ name: "scanResultSummary", type: "textarea", label: "扫描结果摘要", readonly: true, rows: 3 },
{ name: "detailedScanResult", type: "hidden" },
],
// onNodeDblClick: (node, event, cardPosition) => { /* 可能打开扫描配置/结果详情 */ }
},
authCheck: {
name: "认证授权检查",
category: "safety",
color: "#faad14", // 橙黄色
icon_path: "/assets/icons/auth-check.svg", // 需要创建或替换
shape: "rect",
width: 170,
height: 50,
ports: ["top", "right", "bottom", "left"],
metaFields: [
// 可以复用 httpRequest 的部分字段
{ name: "endpointInfo", type: "json", label: "目标接口信息", defaultValue: '{"method":"GET", "path":"/api/resource"}' },
{ name: "credentials", type: "json", label: "使用的凭证", defaultValue: '{"type":"token", "value":"{input.token}"}', description: "支持从输入引用值" },
{ name: "expectedStatus", type: "select", label: "预期HTTP状态", options: ["2xx (成功)", "401 (未授权)", "403 (禁止访问)", "其他"], defaultValue: "2xx (成功)" },
{ name: "customExpectedStatus", type: "number", label: "预期状态码(其他)", optional: true },
{ name: "checkResult", type: "string", label: "检查结果", readonly: true }, // Pass/Fail
{ name: "responseDetails", type: "hidden" }, // 存储实际响应
],
},
dataMaskingCheck: {
name: "数据脱敏检查",
category: "compliance",
color: "#595959", // 深灰
icon_path: "/assets/icons/masking-check.svg", // 需要创建或替换
shape: "rect",
width: 160,
height: 50,
ports: ["top", "right", "bottom", "left"],
metaFields: [
{ name: "dataFieldPath", type: "string", label: "检查字段路径", defaultValue: "body.creditCard", description: "使用点标记法指定字段" },
{ name: "expectedFormat", type: "string", label: "预期脱敏格式", defaultValue: "**** **** **** 1234", description: "用*表示脱敏位" },
{ name: "checkResult", type: "string", label: "检查结果", readonly: true }, // Masked/Unmasked/FieldNotFound
{ name: "actualValue", type: "hidden" },
],
},
// ==== 可靠性验证节点 (reliability) ====
failureInjection: {
name: "故障注入",
category: "reliability",
color: "#cf1322", // 深红色
icon_path: "/assets/icons/chaos.svg", // 需要创建或替换
shape: "rect", // 可以用特殊形状,但跑道形通用
width: 160,
height: 50,
ports: ["top", "right", "bottom", "left"], // 通常不修改数据流,只产生副作用
metaFields: [
{ name: "targetResource", type: "string", label: "注入目标资源", description: "e.g., service:my-app, pod:app-*, db:orders" },
{ name: "failureType", type: "select", label: "故障类型", options: ["latency", "error", "crash", "network_loss", "resource_limit"] },
{ name: "magnitude", type: "string", label: "故障程度", description: "e.g., latency: 500ms, error: 503, network_loss: 80%" },
{ name: "durationSec", type: "number", label: "持续时间(秒)", defaultValue: 60 },
{ name: "injectionStatus", type: "string", label: "注入状态", readonly: true }, // Success/Fail/Skipped
{ name: "details", type: "hidden" },
],
},
// retryWrapper 比较特殊,它更像是一个流程控制块,可能需要不同的实现方式(比如子图或特殊渲染),暂时不定义为普通节点。
// ==== UI 验证节点 (ui) ====
agentBrowserTask: {
name: "Agent UI任务",
category: "ui",
color: "#08979c", // 蓝绿色
icon_path: "/assets/icons/agent.svg", // 需要创建或替换
shape: "rect",
width: 180,
height: 60, // 可以高一点放 Prompt
ports: ["top", "right", "bottom", "left"],
metaFields: [
{ name: "targetUrl", type: "string", label: "目标页面URL" },
{ name: "taskPrompt", type: "textarea", label: "任务指令 (Prompt)", rows: 5, description: "给 Agent 的详细操作指令" },
{ name: "agentModel", type: "string", label: "Agent模型/配置", optional: true, defaultValue: "default_browser_agent" },
{ name: "inputDataUsage", type: "json", label: "输入数据使用方式", optional: true, defaultValue: '{"map": [{"from": "input.username", "to": "form.user"}, {"from": "input.password", "to": "form.pass"}]}', description: "定义如何将输入映射到表单或Prompt" },
{ name: "validationCriteria", type: "textarea", label: "成功/失败标准", optional: true, description: "如何判断 Agent 执行结果是否符合预期" },
{ name: "executionResult", type: "string", label: "执行结果", readonly: true }, // Success/Fail/Error
{ name: "agentLog", type: "textarea", label: "Agent 执行日志", readonly: true, rows: 4 },
{ name: "screenshotUrl", type: "string", label: "截图链接", readonly: true, optional: true },
{ name: "extractedData", type: "hidden" }, // Agent 可能提取的数据
],
},
delayNode: {
name: "延迟等待",
category: "logic", // 放在逻辑类可能更合适
color: "#bfbfbf",
icon_path: "/assets/icons/delay.svg",
shape: "circle",
width: 60,
height: 60,
ports: ["top", "right", "bottom", "left"],
metaFields: [
{ name: "delayMs", type: "number", label: "延迟时间 (ms)", defaultValue: 1000 },
],
},
};
@@ -0,0 +1,260 @@
class EndpointOperations {
static createEndpointCard(event, node, cardPosition, endpointStatus, meta) {
console.log(endpointStatus);
console.log(event);
console.log("createEndpointCard", node);
parseMetaData(node.meta, endpointStatus);
endpointStatus.nodeId = node.id;
setHeadersArray(endpointStatus);
// setHeaders(endpointStatus.headersObj);
setParamsArray(endpointStatus);
setBody(endpointStatus);
console.log("endpointStatus", endpointStatus);
setResponse(endpointStatus.responseObj);
// 解析meta数据
// parseMetaData(node.meta, endpointStatus);
// endpointStatus.nodeId = node.id;
// setHeaders(endpointStatus.headersObj);
// TODO 解析headerparam body response,还有双向绑定问题
const x = Math.max(
50,
Math.min(
window.innerWidth - 430,
d3.event ? d3.event.sourceEvent.clientX : 100
)
);
const y = Math.max(
50,
Math.min(
window.innerHeight - 530,
d3.event ? d3.event.sourceEvent.clientY : 100
)
);
cardPosition.x = event.clientX + 50;
cardPosition.y = event.clientY;
// 使卡片可拖动
// makeCardDraggable(cardDiv);
}
}
function parseMetaData(metaStr, status) {
try {
// 尝试解析meta字符串
console.log(metaStr);
const metaObj = typeof metaStr === "string" ? JSON.parse(metaStr) : metaStr;
if (!metaObj || typeof metaObj !== "object") {
console.error("无效的meta数据", metaObj);
return;
}
console.log("metaOBJ", metaObj);
// 处理metaObj,根据你的需求更新status对象的属性
// 例如:status.endpointNameInput = metaObj.nam
// 更新基本信息
status.endpointNameInput = metaObj.name || "";
status.endpointDescInput = metaObj.description || "";
status.methodSelect = metaObj.method || "GET";
status.urlInput = metaObj.path || "";
// 处理headers
try {
status.headersObj = metaObj.headers || "{}";
} catch (e) {
console.error("解析Headers失败:", e);
status.headersObj = "{}";
}
// 处理params
try {
status.paramsObj = metaObj.params || "{}";
} catch (e) {
console.error("解析Params失败:", e);
status.paramsObj = "{}";
}
// 处理body
try {
status.bodyObj =
metaObj.body ||
JSON.stringify({
type: "json",
content: "{}",
});
} catch (e) {
console.error("解析Body失败:", e);
status.bodyObj = JSON.stringify({
type: "json",
content: "{}",
});
}
// 处理response
try {
status.responseObj = metaObj.response || null;
} catch (e) {
console.error("解析Response失败:", e);
status.responseObj = null;
}
} catch (error) {
console.error("解析meta数据失败:", error);
this.resetStatus(status);
}
}
/**
* 重置状态对象为默认值
* @param {Object} status - 状态对象
*/
function resetStatus(status) {
status.nodeId = "";
status.endpointNameInput = "";
status.endpointDescInput = "";
status.methodSelect = "GET";
status.urlInput = "";
status.headersObj = "{}";
status.paramsObj = "{}";
status.bodyObj = JSON.stringify({
type: "json",
content: "{}",
});
status.responseObj = null;
}
function setHeadersArray(status) {
try {
status.headersArray = [];
headersString = status.headersObj;
const headers = JSON.parse(headersString);
console.log("headersContainer", headers)
let hasContentType = false;
for (const key in headers) {
status.headersArray.push({
"key": key,
"value": headers[key],
});
if (key.toLowerCase() === "content-type") {
hasContentType = true;
}
}
if (!hasContentType) {
status.headersArray = [
{
"key": "Content-Type",
"value": "application/json",
},
...status.headersArray,
];
}
console.log("headersArray", status.headersArray);
// 清除现有headers
} catch (e) {
console.error("解析Headers失败:", e);
}
}
function addNewParam(status) {
status.headersArray.push({
"key": "",
"value": "",
});
}
function setParamsArray(status) {
try {
status.paramsArray = [];
paramsString = status.paramsObj;
const params = JSON.parse(paramsString);
console.log("paramsContainer", params)
for (const key in params) {
status.paramsArray.push({
"key": key,
"value": params[key],
});
}
} catch (e) {
console.error("解析Params失败:", e);
}
}
function setBody(status) {
try {
let bodyString = status.bodyObj
const body = JSON.parse(bodyString);
console.log("body", body);
if (body.type) {
status.bodyType = body.type;
// 设置请求体内容
if (body.content) {
status.bodyContent = body.content;
}
if (status.bodyType === "form") {
try {
const formData =
typeof body.content === "string"
? JSON.parse(body.content)
: body.content;
console.log("formData111111", formData);
if (formData && typeof formData === "object") {
// 遍历表单数据,添加到表单字段
for (const key in formData) {
const value = formData[key];
status.bodyArray.push({
key: key,
value: value,
});
}
}
} catch (e) {
console.error("解析表单数据失败:", e);
// 添加一个空白表单字段
addNewFormField();
}
} else {
}
}
} catch (e) {
console.error("解析Body失败:", e);
}
}
function setResponse(responseString) {
try {
const responseStatus = document.getElementById("endpoint-response-status");
const responseBody = document.getElementById("endpoint-response-body");
const savedResponse = JSON.parse(responseString);
console.log(savedResponse);
if (!savedResponse) {
return;
}
// 解析并设置响应状态和内容
// 示例:假设响应是一个JSON对象,包含status和body属性
// 你需要根据实际情况进行解析和设置
if (savedResponse.body) {
responseBody.textContent = savedResponse.body;
}
if (savedResponse.status) {
responseStatus.textContent = `状态: ${savedResponse.status} ${savedResponse.statusText || ""
}`;
if (savedResponse.status >= 200 && savedResponse.status < 300) {
responseStatus.className = "font-bold text-green-600";
} else {
responseStatus.className = "font-bold text-red-600";
}
}
} catch (e) {
console.error("解析Response失败:", e);
}
}
@@ -0,0 +1,160 @@
// SidebarRenderer.js - 侧边栏节点类型渲染
class SidebarRenderer {
static renderCategories() {
const container = document.getElementById("node-categories-container");
if (!container) return;
// 清空容器
container.innerHTML = "";
// 获取根类别
const rootCategories = this.getRootCategories();
// 渲染根类别
rootCategories.forEach((category) => {
this.renderCategory(container, category);
});
}
static getRootCategories() {
return Object.entries(NODE_CATEGORIES)
.filter(([_, config]) => !config.parentCategory)
.map(([category, config]) => ({ category, ...config }));
}
static getChildCategories(parentCategory) {
return Object.entries(NODE_CATEGORIES)
.filter(([_, config]) => config.parentCategory === parentCategory)
.map(([category, config]) => ({ category, ...config }));
}
static renderCategory(container, categoryInfo) {
const { category, name, open } = categoryInfo;
// 创建类别容器
const categoryDiv = document.createElement("div");
categoryDiv.className = "node-category";
categoryDiv.dataset.category = category;
// 创建类别标题
const titleDiv = document.createElement("div");
titleDiv.className = "category-header";
const titleText = document.createElement("h3");
titleText.className = "category-title";
titleText.textContent = name;
const toggleIcon = document.createElement("span");
toggleIcon.className = "category-toggle";
toggleIcon.textContent = open ? "▼" : "►";
titleDiv.appendChild(titleText);
titleDiv.appendChild(toggleIcon);
categoryDiv.appendChild(titleDiv);
// 创建内容容器
const contentDiv = document.createElement("div");
contentDiv.className = "category-content";
if (!open) contentDiv.style.display = "none";
categoryDiv.appendChild(contentDiv);
// 添加点击事件以展开/收起
titleDiv.addEventListener("click", () => {
const isOpen = contentDiv.style.display !== "none";
contentDiv.style.display = isOpen ? "none" : "block";
toggleIcon.textContent = isOpen ? "►" : "▼";
// 更新状态
NODE_CATEGORIES[category].open = !isOpen;
});
// 渲染子类别
const childCategories = this.getChildCategories(category);
childCategories.forEach((childCategory) => {
this.renderCategory(contentDiv, childCategory);
});
// 渲染该类别下的节点类型
if (category === "demand") {
// 需求节点类别特殊处理 - 保留原有的需求节点列表
const nodesListUl = document.createElement("ul");
nodesListUl.className = "node-list";
nodesListUl.id = "available-nodes";
contentDiv.appendChild(nodesListUl);
const emptyState = document.createElement("div");
emptyState.className = "empty-state";
emptyState.id = "nodes-empty-state";
emptyState.style.display = "none";
emptyState.innerHTML = "<p>暂无可用需求节点</p>";
contentDiv.appendChild(emptyState);
} else {
// 其他类别 - 渲染节点类型
this.renderNodeTypes(contentDiv, category);
}
// 添加到父容器
container.appendChild(categoryDiv);
}
static renderNodeTypes(container, category) {
// 获取该类别下的节点类型
const nodeTypes = this.getNodeTypesByCategory(category);
if (!nodeTypes.length) return;
// 创建列表
const nodeList = document.createElement("ul");
nodeList.className = "node-list";
// 添加节点项
nodeTypes.forEach((nodeType) => {
const li = this.createNodeTypeItem(nodeType);
nodeList.appendChild(li);
});
container.appendChild(nodeList);
}
static getNodeTypesByCategory(category) {
return Object.entries(NODE_TYPES)
.filter(([_, config]) => config.category === category)
.map(([type, config]) => ({ type, ...config }));
}
static createNodeTypeItem(nodeType) {
const li = document.createElement("li");
li.className = "node-type-item";
li.draggable = true;
li.dataset.nodeType = nodeType.type;
// 节点图标
const iconDiv = document.createElement("div");
iconDiv.className = `node-type-icon ${nodeType.shape}`;
iconDiv.style.borderColor = nodeType.color;
iconDiv.style.backgroundColor = nodeType.color + "20"; // 添加透明度
if (nodeType.shape === "diamond") {
iconDiv.style.transform = "rotate(45deg)";
}
// 节点名称
const nameDiv = document.createElement("div");
nameDiv.className = "node-type-name";
nameDiv.textContent = nodeType.name;
// 组合
li.appendChild(iconDiv);
li.appendChild(nameDiv);
// 添加拖拽事件
li.addEventListener("dragstart", (event) => {
event.dataTransfer.setData("nodeType", nodeType.type);
});
return li;
}
}
function showError(message) {
alert("错误: " + message); // 简单实现,可以替换为更友好的通知
}
window.SidebarRenderer = SidebarRenderer;