ui
This commit is contained in:
@@ -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;
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user