testFlow/ui/page/stageGraph.html
Wyle.Gong-巩文昕 7a1aae1e2f ui
2025-04-23 11:21:08 +08:00

1756 lines
54 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Stage 图形编辑</title>
<link rel="stylesheet" href="/ui/assets/common.css" />
<script src="/assets/d3.js"></script>
<style>
body {
font-family: "Microsoft YaHei", sans-serif;
margin: 0;
padding: 0;
overflow: hidden;
}
.container {
display: flex;
height: 100vh;
}
.sidebar {
width: 300px;
background-color: #f5f5f5;
border-right: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: 16px;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar-title {
font-size: 18px;
font-weight: bold;
margin: 0;
}
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.node-list {
list-style: none;
padding: 0;
margin: 0;
}
.node-item {
padding: 12px;
margin-bottom: 8px;
background-color: white;
border: 1px solid #d9d9d9;
border-radius: 4px;
cursor: move;
transition: all 0.3s;
}
.node-item:hover {
border-color: #1890ff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.09);
}
.node-name {
font-weight: 500;
margin-bottom: 4px;
}
.node-info {
font-size: 12px;
color: #666;
}
.graph-container {
flex: 1;
position: relative;
overflow: hidden;
}
.toolbar {
position: absolute;
top: 16px;
right: 16px;
z-index: 10;
display: flex;
gap: 8px;
}
.btn {
padding: 8px 16px;
background-color: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s;
}
.btn:hover {
background-color: #40a9ff;
}
.btn-danger {
background-color: #ff4d4f;
}
.btn-danger:hover {
background-color: #ff7875;
}
.loading {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 2000;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #1890ff;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.empty-state {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: #999;
}
.node rect {
fill: #fff;
stroke: #1890ff;
stroke-width: 1.5px;
rx: 4;
ry: 4;
cursor: pointer;
}
.node text {
font: 14px sans-serif;
text-anchor: middle;
dominant-baseline: middle;
pointer-events: none;
}
.node.selected rect {
stroke: #ff4d4f;
stroke-width: 2px;
}
.link {
stroke: #cacaca;
stroke-width: 1.5px;
marker-end: url(#arrowhead);
cursor: pointer;
stroke-linecap: round;
}
.link-hitbox {
stroke: transparent;
stroke-width: 10px;
cursor: pointer;
pointer-events: stroke;
}
.link-handle {
fill: #1890ff;
cursor: pointer;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal-content {
background-color: white;
padding: 24px;
border-radius: 4px;
width: 400px;
max-width: 90%;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.modal-title {
font-size: 18px;
font-weight: bold;
}
.close {
font-size: 24px;
cursor: pointer;
}
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
margin-bottom: 8px;
font-weight: 500;
}
.form-input {
width: 100%;
padding: 8px 12px;
border: 1px solid #d9d9d9;
border-radius: 4px;
box-sizing: border-box;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 24px;
}
.zoom-controls {
position: absolute;
left: 16px;
bottom: 16px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 10;
}
.zoom-btn {
width: 36px;
height: 36px;
background: white;
border: 1px solid #d9d9d9;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
cursor: pointer;
}
.zoom-btn:hover {
border-color: #1890ff;
color: #1890ff;
}
.stage-info {
position: absolute;
top: 16px;
left: 16px;
background: white;
padding: 8px 16px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.09);
z-index: 10;
}
.stage-name {
font-weight: bold;
margin-bottom: 4px;
}
.stage-level {
font-size: 12px;
color: #666;
}
.node rect.selected {
stroke: #ff4d4f;
stroke-width: 2px;
}
.link.selected {
stroke: #ff4d4f;
marker-end: url(#arrowhead-selected);
/* stroke-width: 2.5px;*/
}
.connection-point {
fill: #1890ff;
stroke: white;
stroke-width: 2px;
cursor: pointer;
opacity: 0.6;
}
.connection-point.hidden {
opacity: 0;
}
.connection-point:hover {
opacity: 1;
}
.dragline {
stroke: #1890ff;
stroke-width: 2px;
stroke-dasharray: 5, 5;
}
/* 添加箭头样式 */
.link {
stroke: #cacaca;
stroke-width: 1.5px;
marker-end: url(#arrowhead);
}
.node rect.highlight {
stroke: #1890ff;
stroke-width: 2.5px;
stroke-dasharray: 5, 3;
}
.mouse-position {
position: fixed;
bottom: 16px;
right: 16px;
background-color: rgba(255, 255, 255, 0.9);
border: 1px solid #d9d9d9;
border-radius: 4px;
padding: 8px 12px;
font-size: 12px;
font-family: monospace;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
min-width: 200px;
}
.mouse-position-title {
font-weight: bold;
margin-bottom: 4px;
color: #1890ff;
}
.mouse-position-data {
display: grid;
grid-template-columns: 80px 1fr;
gap: 4px;
}
.mouse-position-label {
color: #666;
}
</style>
</head>
<body>
<div class="container">
<div class="sidebar">
<div class="sidebar-header">
<h2 class="sidebar-title">可用节点</h2>
<button class="btn" id="refresh-nodes-btn" style="display: none">
刷新
</button>
</div>
<div class="sidebar-content">
<ul class="node-list" id="available-nodes">
<!-- 可用节点将通过JavaScript动态加载 -->
</ul>
<div id="nodes-empty-state" class="empty-state" style="display: none">
<p>暂无可用节点</p>
</div>
</div>
<div class="mouse-position" id="mouse-position">
<div class="mouse-position-title">鼠标坐标</div>
<div class="mouse-position-data">
<span class="mouse-position-label">clientX:</span
><span id="client-x">0</span>
<span class="mouse-position-label">clientY:</span
><span id="client-y">0</span>
<span class="mouse-position-label">pageX:</span
><span id="page-x">0</span>
<span class="mouse-position-label">pageY:</span
><span id="page-y">0</span>
<span class="mouse-position-label">offsetX:</span
><span id="offset-x">0</span>
<span class="mouse-position-label">offsetY:</span
><span id="offset-y">0</span>
<span class="mouse-position-label">layerX:</span
><span id="layer-x">0</span>
<span class="mouse-position-label">layerY:</span
><span id="layer-y">0</span>
<span class="mouse-position-label">graohX:</span
><span id="graph-x">0</span>
<span class="mouse-position-label">graphY:</span
><span id="graph-y">0</span>
</div>
</div>
</div>
<div class="graph-container" id="graph-container">
<div class="stage-info">
<div class="stage-name" id="stage-name">加载中...</div>
<div class="stage-level" id="stage-level"></div>
</div>
<div class="toolbar">
<button class="btn" id="add-link-btn" style="display: none">
添加连接
</button>
<button class="btn" id="save-btn">保存</button>
<button class="btn btn-danger" id="delete-btn">删除</button>
<button class="btn" id="back-btn">返回</button>
</div>
<div class="zoom-controls">
<div class="zoom-btn" id="zoom-in">+</div>
<div class="zoom-btn" id="zoom-out">-</div>
<div class="zoom-btn" id="zoom-reset"></div>
</div>
<div id="graph-empty-state" class="empty-state" style="display: none">
<p>暂无图形节点,请从左侧拖拽节点到此处</p>
</div>
</div>
</div>
<!-- 添加/编辑连接的模态框 -->
<div class="modal" id="link-modal">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title">添加连接</h2>
<span class="close" id="close-link-modal">&times;</span>
</div>
<form id="link-form">
<div class="form-group">
<label class="form-label" for="prev-node">前置节点</label>
<select class="form-input" id="prev-node" required>
<option value="">请选择前置节点</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="next-node">后置节点</label>
<select class="form-input" id="next-node" required>
<option value="">请选择后置节点</option>
</select>
</div>
<div class="form-actions">
<button type="button" class="btn" id="cancel-link-btn">取消</button>
<button type="submit" class="btn" id="save-link-btn">保存</button>
</div>
</form>
</div>
</div>
<!-- 确认删除的模态框 -->
<div class="modal" id="confirm-modal">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title">确认删除</h2>
<span class="close" id="close-confirm-modal">&times;</span>
</div>
<p>确定要删除选中的节点或连接吗?此操作不可恢复。</p>
<div class="form-actions">
<button type="button" class="btn" id="cancel-delete-btn">取消</button>
<button type="button" class="btn btn-danger" id="confirm-delete-btn">
删除
</button>
</div>
</div>
</div>
<!-- 加载指示器 -->
<div class="loading" id="loading">
<div class="spinner"></div>
</div>
<script>
// 基础URL
const API_BASE_URL = "http://127.0.0.1:5002/api";
// 获取URL参数
const urlParams = new URLSearchParams(window.location.search);
const stageId = urlParams.get("id");
if (!stageId) {
alert("缺少Stage ID参数");
window.location.href = "/ui/page/stage.html";
}
// DOM元素
const availableNodesList = document.getElementById("available-nodes");
const nodesEmptyState = document.getElementById("nodes-empty-state");
const graphContainer = document.getElementById("graph-container");
const graphEmptyState = document.getElementById("graph-empty-state");
const stageName = document.getElementById("stage-name");
const stageLevel = document.getElementById("stage-level");
const refreshNodesBtn = document.getElementById("refresh-nodes-btn");
const addLinkBtn = document.getElementById("add-link-btn");
const saveBtn = document.getElementById("save-btn");
const deleteBtn = document.getElementById("delete-btn");
const backBtn = document.getElementById("back-btn");
const zoomInBtn = document.getElementById("zoom-in");
const zoomOutBtn = document.getElementById("zoom-out");
const zoomResetBtn = document.getElementById("zoom-reset");
const linkModal = document.getElementById("link-modal");
const closeLinkModal = document.getElementById("close-link-modal");
const linkForm = document.getElementById("link-form");
const prevNodeSelect = document.getElementById("prev-node");
const nextNodeSelect = document.getElementById("next-node");
const cancelLinkBtn = document.getElementById("cancel-link-btn");
const confirmModal = document.getElementById("confirm-modal");
const closeConfirmModal = document.getElementById("close-confirm-modal");
const cancelDeleteBtn = document.getElementById("cancel-delete-btn");
const confirmDeleteBtn = document.getElementById("confirm-delete-btn");
const loading = document.getElementById("loading");
// 全局变量
let stageData = null;
let availableNodes = [];
let graphNodes = [];
let graphLinks = [];
let selectedElement = null;
let svg = null;
let mainGroup = null;
let simulation = null;
let zoomBehavior = null;
// let isDragging = false;
// 初始化
document.addEventListener("DOMContentLoaded", initialize);
// 初始化函数
async function initialize() {
try {
console.log("init");
await loadStageInfo();
await loadAvailableNodes();
await loadGraphData();
initGraph();
initDragAndDrop();
bindEvents();
initMousePositionTracker();
} catch (error) {
console.error("初始化错误:", error);
showError("初始化错误: " + error.message);
} finally {
hideLoading();
}
}
function initMousePositionTracker() {
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.2") {
this.style.opacity = "1";
} else {
this.style.opacity = "0.2";
}
});
}
let dragLine = null;
let dragStartNode = null;
let isDragging = false;
function length2GraphLength(length) {
const transform = d3.zoomTransform(svg.node());
return length / transform.k;
}
function client2Graph(x, y) {
const transform = d3.zoomTransform(svg.node());
const rect = graphContainer.getBoundingClientRect();
const graphX = (x - rect.left - transform.x) / transform.k;
const graphY = (y - rect.top - transform.y) / transform.k;
return { x: graphX, y: graphY };
}
// 连接线拖拽相关函数
function startLinkDrag(event, point) {
isDragging = true;
const sourceNode = d3.select(this.parentNode).datum();
dragStartNode = sourceNode;
dragLine = mainGroup
.append("path")
.attr("class", "link dragline")
.attr("marker-end", "url(#arrowhead)");
mainGroup.selectAll(".node").each(function () {
d3.select(this).property("leaveTimerId", null);
});
mainGroup
.selectAll(".node")
.on("mouseenter", function () {
// console.log("mouse enter", this);
const timerId = d3.select(this).property("leaveTimerId");
if (timerId) {
clearTimeout(timerId);
d3.select(this).property("leaveTimerId", null);
}
if (isDragging && dragStartNode !== d3.select(this).datum()) {
d3.select(this).select("rect").classed("highlight", true);
}
})
.on("mouseleave", function (event) {
// 获取当前的缩放和平移状态
/* console.log("mouse leave", event);
console.log("node", this);
let pos = client2Graph(event.clientX, event.clientY);
console.log("pos", pos);
let width = this.getBoundingClientRect().width;
let height = this.getBoundingClientRect().height;
let d3width = length2GraphLength(width);
let d3height = length2GraphLength(height);
// 获取node在d3中的坐标
let nodeX = this.getBoundingClientRect().x;
let nodeY = this.getBoundingClientRect().y;
let nodePos = client2Graph(nodeX, nodeY);
nodePos.x = nodePos.x + 60;
nodePos.y = nodePos.y + 20;
console.log("mouse pos", pos, "node pos", nodePos);
console.log(
"rect",
nodePos.x - d3width / 2,
nodePos.x + d3width / 2,
nodePos.y - d3height / 2,
nodePos.y + d3height / 2
);
const node = this;
if (
!(
pos.x > nodePos.x - d3width / 2 &&
pos.x < nodePos.x + d3width / 2 &&
pos.y > nodePos.y - d3height / 2 &&
pos.y < nodePos.y + d3height / 2
)
) {
console.log("realOUT!");
}*/
node=this
const timerId = setTimeout(() => {
d3.select(node).select("rect").classed("highlight", false);
d3.select(node).property("leaveTimerId", null);
}, 200); // 200ms的防抖延迟
d3.select(this).property("leaveTimerId", timerId);
// 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 && 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);
});*/
}
function dragLink(event) {
if (!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;
// 更新拖拽线的路径
dragLine.attr(
"d",
`M${dragStartNode.x},${dragStartNode.y}L${actualX},${actualY}`
);
}
function endLinkDrag(event) {
isDragging = false;
if (dragLine) {
dragLine.remove();
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("hidden", true);
const targetElement = event.sourceEvent.target;
const targetNode = d3.select(event.sourceEvent.target);
if (dragStartNode === targetElement || dragStartNode === targetNode) {
return;
}
if (
targetNode.classed("node") ||
targetNode.classed("connection-point") ||
targetNode.classed("selected") ||
targetNode.node().parentNode?.classList.contains("node")
) {
/* const sourceNode = 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 && dragStartNode !== targetNodeData) {
// 检查是否已存在反向连接
const existingReverseLink = graphLinks.find(
(link) =>
link.source.instanceId === targetNodeData.instanceId &&
link.target.instanceId === dragStartNode.instanceId
);
if (existingReverseLink) {
showError("不能创建循环连接");
return;
}
if (hasCycle(graphLinks, dragStartNode, targetNodeData)) {
showError("不能创建会形成环路的连接");
return;
}
// 创建新连接
const linkId = `${dragStartNode.instanceId}-${targetNodeData.instanceId}`;
graphLinks.push({
source: dragStartNode,
target: targetNodeData,
id: linkId,
});
updateGraph();
}
}
dragStartNode = null;
}
// 加载Stage信息
// 加载Stage信息
async function loadStageInfo() {
const response = await fetch(`${API_BASE_URL}/stage/${stageId}/`);
if (!response.ok) throw new Error(`HTTP error ${response.status}`);
const result = await response.json();
if (result.code === 0 && result.data) {
stageData = result.data.stage;
stageName.textContent = stageData.name || "未命名";
// 修改显示关联树节点而不是level
if (stageData.tree_id === "root") {
stageLevel.textContent = "关联节点: 所有节点 (虚拟根节点)";
} else if (stageData.tree_id) {
stageLevel.textContent = `关联节点ID: ${stageData.tree_id}`;
// 尝试获取树节点名称
try {
const treeResponse = await fetch(
`${API_BASE_URL}/tree/${stageData.tree_id}/`
);
if (treeResponse.ok) {
const treeResult = await treeResponse.json();
if (treeResult.code === 0 && treeResult.data) {
stageLevel.textContent = `关联节点: ${
treeResult.data.node.name || "未命名"
}`;
// console.log("tre", treeResult.data);
}
}
} catch (error) {
console.error("获取树节点名称失败:", error);
}
} else {
stageLevel.textContent = "未关联任何树节点";
}
} else {
throw new Error(result.message || "获取Stage信息失败");
}
}
// 加载可用节点
async function loadAvailableNodes() {
let url;
// 根据stage的tree_id决定请求的URL
if (!stageData.tree_id) {
// 如果没有关联树节点,则不加载任何节点
availableNodes = [];
renderAvailableNodes();
return;
} else if (stageData.tree_id === "root") {
// 如果是虚拟根节点,获取所有树节点
url = `${API_BASE_URL}/tree/`;
} else {
// 获取指定树节点及其所有子孙节点
url = `${API_BASE_URL}/tree/${stageData.tree_id}/descendants`;
}
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error ${response.status}`);
const result = await response.json();
if (result.code === 0) {
// 如果是descendants接口需要合并node和descendants
if (
stageData.tree_id !== "root" &&
result.data &&
result.data.node &&
result.data.descendants
) {
availableNodes = [result.data.node, ...result.data.descendants];
} else {
availableNodes = result.data || [];
}
renderAvailableNodes();
} else {
throw new Error(result.message || "获取可用节点失败");
}
}
// 渲染可用节点列表
// 渲染可用节点列表
function renderAvailableNodes() {
availableNodesList.innerHTML = "";
if (availableNodes.length == 0 || !availableNodes.length) {
nodesEmptyState.style.display = "block";
return;
}
nodesEmptyState.style.display = "none";
if (!availableNodes.length) return;
availableNodes.forEach((node) => {
if (node.id == stageData.tree_id) return;
const li = document.createElement("li");
li.className = "node-item";
li.draggable = true;
li.dataset.nodeId = node.id;
li.innerHTML = `
<div class="node-name">${node.name || "未命名"}</div>
<div class="node-info">
Level: ${node.level || 0}
${
node.parent_id
? `| 父节点: ${
availableNodes.filter((n) => {
return n.id === node.parent_id;
})[0]?.name
}`
: ""
}
</div>
`;
availableNodesList.appendChild(li);
});
}
function showError(message) {
// 可以使用alert或创建一个toast提示
alert(message);
}
// 显示成功消息
function showMessage(message) {
// 可以使用alert或创建一个toast提示
alert(message);
}
// 加载图形数据
async function loadGraphData() {
const response = await fetch(`${API_BASE_URL}/graph/${stageId}/`);
if (!response.ok) throw new Error(`HTTP error ${response.status}`);
const result = await response.json();
if (result.code === 0 && result.data) {
graphNodes = result.data.nodes || [];
graphLinks = result.data.links || [];
graphNodes = graphNodes.map((node) => {
return {
...node,
instanceId: node.instance_id,
};
});
console.log(graphNodes);
graphLinks = graphLinks.map((link) => {
return {
...link,
source: graphNodes.find(
(node) => node.instance_id === link.source_id
),
target: graphNodes.find(
(node) => node.instance_id === link.target_id
),
id: link.link_id || `${link.source_id}-${link.target_id}`, // 确保有id
};
});
// 如果需要,也可以更新 stageData
if (result.data.stage && !stageData) {
stageData = result.data.stage;
stageName.textContent = stageData.name || "未命名";
// 更新显示关联树节点信息
if (stageData.tree_id === "root") {
stageLevel.textContent = "关联节点: 所有节点 (虚拟根节点)";
} else if (stageData.tree_id) {
stageLevel.textContent = `关联节点ID: ${stageData.tree_id}`;
} else {
stageLevel.textContent = "未关联任何树节点";
}
}
} else {
throw new Error(result.message || "获取图形数据失败");
}
}
// 初始化D3图形
function initGraph() {
const width = graphContainer.clientWidth;
const height = graphContainer.clientHeight;
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");
// ... 保持箭头定义不变 ...
mainGroup = svg.append("g");
zoomBehavior = d3
.zoom()
.scaleExtent([0.1, 4])
.on("zoom", (event) => {
mainGroup.attr("transform", event.transform);
});
svg.call(zoomBehavior);
// 优化力导向模拟参数
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); // 调整阻尼
updateGraph();
}
// 更新图形
function updateGraph() {
// 更新连接
/* const links = mainGroup
.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);*/
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", dragStarted)
.on("drag", dragged)
.on("end", dragEnded)
)
.on("click", handleNodeClick);
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", handleLinkClick);
// 添加实际显示的连接线
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) => {
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}`;
}
//return `M${d.source.x},${d.source.y}L${d.target.x},${d.target.y}`}
);
// 更新节点
// 清除旧的连接点
nodes.selectAll(".connection-point").remove();
// 添加节点主体
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" }, // 左
];
nodes.each(function (d) {
const node = d3.select(this);
node
.selectAll(".connection-point")
.data(connectionPoints)
.join("circle")
.attr("class", "connection-point hidden")
.attr("cx", (p) => p.x)
.attr("cy", (p) => p.y)
.attr("r", 8)
.on("mouseenter", function () {
d3.select(this).classed("hidden", false);
})
.on("mouseleave", function () {
// if (!isDragging) {
d3.select(this).classed("hidden", true);
// }
})
.call(
d3
.drag()
.on("start", startLinkDrag)
.on("drag", dragLink)
.on("end", endLinkDrag)
);
});
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) => {
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}`
);
});
}
function handleLinkClick(event, d) {
// console.log("link click");
event.stopPropagation();
clearSelection();
// 取消所有节点的选中状态
graphNodes.forEach((node) => {
node.selected = false;
});
// 更新节点视图
// mainGroup.selectAll(".node").selectAll("rect").attr("class", "");
// 设置当前连接为选中状态
// selectedElement = this;
selectedElement = d3.select(this.parentNode).select(".link").node();
// 可以添加视觉效果表示连接被选中
mainGroup.selectAll(".link").classed("selected", false);
// d3.select(this).classed("selected", true);
d3.select(this.parentNode).select(".link").classed("selected", true);
}
// 节点拖拽相关函数
function dragStarted(event, d) {
event.sourceEvent.stopPropagation();
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
// 标记正在拖拽
d.isDragging = true;
}
function dragged(event, d) {
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}`;
});
}
function dragEnded(event, d) {
if (!event.active) simulation.alphaTarget(0);
// 保持节点位置固定
d.isDragging = false;
}
// 节点点击事件
function handleNodeClick(event, d) {
event.stopPropagation();
// 如果正在拖拽,不处理点击事件
if (d.isDragging) {
d.isDragging = false;
return;
}
// 取消其他节点的选中状态
graphNodes.forEach((node) => {
node.selected = false;
});
clearSelection();
// 设置当前节点的选中状态
d.selected = true;
selectedElement = this;
// 更新视图但不重启模拟
mainGroup
.selectAll(".node")
.selectAll("rect")
.attr("class", (d) => (d.selected ? "selected" : ""));
}
// 初始化拖拽功能
function initDragAndDrop() {
document.querySelectorAll(".node-item").forEach((node) => {
node.addEventListener("dragstart", handleDragStart);
node.addEventListener("dragend", handleDragEnd);
});
graphContainer.addEventListener("dragover", handleDragOver);
graphContainer.addEventListener("drop", handleDrop);
}
function clearSelection() {
// 清除节点选中状态
graphNodes.forEach((node) => {
node.selected = false;
});
mainGroup.selectAll(".node").selectAll("rect").attr("class", "");
// 清除连接线选中状态
mainGroup.selectAll(".link").classed("selected", false);
selectedElement = null;
}
// 绑定事件
function bindEvents() {
svg.on("click", (event) => {
if (event.target === svg.node()) {
clearSelection();
}
});
refreshNodesBtn.addEventListener("click", loadAvailableNodes);
addLinkBtn.addEventListener("click", () => openModal(linkModal));
saveBtn.addEventListener("click", saveGraph);
//deleteBtn.addEventListener("click", deleteSelected);
deleteBtn.addEventListener("click", () => {
if (selectedElement) {
openModal(confirmModal);
} else {
showError("请先选择要删除的节点或连接");
}
});
backBtn.addEventListener(
"click",
() => (window.location.href = "/ui/page/stage.html")
);
zoomInBtn.addEventListener("click", () => {
svg.transition().call(zoomBehavior.scaleBy, 1.2);
});
zoomOutBtn.addEventListener("click", () => {
svg.transition().call(zoomBehavior.scaleBy, 0.8);
});
zoomResetBtn.addEventListener("click", () => {
svg.transition().call(zoomBehavior.transform, d3.zoomIdentity);
});
closeConfirmModal.addEventListener("click", () =>
closeModal(confirmModal)
);
cancelDeleteBtn.addEventListener("click", () =>
closeModal(confirmModal)
);
confirmDeleteBtn.addEventListener("click", confirmDelete);
}
function openModal(modal) {
modal.style.display = "flex";
}
// 工具函数:关闭模态框
function closeModal(modal) {
modal.style.display = "none";
}
// 拖拽相关函数
function handleDragStart(event) {
event.dataTransfer.setData("nodeId", event.target.dataset.nodeId);
}
function handleDragEnd() {
// 清理拖拽状态
}
function handleDragOver(event) {
event.preventDefault();
}
async function handleDrop(event) {
event.preventDefault();
const nodeId = event.dataTransfer.getData("nodeId");
if (!nodeId) return;
const node = availableNodes.find((n) => n.id === nodeId);
if (!node) return;
const rect = graphContainer.getBoundingClientRect();
const transform = d3.zoomTransform(svg.node());
// 修正位置计算
const x = (event.clientX - rect.left - transform.x) / transform.k;
const y = (event.clientY - rect.top - transform.y) / transform.k;
// 添加新节点
const newNode = {
...node,
x: x,
y: y,
fx: x, // 固定初始位置
fy: y,
instanceId: `${node.id}-${Date.now()}`,
};
graphNodes.push(newNode);
// 更新图形并重启模拟
updateGraph();
// 短暂释放节点位置,让它能够稍微调整
setTimeout(() => {
newNode.fx = newNode.x;
newNode.fy = newNode.y;
simulation.alpha(0.1).restart();
}, 500);
}
// 保存图形
async function saveGraph() {
showLoading();
try {
const nodes = graphNodes.map((node) => ({
tree_id: node.id, // 原始树节点ID
stage_id: stageId,
instance_id: node.instanceId,
x: node.x,
y: node.y,
fx: node.fx,
fy: node.fy,
name: node.name,
level: node.level,
parent_id: node.parent_id,
}));
const links = graphLinks.map((link) => ({
stage_id: stageId,
source_id: link.source.instanceId,
target_id: link.target.instanceId,
link_id: link.id,
}));
const response = await fetch(`${API_BASE_URL}/graph/${stageId}/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
nodes: nodes,
links: links,
}),
});
if (!response.ok) throw new Error(`HTTP error ${response.status}`);
const result = await response.json();
if (result.code === 0) {
showMessage("保存成功");
} else {
throw new Error(result.message || "保存失败");
}
} catch (error) {
showError("保存错误: " + error.message);
} finally {
hideLoading();
}
}
// 删除选中元素
function deleteSelected() {
if (!selectedElement) return;
if (
d3.select(selectedElement).classed("node") ||
d3.select(selectedElement.parentNode).classed("node")
) {
// 删除节点使用instanceId而不是id
const nodeData = d3.select(selectedElement.closest(".node")).datum();
const nodeInstanceId = nodeData.instanceId || nodeData.id;
// 只删除特定实例的节点而不是所有相同id的节点
graphNodes = graphNodes.filter(
(n) => (n.instanceId || n.id) !== nodeInstanceId
);
// 删除与该节点相关的连接
graphLinks = graphLinks.filter(
(l) =>
(l.source.instanceId || l.source.id) !== nodeInstanceId &&
(l.target.instanceId || l.target.id) !== nodeInstanceId
);
} else if (d3.select(selectedElement).classed("link")) {
// 删除连接线
const linkData = d3.select(selectedElement).datum();
graphLinks = graphLinks.filter((l) => l.id !== linkData.id);
}
selectedElement = null;
updateGraph();
}
// 绑定事件
// 打开连接模态框
function openLinkModal() {
// 填充节点选择框
populateNodeSelects();
openModal(linkModal);
}
// 填充节点选择框
function populateNodeSelects() {
prevNodeSelect.innerHTML = '<option value="">请选择前置节点</option>';
nextNodeSelect.innerHTML = '<option value="">请选择后置节点</option>';
graphNodes.forEach((node) => {
const prevOption = document.createElement("option");
prevOption.value = node.id;
prevOption.textContent =
node.name || `节点 ${node.id.substring(0, 8)}...`;
prevNodeSelect.appendChild(prevOption);
const nextOption = document.createElement("option");
nextOption.value = node.id;
nextOption.textContent =
node.name || `节点 ${node.id.substring(0, 8)}...`;
nextNodeSelect.appendChild(nextOption);
});
}
// 处理连接表单提交
function handleLinkFormSubmit(event) {
event.preventDefault();
const sourceId = prevNodeSelect.value;
const targetId = nextNodeSelect.value;
if (!sourceId || !targetId) {
showError("请选择前置节点和后置节点");
return;
}
if (sourceId === targetId) {
showError("前置节点和后置节点不能相同");
return;
}
// 检查是否已存在相同的连接
const existingLink = graphLinks.find(
(link) => link.source.id === sourceId && link.target.id === targetId
);
if (existingLink) {
showError("该连接已存在");
return;
}
// 获取源节点和目标节点
const source = graphNodes.find((node) => node.id === sourceId);
const target = graphNodes.find((node) => node.id === targetId);
// 创建新连接
graphLinks.push({
source,
target,
id: `${sourceId}-${targetId}`,
});
// 更新图形
updateGraph();
// 关闭模态框
closeModal(linkModal);
}
// 确认删除
function confirmDelete() {
deleteSelected();
closeModal(confirmModal);
}
// 工具函数:显示加载中
function showLoading() {
// 如果loading元素存在
if (loading) {
loading.style.display = "flex";
} else {
// 创建一个临时的loading元素
const tempLoading = document.createElement("div");
tempLoading.className = "loading";
tempLoading.innerHTML = '<div class="spinner"></div>';
document.body.appendChild(tempLoading);
setTimeout(() => {
document.body.removeChild(tempLoading);
}, 2000);
}
}
// 工具函数:隐藏加载中
function hideLoading() {
if (loading) {
loading.style.display = "none";
}
}
// 工具函数:显示消息
function showMessage(message) {
alert(message); // 简单实现,可以替换为更友好的通知
}
// 工具函数:显示错误
function showError(message) {
alert("错误: " + message); // 简单实现,可以替换为更友好的通知
}
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;
}
</script>
</body>
</html>