1756 lines
54 KiB
HTML
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">×</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">×</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>
|