Files
2025-04-23 11:21:08 +08:00

1756 lines
54 KiB
HTML

<!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>