2074 lines
70 KiB
HTML
2074 lines
70 KiB
HTML
<!DOCTYPE html>
|
||
<html>
|
||
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<title>需求树形结构可视化</title>
|
||
<script src="/assets/d3.js"></script>
|
||
<!-- <script src="https://d3js.org/d3.v7.min.js"></script> -->
|
||
<style>
|
||
body {
|
||
font-family: "Microsoft YaHei", sans-serif;
|
||
margin: 0;
|
||
padding: 20px;
|
||
}
|
||
|
||
body #tree-container {
|
||
width: 100%;
|
||
height: 800px;
|
||
border: 1px solid #eee;
|
||
overflow: hidden;
|
||
position: relative;
|
||
perspective: 1000px;
|
||
}
|
||
|
||
/* 卡片式节点样式 */
|
||
body .node rect {
|
||
fill: #fff;
|
||
stroke: #1890ff;
|
||
stroke-width: 1.5px;
|
||
rx: 4;
|
||
ry: 4;
|
||
transition: all 0.3s;
|
||
cursor: pointer;
|
||
}
|
||
|
||
body .node {
|
||
/* 平滑过渡 */
|
||
}
|
||
|
||
|
||
|
||
body .node text {
|
||
font: 14px sans-serif;
|
||
dominant-baseline: middle;
|
||
pointer-events: none;
|
||
/* 防止文本拦截点击事件 */
|
||
}
|
||
|
||
body .node-text-line {
|
||
font: 14px sans-serif;
|
||
text-anchor: middle;
|
||
dominant-baseline: middle;
|
||
}
|
||
|
||
body .node .node-main-shape {
|
||
/* 应用到跑道主体和图标背景圆 */
|
||
transition: filter 0.3s ease;
|
||
/* 添加过渡效果 */
|
||
/* filter: drop-shadow(2px 3px 3px rgba(0, 0, 0, 0.2)); */
|
||
/* 基础阴影 */
|
||
}
|
||
|
||
body .dragging .node-body,
|
||
.dragging .icon-circle-bg {
|
||
filter: drop-shadow(0px 0px 8px rgba(255, 77, 79, 0.5));
|
||
/* 红色光晕效果 */
|
||
}
|
||
|
||
body .node:hover .node-body,
|
||
.node:hover .icon-circle-bg,
|
||
.node--selected .node-body,
|
||
.node--selected .icon-circle-bg,
|
||
.node--multi-selected .node-body,
|
||
.node--multi-selected .icon-circle-bg {
|
||
filter: drop-shadow(3px 4px 5px rgba(0, 0, 0, 0.25));
|
||
/* 更深的阴影 */
|
||
}
|
||
|
||
body .node .node-body,
|
||
.node .icon-circle-bg {
|
||
filter: drop-shadow(1px 1px 2px rgba(0, 0, 0, 0.15));
|
||
}
|
||
|
||
body .link {
|
||
fill: none;
|
||
/* 连接线通常不填充 */
|
||
/* stroke: #ccc; */
|
||
/* 默认灰色,可以改成更柔和的 #ddd 或 #e0e0e0 */
|
||
stroke: #b0bec5;
|
||
/* 尝试一个柔和的蓝灰色 */
|
||
stroke-width: 1.5px;
|
||
/* 可以尝试 1px 或 2px */
|
||
stroke-opacity: 0.7;
|
||
/* 可以适当降低透明度,让节点更突出 */
|
||
transition: stroke 0.3s ease, stroke-width 0.3s ease;
|
||
/* 添加过渡效果 */
|
||
}
|
||
|
||
|
||
body .control-panel {
|
||
margin-bottom: 20px;
|
||
padding: px;
|
||
background: #f8f8f8;
|
||
border-radius: 5px;
|
||
}
|
||
|
||
body button {
|
||
padding: 6px 15px;
|
||
margin-right: 8px;
|
||
background-color: #1890ff;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
transition: background-color 0.3s;
|
||
}
|
||
|
||
body button:hover {
|
||
background-color: #40a9ff;
|
||
}
|
||
|
||
/* 选中和拖拽状态 */
|
||
body .node--selected rect {
|
||
fill: #e6f7ff;
|
||
stroke: #1890ff;
|
||
stroke-width: 2.5px;
|
||
}
|
||
|
||
body .node--multi-selected rect {
|
||
fill: #f6ffed;
|
||
stroke: #52c41a;
|
||
stroke-width: 2.5px;
|
||
}
|
||
|
||
body .multi-select-mode button#multi-select-btn {
|
||
background-color: #52c41a;
|
||
}
|
||
|
||
body .multi-select-switch {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
margin-right: 15px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
body .switch {
|
||
position: relative;
|
||
display: inline-block;
|
||
width: 40px;
|
||
height: 20px;
|
||
margin-right: 8px;
|
||
}
|
||
|
||
body .switch input {
|
||
opacity: 0;
|
||
width: 0;
|
||
height: 0;
|
||
}
|
||
|
||
body .slider {
|
||
position: absolute;
|
||
cursor: pointer;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background-color: #ccc;
|
||
transition: 0.4s;
|
||
border-radius: 34px;
|
||
}
|
||
|
||
body .slider:before {
|
||
position: absolute;
|
||
content: "";
|
||
height: 16px;
|
||
width: 16px;
|
||
left: 2px;
|
||
bottom: 2px;
|
||
background-color: white;
|
||
transition: 0.4s;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
body input:checked+.slider {
|
||
background-color: #52c41a;
|
||
}
|
||
|
||
body input:checked+.slider:before {
|
||
transform: translateX(20px);
|
||
}
|
||
|
||
body .dragging rect {
|
||
fill: #fff1f0;
|
||
stroke: #ff4d4f;
|
||
}
|
||
|
||
/* 潜在父节点高亮 */
|
||
body .potential-parent rect {
|
||
fill: rgba(24, 144, 255, 0.15);
|
||
stroke: #1890ff;
|
||
stroke-width: 2px;
|
||
}
|
||
|
||
/* 根节点高亮区域 */
|
||
body .root-hint {
|
||
fill: rgba(82, 196, 26, 0.15);
|
||
stroke: #52c41a;
|
||
stroke-width: 2px;
|
||
rx: 4;
|
||
ry: 4;
|
||
pointer-events: none;
|
||
}
|
||
|
||
body .tooltip {
|
||
position: absolute;
|
||
background: rgba(0, 0, 0, 0.7);
|
||
color: white;
|
||
padding: 5px 10px;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
pointer-events: none;
|
||
opacity: 0;
|
||
transition: opacity 0.3s;
|
||
z-index: 1000;
|
||
}
|
||
|
||
body .zoom-controls {
|
||
position: absolute;
|
||
right: 20px;
|
||
top: 80px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
z-index: 100;
|
||
}
|
||
|
||
body .zoom-btn {
|
||
width: 36px;
|
||
height: 36px;
|
||
background: white;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
margin-bottom: 5px;
|
||
font-size: 18px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
}
|
||
|
||
body .tree-label {
|
||
font-size: 14px;
|
||
font-weight: bold;
|
||
fill: #666;
|
||
}
|
||
|
||
body .tree-container {
|
||
border-top: 1px dashed #ddd;
|
||
padding-top: 30px;
|
||
margin-top: 20px;
|
||
}
|
||
|
||
body .tooltip {
|
||
position: absolute;
|
||
background: rgba(0, 0, 0, 0.7);
|
||
color: white;
|
||
padding: 5px 10px;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
pointer-events: none;
|
||
opacity: 0;
|
||
transition: opacity 0.3s;
|
||
z-index: 1000;
|
||
max-width: 300px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
/* 添加表格样式 */
|
||
body .tooltip-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
}
|
||
|
||
body .tooltip-table td {
|
||
padding: 2px 5px;
|
||
vertical-align: top;
|
||
}
|
||
|
||
body .tooltip-table td:first-child {
|
||
font-weight: bold;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* 添加状态标签样式 */
|
||
body .status-tag {
|
||
display: inline-block;
|
||
padding: 2px 6px;
|
||
border-radius: 3px;
|
||
font-size: 11px;
|
||
margin-left: 5px;
|
||
}
|
||
|
||
body .status-pending {
|
||
background-color: #faad14;
|
||
color: #fff;
|
||
}
|
||
|
||
body .status-done {
|
||
background-color: #52c41a;
|
||
color: #fff;
|
||
}
|
||
|
||
body .status-progress {
|
||
background-color: #1890ff;
|
||
color: #fff;
|
||
}
|
||
|
||
body .priority-high {
|
||
background-color: #f5222d;
|
||
color: #fff;
|
||
}
|
||
|
||
body .priority-medium {
|
||
background-color: #fa8c16;
|
||
color: #fff;
|
||
}
|
||
|
||
body .priority-low {
|
||
background-color: #52c41a;
|
||
color: #fff;
|
||
}
|
||
|
||
body .node .node-body {
|
||
/* Default runway body style */
|
||
stroke-width: 1.5px;
|
||
transition: stroke-width 0.2s ease-in-out, fill 0.2s ease-in-out;
|
||
}
|
||
|
||
body .node .icon-circle-bg {
|
||
/* Default icon circle style */
|
||
transition: stroke-width 0.2s ease-in-out, fill 0.2s ease-in-out;
|
||
}
|
||
|
||
body .node .node-icon-image {
|
||
pointer-events: none;
|
||
/* Make sure icon doesn't block clicks */
|
||
}
|
||
|
||
body .node .node-text-line {
|
||
font: 13px sans-serif;
|
||
/* Slightly smaller font maybe? */
|
||
text-anchor: middle;
|
||
dominant-baseline: middle;
|
||
pointer-events: none;
|
||
fill: #333;
|
||
/* Default text color */
|
||
}
|
||
|
||
|
||
/* --- Selection Styles for Runway Nodes --- */
|
||
body .node--selected .node-body {
|
||
/* fill: #e6f7ff; */
|
||
/* Example: Light blue fill on select */
|
||
stroke-width: 2.5px;
|
||
}
|
||
|
||
body .node--selected .icon-circle-bg {
|
||
/* Example: slightly darker background on select maybe? or just thicker stroke */
|
||
stroke: #096dd9;
|
||
/* Darker shade of the type's stroke */
|
||
stroke-width: 1px;
|
||
/* Add a stroke to the icon circle on select */
|
||
}
|
||
|
||
body .node--selected .node-text-line {
|
||
font-weight: bold;
|
||
}
|
||
|
||
|
||
body .node--multi-selected .node-body {
|
||
/* fill: #f6ffed; */
|
||
/* Example: Light green fill on multi-select */
|
||
stroke: #389e0d;
|
||
/* Green stroke for multi-select */
|
||
stroke-width: 2.5px;
|
||
}
|
||
|
||
body .node--multi-selected .icon-circle-bg {
|
||
/* fill: #73d13d; */
|
||
/* Example: Darker green for multi-select icon */
|
||
stroke: #237804;
|
||
/* Darker shade of green stroke */
|
||
stroke-width: 1px;
|
||
}
|
||
|
||
body .node--multi-selected .node-text-line {
|
||
font-weight: bold;
|
||
}
|
||
|
||
/* Dragging style for runway */
|
||
body .dragging .node-body {
|
||
fill: #fff1f0 !important;
|
||
/* Use !important to override type color if needed */
|
||
stroke: #ff4d4f !important;
|
||
}
|
||
|
||
body .dragging .icon-circle-bg {
|
||
fill: #ffccc7 !important;
|
||
stroke: #a8071a !important;
|
||
}
|
||
|
||
/* Potential parent style for runway */
|
||
body .potential-parent .node-body {
|
||
fill: rgba(24, 144, 255, 0.15) !important;
|
||
stroke: #1890ff !important;
|
||
stroke-width: 2px !important;
|
||
}
|
||
|
||
body .potential-parent .icon-circle-bg {
|
||
fill: rgba(24, 144, 255, 0.3) !important;
|
||
stroke: #096dd9 !important;
|
||
stroke-width: 1px !important;
|
||
}
|
||
|
||
body .modal {
|
||
display: none;
|
||
/* Hidden by default */
|
||
position: fixed;
|
||
z-index: 1000;
|
||
left: 0;
|
||
top: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow: auto;
|
||
background-color: rgba(0, 0, 0, 0.4);
|
||
/* Added for centering */
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
|
||
body .modal-content {
|
||
background-color: #fefefe;
|
||
margin: auto;
|
||
/* Default centering */
|
||
padding: 20px;
|
||
border: 1px solid #888;
|
||
width: 80%;
|
||
max-width: 600px;
|
||
/* Adjust max width as needed */
|
||
border-radius: 5px;
|
||
position: relative;
|
||
/* Needed for absolute positioning of close button */
|
||
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
|
||
animation-name: animatetop;
|
||
animation-duration: 0.4s
|
||
}
|
||
|
||
@keyframes animatetop {
|
||
from {
|
||
top: -300px;
|
||
opacity: 0
|
||
}
|
||
|
||
to {
|
||
top: 0;
|
||
opacity: 1
|
||
}
|
||
}
|
||
|
||
body .close-button {
|
||
color: #aaa;
|
||
position: absolute;
|
||
/* Position relative to modal-content */
|
||
top: 10px;
|
||
right: 15px;
|
||
font-size: 28px;
|
||
font-weight: bold;
|
||
}
|
||
|
||
body .close-button:hover,
|
||
body .close-button:focus {
|
||
color: black;
|
||
text-decoration: none;
|
||
cursor: pointer;
|
||
}
|
||
|
||
body #editNodeForm label {
|
||
display: block;
|
||
margin-bottom: 5px;
|
||
font-weight: bold;
|
||
color: #333;
|
||
}
|
||
|
||
body #editNodeForm input[type=text],
|
||
body #editNodeForm textarea,
|
||
body #editNodeForm select {
|
||
width: calc(100% - 18px);
|
||
/* Adjust for padding/border */
|
||
padding: 8px;
|
||
margin-bottom: 10px;
|
||
display: inline-block;
|
||
border: 1px solid #ccc;
|
||
border-radius: 4px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body #editNodeForm textarea {
|
||
resize: vertical;
|
||
/* Allow vertical resize */
|
||
}
|
||
|
||
body .modal-actions button {
|
||
padding: 10px 15px;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
margin-left: 10px;
|
||
}
|
||
|
||
body .modal-actions button#modal-save-btn {
|
||
background-color: #1890ff;
|
||
color: white;
|
||
}
|
||
|
||
body .modal-actions button#modal-enter-stage-btn {
|
||
background-color: #52c41a;
|
||
color: white;
|
||
}
|
||
|
||
body .modal-actions button#modal-cancel-btn {
|
||
background-color: #f0f0f0;
|
||
color: #333;
|
||
}
|
||
|
||
body .modal-actions button:disabled {
|
||
background-color: #d9d9d9;
|
||
cursor: not-allowed;
|
||
opacity: 0.7;
|
||
}
|
||
|
||
body #modal-status-area div {
|
||
padding: 5px 0;
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body>
|
||
<div class="control-panel">
|
||
<button id="add-btn">添加节点</button>
|
||
<button id="edit-btn">编辑节点</button>
|
||
<button id="delete-btn">删除节点</button>
|
||
<button id="refresh-btn" style="display: none">刷新</button>
|
||
<button id="fit-btn" style="display: none">适应视图</button>
|
||
</div>
|
||
|
||
<div id="tree-container"></div>
|
||
<div class="tooltip"></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="editNodeModal" class="modal"
|
||
style="display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.5); justify-content: center; align-items: center;">
|
||
<div class="modal-content"
|
||
style="background-color: #fefefe; margin: auto; padding: 20px; border: 1px solid #888; width: 80%; max-width: 600px; border-radius: 5px; position: relative;">
|
||
<span class="close-button"
|
||
style="color: #aaa; float: right; font-size: 28px; font-weight: bold; cursor: pointer;">×</span>
|
||
<h2>编辑节点信息</h2>
|
||
<form id="editNodeForm">
|
||
<input type="hidden" id="modal-node-original-id"> <!-- Store the internal DB ID -->
|
||
|
||
<div style="margin-bottom: 15px;">
|
||
<label style="display: block; margin-bottom: 5px;">ID:</label>
|
||
<span id="modal-node-id-display" style="font-weight: bold;"></span>
|
||
</div>
|
||
<div style="margin-bottom: 15px;">
|
||
<label style="display: block; margin-bottom: 5px;">需求ID (Req ID):</label>
|
||
<span id="modal-node-req_id" style="font-weight: bold;"></span>
|
||
</div>
|
||
<div style="margin-bottom: 15px;">
|
||
<label for="modal-node-name" style="display: block; margin-bottom: 5px;">名称:</label>
|
||
<input type="text" id="modal-node-name" required
|
||
style="width: 95%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;">
|
||
</div>
|
||
<div style="margin-bottom: 15px;">
|
||
<label for="modal-node-description" style="display: block; margin-bottom: 5px;">描述:</label>
|
||
<textarea id="modal-node-description" rows="3"
|
||
style="width: 95%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;"></textarea>
|
||
</div>
|
||
<div style="display: flex; justify-content: space-between; margin-bottom: 15px;">
|
||
<div style="width: 48%;">
|
||
<label for="modal-node-type" style="display: block; margin-bottom: 5px;">类型:</label>
|
||
<select id="modal-node-type" style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;">
|
||
<option value="功能需求">功能需求</option>
|
||
<option value="性能需求">性能需求</option>
|
||
<option value="安全需求">安全需求</option>
|
||
<option value="合规性需求">合规性需求</option>
|
||
<option value="可靠性需求">可靠性需求</option>
|
||
<option value="无">无</option>
|
||
<!-- Add other types if needed -->
|
||
</select>
|
||
</div>
|
||
<div style="width: 48%;">
|
||
<label for="modal-node-status" style="display: block; margin-bottom: 5px;">状态:</label>
|
||
<select id="modal-node-status"
|
||
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;">
|
||
<option value="待处理">待处理</option>
|
||
<option value="进行中">进行中</option>
|
||
<option value="已完成">已完成</option>
|
||
<option value="已评审">已评审</option>
|
||
<option value="已关闭">已关闭</option>
|
||
<option value="无">无</option>
|
||
<!-- Add other statuses if needed -->
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div style="display: flex; justify-content: space-between; margin-bottom: 15px;">
|
||
<div style="width: 48%;">
|
||
<label for="modal-node-priority" style="display: block; margin-bottom: 5px;">优先级:</label>
|
||
<select id="modal-node-priority"
|
||
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;">
|
||
<option value="高">高</option>
|
||
<option value="中">中</option>
|
||
<option value="低">低</option>
|
||
<option value="无">无</option>
|
||
<!-- Add other priorities if needed -->
|
||
</select>
|
||
</div>
|
||
<div style="width: 48%;">
|
||
<label style="display: block; margin-bottom: 5px;">层级:</label>
|
||
<span id="modal-node-level"></span>
|
||
</div>
|
||
</div>
|
||
<div style="margin-bottom: 15px;">
|
||
<label style="display: block; margin-bottom: 5px;">父需求ID:</label>
|
||
<span id="modal-node-parent_req_id"></span>
|
||
</div>
|
||
<div style="margin-bottom: 15px; font-size: 0.9em; color: #666;">
|
||
创建于: <span id="modal-node-created_at"></span> | 更新于: <span id="modal-node-updated_at"></span>
|
||
</div>
|
||
|
||
<div class="modal-actions" style="text-align: right; margin-top: 20px;">
|
||
<button type="button" id="modal-enter-stage-btn"
|
||
style="padding: 8px 15px; background-color: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer; margin-right: 10px;">进入Stage</button>
|
||
<button type="button" id="modal-cancel-btn"
|
||
style="padding: 8px 15px; background-color: #ccc; color: #333; border: none; border-radius: 4px; cursor: pointer; margin-right: 10px;">取消</button>
|
||
<button type="submit" id="modal-save-btn"
|
||
style="padding: 8px 15px; background-color: #1890ff; color: white; border: none; border-radius: 4px; cursor: pointer;">保存更改</button>
|
||
</div>
|
||
<div id="modal-status-area" style="margin-top: 15px; text-align: left;">
|
||
<div id="modal-loading" style="display:none; color: #1890ff;">处理中...</div>
|
||
<div id="modal-error" style="display:none; color: red; font-weight: bold;"></div>
|
||
<div id="modal-message" style="display:none; color: green;"></div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
function initD3() {
|
||
// 全局变量
|
||
const API_BASE_URL = "http://127.0.0.1:5002/api";
|
||
|
||
|
||
const nodeTypeStyles = {
|
||
"功能需求": {
|
||
fill: "#e6f7ff", // Light blue fill
|
||
stroke: "#1890ff", // Blue stroke
|
||
// icon: "/assets/function.svg" // Example path, replace with your actual icon
|
||
icon: "/assets/icons/demand.svg" // Optional default icon
|
||
},
|
||
"性能需求": {
|
||
fill: "#fffbe6", // Light yellow fill
|
||
stroke: "#faad14", // Yellow stroke
|
||
// icon: "/assets/performance.svg"
|
||
icon: "/assets/icons/speed.svg" // Optional default icon
|
||
|
||
},
|
||
"安全需求": {
|
||
fill: "#fff1f0", // Light red fill
|
||
stroke: "#f5222d", // Red stroke
|
||
// icon: "/assets/security.svg"
|
||
icon: "/assets/icons/safety.svg" // Optional default icon
|
||
|
||
},
|
||
"合规性需求": {
|
||
fill: "#f6ffed", // Light green fill
|
||
stroke: "#52c41a", // Green stroke
|
||
// icon: "/assets/compliance.svg"
|
||
icon: "/assets/icons/law.svg" // Optional default icon
|
||
|
||
},
|
||
"可靠性需求": {
|
||
fill: "#e6fffb", // Light cyan fill
|
||
stroke: "#13c2c2", // Cyan stroke
|
||
// icon: "/assets/reliability.svg"
|
||
icon: "/assets/icons/stable.svg" // Optional default icon
|
||
|
||
},
|
||
"default": { // Fallback for unknown or "无" types
|
||
fill: "#ffffff", // White fill
|
||
stroke: "#d9d9d9", // Grey stroke
|
||
icon: "/assets/icons/demand.svg" // Optional default icon
|
||
}
|
||
};
|
||
|
||
console.log("init");
|
||
let treeData = [];
|
||
let selectedNodeId = null; // 存储选中节点的ID而非DOM元素
|
||
let zoomBehavior;
|
||
let lastPotentialParent = null; // 跟踪上一个潜在父节点
|
||
let treeGroups = []; // 存储每棵树的组引用
|
||
let treeOffsets = []; // 存储每棵树的垂直偏移
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
let projectId = urlParams.get("project_id");
|
||
// 安全获取容器尺寸
|
||
const container = $node.children[1];
|
||
const containerWidth = container ? container.clientWidth : 800; // 默认值
|
||
const containerHeight = container ? container.clientHeight : 600; // 默认值
|
||
console.log(
|
||
"container width",
|
||
containerWidth,
|
||
"containerHeight",
|
||
containerHeight
|
||
);
|
||
// 设置图表尺寸和边距 - 使用安全值
|
||
const margin = { top: 40, right: 20, bottom: 40, left: 20 };
|
||
const width = Math.max(
|
||
100,
|
||
containerWidth - margin.left - margin.right
|
||
);
|
||
const height = Math.max(
|
||
100,
|
||
containerHeight - margin.top - margin.bottom
|
||
);
|
||
|
||
// 创建工具提示
|
||
const tooltip = d3.select(".tooltip");
|
||
|
||
// 创建SVG容器 - 检查选择器是否有效
|
||
const svgContainer = d3.select("#tree-container");
|
||
if (svgContainer.empty()) {
|
||
console.error("Container #tree-container not found");
|
||
return;
|
||
}
|
||
|
||
const svg = svgContainer
|
||
.append("svg")
|
||
.attr("width", "100%")
|
||
.attr("height", "100%")
|
||
.on("click", function (event) {
|
||
// 只有点击到SVG背景而非节点时才取消选择
|
||
if (event.target.tagName === "svg") {
|
||
selectedNodeId = null;
|
||
mainGroup.selectAll(".node").classed("node--selected", false);
|
||
}
|
||
});
|
||
|
||
// 创建主要图形组
|
||
const mainGroup = svg
|
||
.append("g")
|
||
.attr("transform", `translate(${margin.left},${margin.top})`);
|
||
// console.log("minG1",mainGroup)
|
||
// 根节点高亮指示器
|
||
const rootHint = mainGroup
|
||
.append("rect")
|
||
.attr("class", "root-hint")
|
||
.style("opacity", 0);
|
||
|
||
// 初始化zoom行为
|
||
zoomBehavior = d3
|
||
.zoom()
|
||
.scaleExtent([0.1, 3])
|
||
.on("zoom", (event) => {
|
||
mainGroup.attr("transform", event.transform);
|
||
});
|
||
|
||
svg.call(zoomBehavior);
|
||
|
||
// 适应视图函数
|
||
function fitView() {
|
||
const bounds = mainGroup.node().getBBox();
|
||
const fullWidth = isNaN(bounds.width) ? width : bounds.width;
|
||
const fullHeight = isNaN(bounds.height) ? height : bounds.height;
|
||
|
||
// 确保不使用NaN值
|
||
if (
|
||
isNaN(fullWidth) ||
|
||
isNaN(fullHeight) ||
|
||
fullWidth <= 0 ||
|
||
fullHeight <= 0
|
||
) {
|
||
console.warn(
|
||
"Invalid dimensions for fitView:",
|
||
fullWidth,
|
||
fullHeight
|
||
);
|
||
return;
|
||
}
|
||
|
||
let scale = Math.min(width / fullWidth, height / fullHeight) * 0.9;
|
||
//console.log(scale)
|
||
|
||
const maxScale = 3;
|
||
scale = Math.min(scale, maxScale);
|
||
const translateX =
|
||
(width - 0.5 * fullWidth * scale) / 2 + margin.left;
|
||
const translateY = (height - fullHeight * scale) / 2 + margin.top;
|
||
|
||
svg
|
||
.transition()
|
||
.duration(750)
|
||
.call(
|
||
zoomBehavior.transform,
|
||
d3.zoomIdentity.translate(translateX, translateY).scale(scale)
|
||
);
|
||
}
|
||
|
||
// 将后端数据转换为D3可用的格式
|
||
function convertToD3Format(data) {
|
||
// 防御性检查
|
||
if (!Array.isArray(data)) {
|
||
console.error("Expected array data, got:", data);
|
||
return [];
|
||
}
|
||
|
||
const map = {};
|
||
const roots = [];
|
||
|
||
// 首先创建所有节点的映射
|
||
data.forEach((item) => {
|
||
map[item.id] = {
|
||
id: item.id,
|
||
name: item.name || "未命名",
|
||
level: item.level || 0,
|
||
description: item.description || "无描述",
|
||
parent_id: item.parent_id || "-1",
|
||
parent_req_id: item.parent_req_id || "-1",
|
||
req_id: item.req_id || "-1",
|
||
priority: item.priority || "无",
|
||
status: item.status || "无",
|
||
type: item.type || "无",
|
||
created_at: item.created_at || "无",
|
||
updated_at: item.updated_at || "无",
|
||
children: [],
|
||
};
|
||
});
|
||
|
||
// 构建树形结构
|
||
data.forEach((item) => {
|
||
const node = map[item.id];
|
||
if (!node) return; // 防御性检查
|
||
|
||
if (
|
||
!item.parent_id ||
|
||
item.parent_id === "" ||
|
||
item.parent_id === "-1"
|
||
) {
|
||
roots.push(node);
|
||
} else {
|
||
const parent = map[item.parent_id];
|
||
if (parent) {
|
||
parent.children.push(node);
|
||
} else {
|
||
// 如果找不到父节点,将其作为根节点
|
||
console.warn(
|
||
`找不到父节点 ID:${item.parent_id},将节点 ID:${item.id} 作为根节点`
|
||
);
|
||
roots.push(node);
|
||
}
|
||
}
|
||
});
|
||
|
||
return roots;
|
||
}
|
||
|
||
|
||
function updateTree(rootNodes) {
|
||
// 清除节点和连接
|
||
mainGroup.selectAll(".tree-container").remove();
|
||
treeGroups = [];
|
||
treeOffsets = [];
|
||
|
||
if (!rootNodes || rootNodes.length === 0) {
|
||
mainGroup
|
||
.append("text")
|
||
.attr("x", width / 2)
|
||
.attr("y", height / 2)
|
||
.attr("text-anchor", "middle")
|
||
.text("暂无数据");
|
||
return;
|
||
}
|
||
|
||
// 计算每棵树的垂直偏移
|
||
const treeSpacing = 200; // 树之间的间距
|
||
let totalHeight = 0;
|
||
// console.log(rootNodes);
|
||
// 为每棵树创建独立的布局和渲染
|
||
rootNodes.forEach((rootData, index) => {
|
||
try {
|
||
// 防御性检查
|
||
if (!rootData || typeof rootData !== "object") {
|
||
console.error(`Invalid root data at index ${index}:`, rootData);
|
||
return;
|
||
}
|
||
|
||
treeOffsets.push(totalHeight);
|
||
|
||
// 创建树容器
|
||
const treeContainer = mainGroup
|
||
.append("g")
|
||
.attr("class", "tree-container")
|
||
.attr("transform", `translate(0, ${totalHeight})`);
|
||
|
||
treeGroups.push(treeContainer);
|
||
|
||
// 添加树标签
|
||
treeContainer
|
||
.append("text")
|
||
.attr("class", "tree-label")
|
||
.attr("x", width / 2)
|
||
.attr("y", -25)
|
||
.attr("text-anchor", "middle")
|
||
.text(`${rootData.name || "Tree " + (index + 1)}`);
|
||
|
||
// 转换数据为层次结构
|
||
const root = d3.hierarchy(rootData);
|
||
//console.log("root",root)
|
||
// 创建自定义树布局 - 确保足够的垂直空间防止重叠
|
||
const treeLayout = d3
|
||
.tree()
|
||
.nodeSize([150, 120]) // 增加水平间距[宽度, 高度]
|
||
.separation(function (a, b) {
|
||
// 根据节点深度动态调整间距
|
||
return a.parent === b.parent ? 1.5 : 2.5;
|
||
});
|
||
|
||
// 计算该棵树的节点位置 - 添加错误处理
|
||
try {
|
||
treeLayout(root);
|
||
} catch (e) {
|
||
console.error("Error during tree layout calculation:", e);
|
||
return;
|
||
}
|
||
|
||
// 水平居中处理 - 添加防御性检查
|
||
let minX = Infinity,
|
||
maxX = -Infinity;
|
||
let validDescendants = root
|
||
.descendants()
|
||
.filter((d) => !isNaN(d.x) && !isNaN(d.y));
|
||
|
||
if (validDescendants.length === 0) {
|
||
console.warn("No valid descendants for tree layout");
|
||
return;
|
||
}
|
||
|
||
validDescendants.forEach((d) => {
|
||
if (d.x < minX) minX = d.x;
|
||
if (d.x > maxX) maxX = d.x;
|
||
});
|
||
|
||
const centerOffset = (width * 5) / 4;
|
||
// width / 2 - (maxX + minX) / 2+800;
|
||
// console.log("centerOffset", centerOffset, width, maxX, minX);
|
||
// 应用水平居中偏移 - 确保不使用NaN
|
||
validDescendants.forEach((d) => {
|
||
d.x = !isNaN(d.x) ? d.x + centerOffset : centerOffset;
|
||
});
|
||
|
||
// 计算树高度
|
||
let maxY = 0;
|
||
validDescendants.forEach((d) => {
|
||
if (d.depth > 0 && d.y > maxY && !isNaN(d.y)) maxY = d.y;
|
||
});
|
||
|
||
// 最小树高度,确保下一棵树有足够空间
|
||
const treeHeight = Math.max(300, maxY + 100);
|
||
totalHeight += treeHeight + treeSpacing;
|
||
|
||
// 计算节点和连接线
|
||
const nodes = validDescendants;
|
||
const links = root
|
||
.links()
|
||
.filter(
|
||
(link) =>
|
||
!isNaN(link.source.x) &&
|
||
!isNaN(link.source.y) &&
|
||
!isNaN(link.target.x) &&
|
||
!isNaN(link.target.y)
|
||
);
|
||
|
||
// 定义垂直连接线生成器 - 添加防御性检查
|
||
const diagonal = d3
|
||
.linkHorizontal()
|
||
.x((d) => (isNaN(d.x) ? 0 : d.x))
|
||
.y((d) => (isNaN(d.y) ? 0 : d.y));
|
||
|
||
// 绘制连接线
|
||
treeContainer
|
||
.selectAll(".link")
|
||
.data(links)
|
||
.enter()
|
||
.append("path")
|
||
.attr("class", "link")
|
||
.attr("d", (d) => {
|
||
try {
|
||
return diagonal(d);
|
||
} catch (e) {
|
||
console.error("Error generating link path:", e, d);
|
||
return "M0,0"; // 返回空路径
|
||
}
|
||
});
|
||
|
||
const nodeGroups = treeContainer
|
||
.selectAll(".node")
|
||
.data(nodes, d => d.data.id) // Use key function for better updates
|
||
.join(
|
||
enter => enter.append("g")
|
||
.attr("class", "node")
|
||
.attr("transform", (d) => {
|
||
const x = isNaN(d.x) ? 0 : d.x;
|
||
const y = isNaN(d.y) ? 0 : d.y;
|
||
return `translate(${x},${y})`;
|
||
})
|
||
.attr("data-id", (d) => d.data.id)
|
||
.attr("data-tree-index", index)
|
||
.attr("data-is-single-node", "false"),
|
||
update => update // Apply updates if needed (e.g., position change)
|
||
.attr("transform", (d) => {
|
||
const x = isNaN(d.x) ? 0 : d.x;
|
||
const y = isNaN(d.y) ? 0 : d.y;
|
||
return `translate(${x},${y})`;
|
||
}),
|
||
exit => exit.remove() // Remove nodes that no longer exist
|
||
);
|
||
|
||
|
||
// --- NEW: Render each node with runway shape and icon ---
|
||
nodeGroups.each(function (d) {
|
||
const node = d3.select(this);
|
||
const nodeData = d.data;
|
||
|
||
// 清理旧内容 (重要!)
|
||
node.selectAll("*").remove();
|
||
|
||
// 获取类型配置
|
||
const nodeType = nodeData.type || "default";
|
||
const typeConfig = nodeTypeStyles[nodeType] || nodeTypeStyles["default"];
|
||
|
||
let nodeName = nodeData.name || "未命名";
|
||
const minTextWidth = 60; // 文本区域最小宽度
|
||
const minHeight = 36; // 节点最小高度
|
||
const textPaddingVertical = 5;
|
||
const textPaddingHorizontal = 12; // 文本左右内边距
|
||
const iconPadding = 4; // 图标和文本之间的间隙 (视觉上)
|
||
|
||
// --- 动态计算尺寸 ---
|
||
// 1. 估算文本尺寸 (需要 splitTextIntoLines 函数)
|
||
const tempText = svg.append("text").attr("class", "node-text-line temp-text-measure").style("opacity", 0);
|
||
// 调整每行最大字符数估算 (这里假设平均字符宽度,可以按需调整)
|
||
nodeName = nodeName.length > 24 ? nodeName.substring(0, 24) + "..." : nodeName;
|
||
const lines = splitTextIntoLines(nodeName, 9);
|
||
let actualTextWidth = 0;
|
||
lines.forEach((line) => {
|
||
const bbox = tempText.text(line).node().getBBox();
|
||
actualTextWidth = Math.max(actualTextWidth, bbox.width);
|
||
});
|
||
tempText.remove();
|
||
|
||
// 2. 计算节点高度和文本部分宽度
|
||
const lineHeight = 16; // 大致行高
|
||
const textHeight = Math.max(minHeight - 2 * textPaddingVertical, lines.length * lineHeight);
|
||
const nodeHeight = textHeight + 2 * textPaddingVertical;
|
||
const textPartWidth = Math.max(minTextWidth, actualTextWidth) + 2 * textPaddingHorizontal; // 包含左右padding的总文本区宽度
|
||
|
||
// 3. 计算跑道形状的总宽度和圆角半径
|
||
const r = nodeHeight / 2; // 圆角半径等于高度一半
|
||
// 总宽度 = 左侧圆角区域宽度(等于高度) + 文本部分宽度
|
||
const nodeWidth = nodeHeight + textPartWidth;
|
||
|
||
// 4. 计算图标和文本的中心X坐标 (相对于节点组的0,0原点)
|
||
// 图标区域中心X = 整个跑道左边缘 + 半径r
|
||
const iconCenterX = -nodeWidth / 2 + r;
|
||
// 文本组中心X = 图标区域结束位置 + 文本区域宽度的一半
|
||
// 图标区域结束于 -nodeWidth/2 + 2*r
|
||
// 文本区域从 -nodeWidth/2 + 2*r 开始,宽度为 textPartWidth
|
||
// 文本组中心点 = (-nodeWidth/2 + 2*r) + textPartWidth / 2
|
||
// 代入 nodeWidth = nodeHeight + textPartWidth = 2*r + textPartWidth
|
||
// 中心点 = (-(2*r + textPartWidth)/2 + 2*r) + textPartWidth / 2
|
||
// = (-r - textPartWidth/2 + 2*r) + textPartWidth / 2
|
||
// = r - textPartWidth/2 + textPartWidth/2
|
||
// = r
|
||
const textGroupCenterX = r; // 文本组的中心X坐标为 r (即 nodeHeight/2)
|
||
|
||
|
||
// --- 1. 绘制跑道形主体 ---
|
||
node.append("path")
|
||
.attr("class", "node-body node-main-shape")
|
||
.attr("d", () => {
|
||
// 使用计算出的 nodeWidth, nodeHeight, r 绘制完整的跑道形
|
||
// M = MoveTo, L = LineTo, A = ArcTo, Z = ClosePath
|
||
const x1 = -nodeWidth / 2; // 最左边X
|
||
const x2 = nodeWidth / 2; // 最右边X
|
||
const y1 = -nodeHeight / 2;// 最上边Y
|
||
const y2 = nodeHeight / 2; // 最下边Y
|
||
|
||
return `M ${x1 + r}, ${y1}` + // 移动到左上圆弧结束点 (顶部直线起点)
|
||
` L ${x2 - r}, ${y1}` + // 绘制顶部直线
|
||
` A ${r},${r} 0 0 1 ${x2}, ${y1 + r}` + // 绘制右上圆弧
|
||
` L ${x2}, ${y2 - r}` + // 绘制右边直线
|
||
` A ${r},${r} 0 0 1 ${x2 - r}, ${y2}` + // 绘制右下圆弧
|
||
` L ${x1 + r}, ${y2}` + // 绘制底部直线
|
||
` A ${r},${r} 0 0 1 ${x1}, ${y2 - r}` + // 绘制左下圆弧
|
||
` L ${x1}, ${y1 + r}` + // 绘制左边直线
|
||
` A ${r},${r} 0 0 1 ${x1 + r}, ${y1}` + // 绘制左上圆弧
|
||
` Z`; // 闭合路径
|
||
})
|
||
.attr("fill", typeConfig.fill || nodeTypeStyles.default.fill)
|
||
.attr("stroke", typeConfig.stroke || nodeTypeStyles.default.stroke)
|
||
.attr("stroke-width", 1.5);
|
||
|
||
// --- 2. 绘制图标组 (图标背景+图标) ---
|
||
const iconGroup = node.append("g")
|
||
.attr("class", "node-icon-group")
|
||
.attr("transform", `translate(${iconCenterX}, 0)`); // 定位到左侧圆弧中心
|
||
|
||
// 图标背景圆 (可选,增加视觉效果)
|
||
// 可以用跑道本身的填充色,或者稍微不同的颜色
|
||
iconGroup.append("circle")
|
||
.attr("class", "icon-circle-bg") // 不加 node-main-shape,因为它在主体内部
|
||
.attr("r", r)
|
||
.attr("fill", typeConfig.stroke || nodeTypeStyles.default.stroke) // 用边框色做背景突出图标
|
||
.attr("fill-opacity", 0.8) // 可以稍微透明
|
||
.attr("stroke", "none");
|
||
|
||
// 图标图片 或 回退文本
|
||
const iconCircleRadiusEffective = r * 0.9; // 图标实际可用的圆半径
|
||
if (typeConfig.icon) {
|
||
const iconSize = iconCircleRadiusEffective * 1.4; // 图标显示尺寸,比背景圆稍大一点点
|
||
iconGroup.append("image")
|
||
.attr("class", "node-icon-image")
|
||
.attr("href", typeConfig.icon)
|
||
.attr("width", iconSize)
|
||
.attr("height", iconSize)
|
||
.attr("x", -iconSize / 2) // 图片中心对准圆心
|
||
.attr("y", -iconSize / 2);
|
||
} else {
|
||
// 回退: 显示类型首字母
|
||
iconGroup.append("text")
|
||
.attr("class", "icon-text-fallback")
|
||
.attr("text-anchor", "middle")
|
||
.attr("dominant-baseline", "middle")
|
||
.attr("fill", typeConfig.fill || "#fff") // 用节点填充色或白色
|
||
.attr("font-size", `${Math.min(iconCircleRadiusEffective * 0.9, 14)}px`)
|
||
.attr("font-weight", "bold")
|
||
.text(nodeType.substring(0, 1));
|
||
}
|
||
|
||
// --- 3. 绘制文本组 ---
|
||
const textGroup = node.append("g")
|
||
.attr("class", "node-text-group")
|
||
.attr("transform", `translate(${textGroupCenterX}, 0)`); // 定位文本组的中心
|
||
|
||
const startY = - (lines.length - 1) * lineHeight / 2; // 计算垂直居中的起始Y
|
||
|
||
lines.forEach((line, i) => {
|
||
textGroup.append("text")
|
||
.attr("class", "node-text-line")
|
||
.attr("x", 0) // text-anchor=middle 会让其基于 x=0 居中
|
||
.attr("y", startY + i * lineHeight)
|
||
.attr("text-anchor", "middle") // 确保文本在文本组内居中
|
||
.text(line);
|
||
});
|
||
|
||
});
|
||
|
||
// --- Update Selection Classes (after drawing) ---
|
||
nodeGroups
|
||
.classed("node--selected", d => selectedNodeId === d.data.id)
|
||
// 添加悬停事件
|
||
nodeGroups
|
||
.on("mouseover", (event, d) => {
|
||
// 显示工具提示
|
||
// console.log("mouseover", d);
|
||
tooltip.transition().duration(200).style("opacity", 0.9);
|
||
|
||
// 格式化日期
|
||
const formatDate = (dateStr) => {
|
||
if (!dateStr) return "未设置";
|
||
try {
|
||
const date = new Date(dateStr);
|
||
return date.toLocaleString("zh-CN", {
|
||
year: "numeric",
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
});
|
||
} catch (e) {
|
||
return dateStr;
|
||
}
|
||
};
|
||
|
||
// 获取状态样式类
|
||
const getStatusClass = (status) => {
|
||
if (!status) return "";
|
||
if (status.includes("待")) return "status-pending";
|
||
if (status.includes("完成")) return "status-done";
|
||
if (status.includes("进行")) return "status-progress";
|
||
return "";
|
||
};
|
||
|
||
// 获取优先级样式类
|
||
const getPriorityClass = (priority) => {
|
||
if (!priority) return "";
|
||
if (priority.includes("高")) return "priority-high";
|
||
if (priority.includes("中")) return "priority-medium";
|
||
if (priority.includes("低")) return "priority-low";
|
||
return "";
|
||
};
|
||
|
||
// 构建HTML表格
|
||
|
||
const tooltipContent = `
|
||
<table class="tooltip-table">
|
||
<tr><td>ID:</td><td>${d.data.id || "未设置"}</td></tr>
|
||
<tr><td>需求ID:</td><td>${d.data.req_id || "未设置"}</td></tr>
|
||
<tr><td>名称:</td><td>${d.data.name || "未命名"}</td></tr>
|
||
<tr><td>描述:</td><td>${d.data.description || "无描述"}</td></tr>
|
||
<tr><td>类型:</td><td>${d.data.type || "未设置"}</td></tr>
|
||
<tr><td>状态:</td><td>
|
||
${d.data.status
|
||
? `<span class="status-tag ${getStatusClass(
|
||
d.data.status
|
||
)}">${d.data.status}</span>`
|
||
: ""
|
||
}
|
||
</td></tr>
|
||
<tr><td>优先级:</td><td>
|
||
${d.data.priority
|
||
? `<span class="status-tag ${getPriorityClass(
|
||
d.data.priority
|
||
)}">${d.data.priority}</span>`
|
||
: ""
|
||
}
|
||
</td></tr>
|
||
<tr><td>层级:</td><td>${d.data.level !== undefined ? d.data.level : "未设置"
|
||
}</td></tr>
|
||
<tr><td>父需求ID:</td><td>${d.data.parent_req_id || "无"
|
||
}</td></tr>
|
||
<tr><td>创建时间:</td><td>${formatDate(
|
||
d.data.created_at
|
||
)}</td></tr>
|
||
<tr><td>更新时间:</td><td>${formatDate(
|
||
d.data.updated_at
|
||
)}</td></tr>
|
||
</table>
|
||
`;
|
||
|
||
tooltip
|
||
.html(tooltipContent)
|
||
.style("left", event.pageX + 10 + "px")
|
||
.style("top", event.pageY - 28 + "px");
|
||
})
|
||
.on("mouseout", () => {
|
||
// 隐藏工具提示
|
||
tooltip.transition().duration(500).style("opacity", 0);
|
||
}).on("contextmenu", function (event, d) {
|
||
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
|
||
if (isMultiSelectMode) {
|
||
// 多选模式下的点击处理
|
||
handleMultiSelectClick(this, d);
|
||
return;
|
||
} else {
|
||
console.log("单选模式");
|
||
mainGroup.selectAll(".node").classed("node--selected", false);
|
||
d3.select(this).classed("node--selected", true);
|
||
selectedNodeId = d.data.id;
|
||
editNode()
|
||
|
||
|
||
return;
|
||
}
|
||
|
||
// createEndpointCard(d);
|
||
});
|
||
|
||
// 添加拖拽功能
|
||
setupDrag(nodeGroups);
|
||
} catch (e) {
|
||
console.error(`Error processing tree at index ${index}:`, e);
|
||
}
|
||
});
|
||
|
||
// 自动适应视图
|
||
setTimeout(fitView, 300); // 增加延迟确保DOM已更新
|
||
}
|
||
|
||
// 设置拖拽功能
|
||
function setupDrag(nodes) {
|
||
const dragHandler = d3
|
||
.drag()
|
||
.on("start", dragStarted)
|
||
.on("drag", dragged)
|
||
.on("end", dragEnded);
|
||
|
||
nodes.call(dragHandler);
|
||
}
|
||
function splitTextIntoLines(text, maxCharsPerLine) {
|
||
if (!text) return [""];
|
||
// Basic check for CJK characters which take more space
|
||
const charWidthFactor = /[一-龯]/.test(text) ? 0.6 : 1; // CJK chars take roughly 1/0.6 = 1.6x space
|
||
let adjustedMaxChars = Math.max(5, Math.floor(maxCharsPerLine * charWidthFactor));
|
||
adjustedMaxChars = maxCharsPerLine
|
||
// Simple splitting logic (can be improved)
|
||
const lines = [];
|
||
let currentLine = "";
|
||
for (let i = 0; i < text.length; i++) {
|
||
const char = text[i];
|
||
if (currentLine.length >= adjustedMaxChars) {
|
||
lines.push(currentLine);
|
||
currentLine = char;
|
||
} else {
|
||
currentLine += char;
|
||
}
|
||
}
|
||
if (currentLine) {
|
||
lines.push(currentLine);
|
||
}
|
||
return lines.length > 0 ? lines : [""];
|
||
}
|
||
|
||
// 拖拽开始
|
||
function dragStarted(event, d) {
|
||
if (!event || !event.sourceEvent) return;
|
||
event.sourceEvent.stopPropagation();
|
||
|
||
// 记录初始位置,用于判断是否为点击
|
||
d.startX = event.x;
|
||
d.startY = event.y;
|
||
|
||
d3.select(this).raise().classed("dragging", true);
|
||
d.dx = 0;
|
||
d.dy = 0;
|
||
|
||
// 记录原始树索引
|
||
d.originalTreeIndex = parseInt(
|
||
d3.select(this).attr("data-tree-index")
|
||
);
|
||
if (isNaN(d.originalTreeIndex)) d.originalTreeIndex = 0; // 默认值
|
||
|
||
// 隐藏根节点提示
|
||
rootHint.style("opacity", 0);
|
||
lastPotentialParent = null;
|
||
}
|
||
|
||
// 拖拽中
|
||
function dragged(event, d) {
|
||
if (!event) return;
|
||
|
||
d.dx = (d.dx || 0) + event.dx;
|
||
d.dy = (d.dy || 0) + event.dy;
|
||
|
||
// 确保坐标有效
|
||
const x = isNaN(d.x) ? 0 : d.x;
|
||
const y = isNaN(d.y) ? 0 : d.y;
|
||
const dx = isNaN(d.dx) ? 0 : d.dx;
|
||
const dy = isNaN(d.dy) ? 0 : d.dy;
|
||
|
||
d3.select(this).attr("transform", `translate(${x + dx}, ${y + dy})`);
|
||
|
||
// 清除上一个潜在父节点的高亮
|
||
if (lastPotentialParent) {
|
||
d3.select(lastPotentialParent).classed("potential-parent", false);
|
||
lastPotentialParent = null;
|
||
}
|
||
|
||
// 检查是否有潜在的放置目标
|
||
const targetNode = findClosestNode(event, d);
|
||
|
||
if (targetNode && targetNode.element) {
|
||
// 直接高亮目标节点,而不是创建虚影
|
||
const targetElement = targetNode.element;
|
||
d3.select(targetElement).classed("potential-parent", true);
|
||
lastPotentialParent = targetElement;
|
||
|
||
// 隐藏根节点提示
|
||
rootHint.style("opacity", 0);
|
||
} else {
|
||
// 检查是否足够远,表示可能成为根节点
|
||
const distanceFromOrigin = Math.sqrt(dx * dx + dy * dy);
|
||
if (distanceFromOrigin > 150 && d.parent) {
|
||
// 已经是根节点的不处理
|
||
// 显示"成为根节点"的提示
|
||
try {
|
||
const nodeRect = d3
|
||
.select(this)
|
||
.select("rect")
|
||
.node()
|
||
.getBBox();
|
||
|
||
rootHint
|
||
.attr("x", x + dx - nodeRect.width / 2)
|
||
.attr("y", y + dy - nodeRect.height / 2)
|
||
.attr("width", nodeRect.width)
|
||
.attr("height", nodeRect.height)
|
||
.style("opacity", 0.7);
|
||
} catch (e) {
|
||
console.warn("Error displaying root hint:", e);
|
||
rootHint.style("opacity", 0);
|
||
}
|
||
} else {
|
||
rootHint.style("opacity", 0);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 拖拽结束
|
||
function dragEnded(event, d) {
|
||
if (!event) return;
|
||
|
||
// 计算拖拽距离
|
||
const dragDistance = Math.sqrt(
|
||
Math.pow(event.x - (d.startX || 0), 2) +
|
||
Math.pow(event.y - (d.startY || 0), 2)
|
||
);
|
||
|
||
// 重置样式
|
||
d3.select(this).classed("dragging", false);
|
||
rootHint.style("opacity", 0);
|
||
|
||
// 清除潜在父节点高亮
|
||
if (lastPotentialParent) {
|
||
d3.select(lastPotentialParent).classed("potential-parent", false);
|
||
lastPotentialParent = null;
|
||
}
|
||
|
||
// 如果移动距离小于阈值,视为点击
|
||
if (dragDistance < 5) {
|
||
// 处理点击选中逻辑
|
||
mainGroup.selectAll(".node").classed("node--selected", false);
|
||
d3.select(this).classed("node--selected", true);
|
||
selectedNodeId = d.data.id;
|
||
return;
|
||
}
|
||
|
||
// 检查是否拖拽到其他节点附近
|
||
const targetNode = findClosestNode(event, d);
|
||
|
||
if (
|
||
targetNode &&
|
||
targetNode.node !== d &&
|
||
targetNode.node.data.id !== d.data.id
|
||
) {
|
||
// 避免循环引用 - 不能将节点拖动到其子节点
|
||
if (isDescendantOf(d, targetNode.node)) {
|
||
alert("不能将节点移动到其子节点下");
|
||
updateTree(treeData);
|
||
return;
|
||
}
|
||
|
||
// 更新父子关系
|
||
updateParentRelationship(d.data.id, targetNode.node.data.id);
|
||
} else {
|
||
// 检查是否足够远离原点,表示成为根节点
|
||
const distanceFromOrigin = Math.sqrt(
|
||
(d.dx || 0) * (d.dx || 0) + (d.dy || 0) * (d.dy || 0)
|
||
);
|
||
if (distanceFromOrigin > 150 && d.parent) {
|
||
// 已经是根节点的不处理
|
||
// 将节点变为根节点
|
||
updateParentRelationship(d.data.id, "-1");
|
||
} else {
|
||
updateTree(treeData); // 重置位置
|
||
}
|
||
}
|
||
}
|
||
|
||
// 查找最近的节点 - 重写以更精确地处理跨树拖拽
|
||
function findClosestNode(event, sourceNode) {
|
||
// 防御性检查
|
||
if (
|
||
!sourceNode ||
|
||
!treeOffsets ||
|
||
sourceNode.originalTreeIndex === undefined
|
||
) {
|
||
return null;
|
||
}
|
||
|
||
// 获取当前拖拽节点的全局坐标
|
||
const srcX = isNaN(sourceNode.x) ? 0 : sourceNode.x;
|
||
const srcY = isNaN(sourceNode.y) ? 0 : sourceNode.y;
|
||
const srcDx = isNaN(sourceNode.dx) ? 0 : sourceNode.dx;
|
||
const srcDy = isNaN(sourceNode.dy) ? 0 : sourceNode.dy;
|
||
|
||
const origTreeIndex = !isNaN(sourceNode.originalTreeIndex)
|
||
? sourceNode.originalTreeIndex
|
||
: 0;
|
||
|
||
const treeOffset = treeOffsets[origTreeIndex] || 0;
|
||
|
||
const draggedX = srcX + srcDx;
|
||
const draggedY = srcY + srcDy + treeOffset;
|
||
|
||
let closestNode = null;
|
||
let closestDistance = 80; // 初始距离阈值
|
||
|
||
// 检查所有树中的所有节点
|
||
treeGroups.forEach((treeGroup, treeIndex) => {
|
||
if (!treeGroup) return;
|
||
|
||
const currentTreeOffset = treeOffsets[treeIndex] || 0;
|
||
|
||
treeGroup.selectAll(".node").each(function (d) {
|
||
// 防御性检查
|
||
if (!d || !d.data) return;
|
||
|
||
// 跳过源节点和其子节点
|
||
if (d === sourceNode || isDescendantOf(sourceNode, d)) return;
|
||
|
||
// 获取节点全局坐标
|
||
const nodeTransform = d3.select(this).attr("transform");
|
||
if (!nodeTransform) return;
|
||
|
||
const matches = nodeTransform.match(
|
||
/translate\(([^,]+),([^)]+)\)/
|
||
);
|
||
if (!matches) return;
|
||
|
||
let nodeX = parseFloat(matches[1]);
|
||
let nodeY = parseFloat(matches[2]) + currentTreeOffset;
|
||
|
||
// 确保坐标有效
|
||
if (isNaN(nodeX)) nodeX = 0;
|
||
if (isNaN(nodeY)) nodeY = 0;
|
||
|
||
const distance = Math.sqrt(
|
||
Math.pow(draggedX - nodeX, 2) + Math.pow(draggedY - nodeY, 2)
|
||
);
|
||
|
||
if (distance < closestDistance) {
|
||
closestDistance = distance;
|
||
closestNode = {
|
||
node: d,
|
||
element: this,
|
||
distance: distance,
|
||
};
|
||
}
|
||
});
|
||
});
|
||
|
||
return closestNode;
|
||
}
|
||
|
||
// 检查是否是子孙节点
|
||
function isDescendantOf(parent, child) {
|
||
if (!parent || !child) return false;
|
||
if (!parent.children) return false;
|
||
|
||
for (let i = 0; i < parent.children.length; i++) {
|
||
if (parent.children[i] === child) return true;
|
||
if (isDescendantOf(parent.children[i], child)) return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
// 更新父子关系
|
||
function updateParentRelationship(nodeId, newParentId) {
|
||
if (!nodeId) {
|
||
console.error("Missing nodeId in updateParentRelationship");
|
||
return;
|
||
}
|
||
|
||
fetch(`http://127.0.0.1:5002/api/project/demand/${nodeId}`, {
|
||
method: "PATCH",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
parent_id: newParentId || "-1",
|
||
}),
|
||
})
|
||
.then((response) => {
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error ${response.status}`);
|
||
}
|
||
return response.json();
|
||
})
|
||
.then((data) => {
|
||
fetchTreeData();
|
||
})
|
||
.catch((error) => {
|
||
console.error("更新节点关系失败:", error);
|
||
updateTree(treeData);
|
||
});
|
||
}
|
||
|
||
// 从API获取数据
|
||
async function fetchTreeData() {
|
||
try {
|
||
console.log("projectId", projectId);
|
||
const response = await fetch(
|
||
`http://127.0.0.1:5002/api/project/demand/${projectId}`
|
||
);
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error ${response.status}`);
|
||
}
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.code === 0 && Array.isArray(result.data)) {
|
||
treeData = convertToD3Format(result.data);
|
||
updateTree(treeData);
|
||
} else {
|
||
console.error("获取数据失败:", result.message || "未知错误");
|
||
// 显示错误信息
|
||
mainGroup.selectAll("*").remove();
|
||
mainGroup
|
||
.append("text")
|
||
.attr("x", width / 2)
|
||
.attr("y", height / 2)
|
||
.attr("text-anchor", "middle")
|
||
.text("数据加载失败: " + (result.message || "请检查网络连接"));
|
||
}
|
||
} catch (error) {
|
||
console.error("获取数据错误:", error);
|
||
// 显示错误信息
|
||
mainGroup.selectAll("*").remove();
|
||
mainGroup
|
||
.append("text")
|
||
.attr("x", width / 2)
|
||
.attr("y", height / 2)
|
||
.attr("text-anchor", "middle")
|
||
.text("数据加载错误: " + error.message);
|
||
}
|
||
}
|
||
|
||
// 添加节点
|
||
function addNode() {
|
||
let parentId = "-1"; // 默认为根节点
|
||
|
||
if (selectedNodeId) {
|
||
parentId = selectedNodeId;
|
||
}
|
||
|
||
const newNodeName = prompt("请输入新节点名称:");
|
||
const newNodeDesc = prompt("请输入节点描述:");
|
||
if (newNodeName) {
|
||
fetch(`http://127.0.0.1:5002/api/project/demand/${projectId}`, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
name: newNodeName,
|
||
description: newNodeDesc || "",
|
||
parent_id: parentId,
|
||
}),
|
||
})
|
||
.then((response) => response.json())
|
||
.then((data) => {
|
||
alert("节点添加成功");
|
||
fetchTreeData();
|
||
})
|
||
.catch((error) => {
|
||
console.error("添加节点失败:", error);
|
||
alert("添加节点失败: " + error.message);
|
||
});
|
||
}
|
||
}
|
||
|
||
// 编辑节点
|
||
function editNode() {
|
||
if (!selectedNodeId) {
|
||
alert("请先选择一个节点进行编辑");
|
||
return;
|
||
}
|
||
|
||
// 查找选中的节点数据 (确保数据是最新的)
|
||
let foundNodeData = null;
|
||
const allNodes = mainGroup.selectAll("g.node").data(); // 获取所有节点的数据
|
||
const hierarchyNode = allNodes.find(n => n.data.id === selectedNodeId);
|
||
|
||
if (hierarchyNode && hierarchyNode.data) {
|
||
foundNodeData = hierarchyNode.data;
|
||
currentSelectedNodeData = foundNodeData; // Store for stage entry
|
||
} else {
|
||
// 备选方案:如果D3数据绑定有问题,尝试从原始 treeData 查找
|
||
// (这需要确保 treeData 在作用域内且是最新)
|
||
function findNodeById(nodes, id) {
|
||
for (const node of nodes) {
|
||
if (node.id === id) return node;
|
||
if (node.children && node.children.length > 0) {
|
||
const found = findNodeById(node.children, id);
|
||
if (found) return found;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
// const foundRawData = findNodeById(treeData, selectedNodeId);
|
||
// if(foundRawData) {
|
||
// foundNodeData = foundRawData;
|
||
// currentSelectedNodeData = foundNodeData;
|
||
// } else {
|
||
alert("无法找到选中的节点数据,请刷新后重试。");
|
||
console.error("Node data not found for ID:", selectedNodeId);
|
||
return;
|
||
// }
|
||
}
|
||
|
||
// 填充模态框
|
||
document.getElementById('modal-node-original-id').value = foundNodeData.id; // Store DB ID
|
||
document.getElementById('modal-node-id-display').textContent = foundNodeData.id || 'N/A';
|
||
document.getElementById('modal-node-req_id').textContent = foundNodeData.req_id || 'N/A';
|
||
document.getElementById('modal-node-name').value = foundNodeData.name || '';
|
||
document.getElementById('modal-node-description').value = foundNodeData.description || '';
|
||
document.getElementById('modal-node-type').value = foundNodeData.type || '无';
|
||
document.getElementById('modal-node-status').value = foundNodeData.status || '无';
|
||
document.getElementById('modal-node-priority').value = foundNodeData.priority || '无';
|
||
document.getElementById('modal-node-level').textContent = foundNodeData.level !== undefined ? foundNodeData.level : 'N/A';
|
||
document.getElementById('modal-node-parent_req_id').textContent = foundNodeData.parent_req_id || '无';
|
||
document.getElementById('modal-node-created_at').textContent = formatDate(foundNodeData.created_at);
|
||
document.getElementById('modal-node-updated_at').textContent = formatDate(foundNodeData.updated_at);
|
||
|
||
// 控制 "进入Stage" 按钮的可用性
|
||
const enterStageBtn = document.getElementById('modal-enter-stage-btn');
|
||
if (foundNodeData.id) {
|
||
enterStageBtn.disabled = false;
|
||
enterStageBtn.style.opacity = "1";
|
||
enterStageBtn.style.cursor = "pointer";
|
||
} else {
|
||
enterStageBtn.disabled = true;
|
||
enterStageBtn.style.opacity = "0.5";
|
||
enterStageBtn.style.cursor = "not-allowed";
|
||
}
|
||
|
||
// 打开模态框
|
||
openModal(editModal);
|
||
}
|
||
const formatDate = (dateStr) => {
|
||
if (!dateStr || dateStr === "无") return "无";
|
||
try {
|
||
const date = new Date(dateStr);
|
||
return date.toLocaleString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||
} catch (e) {
|
||
return dateStr; // Return original if parsing fails
|
||
}
|
||
};
|
||
|
||
// 删除节点
|
||
function deleteNode() {
|
||
if (!selectedNodeId) {
|
||
alert("请先选择一个节点");
|
||
return;
|
||
}
|
||
|
||
// 查找选中节点的数据
|
||
let selectedData;
|
||
mainGroup.selectAll(".node").each(function (d) {
|
||
if (d && d.data && d.data.id === selectedNodeId) {
|
||
selectedData = d.data;
|
||
}
|
||
});
|
||
|
||
if (!selectedData) {
|
||
alert("找不到选中的节点");
|
||
return;
|
||
}
|
||
|
||
const confirmDelete = confirm(
|
||
`确定删除节点 "${selectedData.name}" 吗? 子节点也会删除。`
|
||
);
|
||
|
||
if (confirmDelete) {
|
||
fetch(
|
||
`http://127.0.0.1:5002/api/project/demand/${selectedNodeId}`,
|
||
{
|
||
method: "DELETE",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
cascade: false,
|
||
}),
|
||
}
|
||
)
|
||
.then((response) => response.json())
|
||
.then((data) => {
|
||
alert("节点删除成功");
|
||
selectedNodeId = null;
|
||
fetchTreeData();
|
||
})
|
||
.catch((error) => {
|
||
console.error("删除节点失败:", error);
|
||
alert("删除节点失败: " + error.message);
|
||
});
|
||
}
|
||
}
|
||
|
||
function showModalLoading(message = '处理中...') {
|
||
modalError.style.display = 'none';
|
||
modalMessage.style.display = 'none';
|
||
modalLoading.textContent = message;
|
||
modalLoading.style.display = 'block';
|
||
}
|
||
function hideModalLoading() {
|
||
modalLoading.style.display = 'none';
|
||
}
|
||
function showModalError(message) {
|
||
hideModalLoading();
|
||
modalMessage.style.display = 'none';
|
||
modalError.textContent = message;
|
||
modalError.style.display = 'block';
|
||
}
|
||
function showModalMessage(message) {
|
||
hideModalLoading();
|
||
modalError.style.display = 'none';
|
||
modalMessage.textContent = message;
|
||
modalMessage.style.display = 'block';
|
||
}
|
||
function resetModalStatus() {
|
||
hideModalLoading();
|
||
modalError.style.display = 'none';
|
||
modalMessage.style.display = 'none';
|
||
}
|
||
|
||
function openModal(modalElement) {
|
||
resetModalStatus();
|
||
modalElement.style.display = 'flex'; // Use flex for centering
|
||
}
|
||
|
||
function closeModal(modalElement) {
|
||
modalElement.style.display = 'none';
|
||
}
|
||
|
||
// --- 处理编辑表单提交 ---
|
||
async function handleEditFormSubmit(event) {
|
||
event.preventDefault();
|
||
resetModalStatus();
|
||
|
||
const nodeId = document.getElementById('modal-node-original-id').value;
|
||
if (!nodeId) {
|
||
showModalError("无法获取节点ID,无法保存。");
|
||
return;
|
||
}
|
||
|
||
const updatedData = {
|
||
name: document.getElementById('modal-node-name').value.trim(),
|
||
description: document.getElementById('modal-node-description').value.trim(),
|
||
type: document.getElementById('modal-node-type').value,
|
||
status: document.getElementById('modal-node-status').value,
|
||
priority: document.getElementById('modal-node-priority').value,
|
||
// 注意:不应直接修改 req_id, level, parent_id 等结构性或只读信息
|
||
};
|
||
|
||
if (!updatedData.name) {
|
||
showModalError("节点名称不能为空。");
|
||
document.getElementById('modal-node-name').focus();
|
||
return;
|
||
}
|
||
|
||
showModalLoading("正在保存...");
|
||
try {
|
||
const response = await fetch(`${API_BASE_URL}/project/demand/${nodeId}`, {
|
||
method: "PATCH",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify(updatedData),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json().catch(() => ({ message: response.statusText }));
|
||
throw new Error(`HTTP error ${response.status}: ${errorData.message || '保存失败'}`);
|
||
}
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.code === 0) {
|
||
showModalMessage("节点更新成功!");
|
||
// 等待1秒后关闭并刷新
|
||
setTimeout(() => {
|
||
closeModal(editModal);
|
||
fetchTreeData(); // 刷新整个树以显示更新
|
||
}, 1000);
|
||
} else {
|
||
showModalError("更新节点失败: " + (result.message || "未知服务端错误"));
|
||
}
|
||
} catch (error) {
|
||
console.error("更新节点时出错:", error);
|
||
showModalError("更新节点时发生错误: " + error.message);
|
||
} finally {
|
||
// hideModalLoading() 会在 showModalMessage/showModalError 中调用
|
||
}
|
||
}
|
||
|
||
// --- 处理 "进入Stage" 按钮点击 ---
|
||
async function handleEnterStage() {
|
||
resetModalStatus();
|
||
if (!currentSelectedNodeData || !currentSelectedNodeData.req_id || currentSelectedNodeData.req_id === '-1') {
|
||
showModalError("当前节点没有有效的需求ID (Req ID),无法进入Stage。");
|
||
return;
|
||
}
|
||
|
||
const demandId = currentSelectedNodeData.id;
|
||
const nodeName = currentSelectedNodeData.name || "未命名节点";
|
||
|
||
showModalLoading("正在检查Stage...");
|
||
|
||
// 1. 获取 Project ID
|
||
// const currentProjectId = await getProjectIdFromDocId(docId); // docId 需要可用
|
||
if (!projectId) {
|
||
// getProjectIdFromDocId 内部会处理错误显示
|
||
hideModalLoading();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 2. 检查 Stage 是否存在
|
||
const existingStageId = await findStageByDemandId(projectId, demandId);
|
||
|
||
if (existingStageId) {
|
||
// 3a. 如果存在,直接跳转
|
||
showModalMessage(`找到关联Stage (ID: ${existingStageId}),正在跳转...`);
|
||
redirectToStage(projectId, existingStageId);
|
||
} else {
|
||
// 3b. 如果不存在,创建 Stage
|
||
showModalLoading("未找到关联Stage,正在创建...");
|
||
const newStageId = await createStageForDemand(projectId, demandId, nodeName);
|
||
if (newStageId) {
|
||
showModalMessage(`新Stage (ID: ${newStageId}) 创建成功,正在跳转...`);
|
||
redirectToStage(projectId, newStageId);
|
||
} else {
|
||
// createStageForDemand 内部应处理错误显示
|
||
hideModalLoading(); // 确保 Loading 隐藏
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("处理进入Stage时出错:", error);
|
||
showModalError("处理Stage时发生错误: " + error.message);
|
||
}
|
||
}
|
||
|
||
// --- Helper: 根据 Demand ID 查找 Stage ID ---
|
||
async function findStageByDemandId(projectId, demandId) {
|
||
try {
|
||
const response = await fetch(`${API_BASE_URL}/project/stage/${projectId}/`);
|
||
if (!response.ok) {
|
||
const errorData = await response.json().catch(() => ({}));
|
||
console.error(`检查Stage列表失败 ${response.status}: ${errorData.message || response.statusText}`);
|
||
// 不在这里抛出错误,让调用者决定如何处理找不到的情况
|
||
return null; // 或许 API 本身就支持按 demand_id 查询?那样更好
|
||
}
|
||
const result = await response.json();
|
||
if (result.code === 0 && Array.isArray(result.data)) {
|
||
const foundStage = result.data.find(stage => stage.demand_id === demandId);
|
||
return foundStage ? foundStage.id : null;
|
||
} else {
|
||
console.warn("获取Stage列表数据格式不正确或无数据");
|
||
return null;
|
||
}
|
||
} catch (error) {
|
||
console.error("检查Stage列表时网络错误:", error);
|
||
throw new Error("检查Stage列表时网络错误: " + error.message); // 抛出让上层捕获
|
||
}
|
||
}
|
||
|
||
// --- Helper: 为 Demand 创建新 Stage ---
|
||
async function createStageForDemand(projectId, demandId, nodeName) {
|
||
const stageData = {
|
||
name: `Stage - ${nodeName}`, // 默认名称
|
||
project_id: projectId,
|
||
demand_id: demandId,
|
||
};
|
||
|
||
try {
|
||
const response = await fetch(`${API_BASE_URL}/project/stage/`, { // POST 到基础 URL
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify(stageData),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json().catch(() => ({ message: response.statusText }));
|
||
throw new Error(`HTTP error ${response.status}: ${errorData.message || '创建Stage失败'}`);
|
||
}
|
||
|
||
const result = await response.json();
|
||
|
||
// **假设** 成功时返回的数据包含新 Stage 的 ID
|
||
// 可能需要根据你的 API 实际返回值调整路径
|
||
if (result.code === 0 && result.data && result.data.id) {
|
||
console.log("新Stage创建成功,ID:", result.data.id);
|
||
return result.data.id;
|
||
} else if (result.code === 0 && typeof result.data === 'string') {
|
||
// 有些 API 可能直接在 data 中返回 ID 字符串
|
||
console.log("新Stage创建成功,ID:", result.data);
|
||
return result.data;
|
||
}
|
||
else {
|
||
throw new Error("创建Stage失败: " + (result.message || "未返回有效的Stage ID"));
|
||
}
|
||
} catch (error) {
|
||
console.error("创建Stage时出错:", error);
|
||
showModalError("创建Stage时发生错误: " + error.message); // 在模态框显示错误
|
||
return null; // 返回 null 表示失败
|
||
}
|
||
}
|
||
|
||
// --- Helper: 跳转到 Stage 页面 ---
|
||
function redirectToStage(projectId, stageId) {
|
||
if (!projectId || !stageId) {
|
||
console.error("缺少 projectId 或 stageId,无法跳转");
|
||
showModalError("无法跳转到Stage页面:缺少必要参数。");
|
||
return;
|
||
}
|
||
const targetUrl = `/project/stage/stageGraph?project_id=${projectId}&stage_id=${stageId}`;
|
||
console.log("正在跳转到:", targetUrl);
|
||
// 延迟跳转,让用户看到消息
|
||
setTimeout(() => {
|
||
window.location.href = targetUrl;
|
||
}, 1500);
|
||
}
|
||
|
||
const editModal = document.getElementById('editNodeModal');
|
||
const modalLoading = document.getElementById('modal-loading');
|
||
const modalError = document.getElementById('modal-error');
|
||
const modalMessage = document.getElementById('modal-message');
|
||
editModal.querySelector('.close-button').addEventListener('click', () => closeModal(editModal));
|
||
document.getElementById('modal-cancel-btn').addEventListener('click', () => closeModal(editModal));
|
||
|
||
// 保存按钮
|
||
document.getElementById('editNodeForm').addEventListener('submit', handleEditFormSubmit);
|
||
|
||
// 进入Stage按钮
|
||
document.getElementById('modal-enter-stage-btn').addEventListener('click', handleEnterStage);
|
||
|
||
// 注册按钮事件
|
||
$node.children[0].children[0].addEventListener("click", addNode);
|
||
$node.children[0].children[1].addEventListener("click", editNode);
|
||
$node.children[0].children[2].addEventListener("click", deleteNode);
|
||
$node.children[0].children[3].addEventListener("click", fetchTreeData);
|
||
$node.children[0].children[4].addEventListener("click", fitView);
|
||
|
||
// 缩放控制
|
||
$node.children[3].children[0].addEventListener("click", () => {
|
||
svg.transition().call(zoomBehavior.scaleBy, 1.2);
|
||
});
|
||
|
||
$node.children[3].children[1].addEventListener("click", () => {
|
||
svg.transition().call(zoomBehavior.scaleBy, 0.8);
|
||
});
|
||
|
||
$node.children[3].children[2].addEventListener("click", fitView);
|
||
|
||
// 初始加载数据
|
||
fetchTreeData();
|
||
}
|
||
initD3();
|
||
//document.addEventListener("DOMContentLoaded", initD3);
|
||
//setTimeout(initD3, 2000);
|
||
</script>
|
||
</body>
|
||
|
||
</html> |