3153 lines
111 KiB
HTML
3153 lines
111 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;
|
||
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>
|
||
<label class="multi-select-switch">
|
||
<span class="switch">
|
||
<input type="checkbox" id="multi-select-toggle" />
|
||
<span class="slider"></span>
|
||
</span>
|
||
多选模式
|
||
</label>
|
||
<button id="merge-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>
|
||
|
||
<!-- Edit Node Modal -->
|
||
<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;display:none">进入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";
|
||
let projectId = null;
|
||
|
||
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 = []; // 存储每棵树的垂直偏移
|
||
let singleNodesOffset = 0;
|
||
// 添加多选模式相关变量
|
||
let isMultiSelectMode = false; // 是否处于多选模式
|
||
let selectedNodeIds = new Set(); // 存储多选模式下选中的节点ID
|
||
let targetNodeId = null; // 存储合并目标节点ID
|
||
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
let docId = urlParams.get("doc_id");
|
||
setProjectId()
|
||
// 安全获取容器尺寸
|
||
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") {
|
||
if (isMultiSelectMode) {
|
||
// 多选模式下不清除选择
|
||
return;
|
||
} else {
|
||
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;
|
||
}
|
||
|
||
const scale = Math.min(width / fullWidth, height / fullHeight) * 0.9;
|
||
|
||
const translateX = (width - 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) {
|
||
// 清除节点和连接
|
||
console.log("updateTree");
|
||
mainGroup.selectAll(".tree-container").remove();
|
||
mainGroup.selectAll("*").remove();
|
||
const singleNodeTrees = [];
|
||
const multiNodeTrees = [];
|
||
|
||
rootNodes.forEach((rootData) => {
|
||
if (!rootData.children || rootData.children.length === 0) {
|
||
singleNodeTrees.push(rootData);
|
||
} else {
|
||
multiNodeTrees.push(rootData);
|
||
}
|
||
});
|
||
|
||
mainGroup.selectAll(".single-nodes-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;
|
||
|
||
// 先渲染多节点树
|
||
multiNodeTrees.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)
|
||
.classed("node--multi-selected", d => selectedNodeIds.has(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("click", (event, d) => {
|
||
}).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);
|
||
}
|
||
});
|
||
|
||
// 如果有单节点树,创建单节点树区域
|
||
if (singleNodeTrees.length > 0) {
|
||
// --- Setup: Separator, Title, Container ---
|
||
const separatorHeight = 50;
|
||
/* mainGroup.append("line")
|
||
.attr("x1", width / 2).attr("y1", totalHeight)
|
||
.attr("x2", width).attr("y2", totalHeight)
|
||
.attr("stroke", "#ccc").attr("stroke-dasharray", "5,5");
|
||
*/
|
||
mainGroup.append("text")
|
||
.attr("x", width / 2).attr("y", totalHeight + 30)
|
||
.attr("text-anchor", "middle").attr("font-weight", "bold")
|
||
.text("单节点需求树");
|
||
|
||
totalHeight += separatorHeight;
|
||
singleNodesOffset = totalHeight; // Store offset if needed elsewhere
|
||
|
||
const singleNodesContainer = mainGroup.append("g")
|
||
.attr("class", "single-nodes-container")
|
||
.attr("transform", `translate(${width / 4 * 3}, ${totalHeight})`)
|
||
|
||
|
||
// --- D3 Join Pattern for Single Nodes ---
|
||
const nodesSelection = singleNodesContainer
|
||
.selectAll("g.node") // Select node groups
|
||
// Convert raw data to hierarchy nodes and use ID as key
|
||
.data(singleNodeTrees.map(d => d3.hierarchy(d)), d => d.data.id)
|
||
.join(
|
||
// --- Enter: Create new node groups ---
|
||
enter => enter.append("g")
|
||
.attr("class", "node") // Add node class immediately
|
||
// Calculate initial grid position (using estimates)
|
||
.attr("transform", (d, i) => {
|
||
const approxNodeWidth = 180; // Estimated width for positioning
|
||
const approxNodeHeight = 50; // Estimated height for positioning
|
||
const nodeMargin = 30;
|
||
const containerEffectiveWidth = Math.max(200, width - 100);
|
||
const nodesPerRow = Math.max(1, Math.floor(containerEffectiveWidth / (approxNodeWidth + nodeMargin)));
|
||
const row = Math.floor(i / nodesPerRow);
|
||
const col = i % nodesPerRow;
|
||
const x = 50 + col * (approxNodeWidth + nodeMargin) + approxNodeWidth / 2;
|
||
const y = row * (approxNodeHeight + nodeMargin) + approxNodeHeight / 2;
|
||
return `translate(${x}, ${y})`;
|
||
})
|
||
.attr("data-id", d => d.data.id)
|
||
.attr("data-tree-index", (d, i) => multiNodeTrees.length + i)
|
||
.attr("data-is-single-node", "true"),
|
||
// --- Update: Update position if needed (optional here unless grid changes) ---
|
||
update => update
|
||
.attr("transform", (d, i) => { // Recalculate position on update
|
||
const approxNodeWidth = 180;
|
||
const approxNodeHeight = 50;
|
||
const nodeMargin = 30;
|
||
const containerEffectiveWidth = Math.max(200, width - 100);
|
||
const nodesPerRow = Math.max(1, Math.floor(containerEffectiveWidth / (approxNodeWidth + nodeMargin)));
|
||
const row = Math.floor(i / nodesPerRow);
|
||
const col = i % nodesPerRow;
|
||
const x = 50 + col * (approxNodeWidth + nodeMargin) + approxNodeWidth / 2;
|
||
const y = row * (approxNodeHeight + nodeMargin) + approxNodeHeight / 2;
|
||
return `translate(${x}, ${y})`;
|
||
}),
|
||
// --- Exit: Remove old node groups ---
|
||
exit => exit.remove()
|
||
);
|
||
|
||
// --- Render Node Internals (Runway Shape, Icon, Text) for *all* nodes (enter + update) ---
|
||
nodesSelection.each(function (d) { // 'd' is the hierarchy node
|
||
const node = d3.select(this);
|
||
const nodeData = d.data; // Get the original data object
|
||
|
||
// 1. Clean previous contents
|
||
node.selectAll("*").remove();
|
||
|
||
// 2. Get type config
|
||
const nodeType = nodeData.type || "default";
|
||
const typeConfig = nodeTypeStyles[nodeType] || nodeTypeStyles["default"];
|
||
|
||
// 3. Calculate dynamic dimensions (copy from multi-node logic)
|
||
let nodeName = nodeData.name || "未命名";
|
||
nodeName = nodeName.length > 24 ? nodeName.substring(0, 24) + "..." : nodeName;
|
||
|
||
const minTextWidth = 60, minHeight = 36;
|
||
const textPaddingVertical = 5, textPaddingHorizontal = 12;
|
||
const lineHeight = 16;
|
||
|
||
const tempText = svg.append("text").attr("class", "node-text-line temp-text-measure").style("opacity", 0);
|
||
const lines = splitTextIntoLines(nodeName, 9); // Ensure splitTextIntoLines is accessible
|
||
let actualTextWidth = 0;
|
||
lines.forEach((line) => {
|
||
actualTextWidth = Math.max(actualTextWidth, tempText.text(line).node().getBBox().width);
|
||
});
|
||
tempText.remove();
|
||
|
||
const textHeight = Math.max(minHeight - 2 * textPaddingVertical, lines.length * lineHeight);
|
||
const nodeHeight = textHeight + 2 * textPaddingVertical;
|
||
const textPartWidth = Math.max(minTextWidth, actualTextWidth) + 2 * textPaddingHorizontal;
|
||
const r = nodeHeight / 2;
|
||
const nodeWidth = nodeHeight + textPartWidth;
|
||
const iconCenterX = -nodeWidth / 2 + r;
|
||
const textGroupCenterX = r;
|
||
|
||
// 4. Draw Runway Path
|
||
node.append("path")
|
||
.attr("class", "node-body node-main-shape")
|
||
.attr("d", () => {
|
||
const x1 = -nodeWidth / 2, x2 = nodeWidth / 2;
|
||
const y1 = -nodeHeight / 2, y2 = nodeHeight / 2;
|
||
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);
|
||
|
||
// 5. Draw Icon Group
|
||
const iconGroup = node.append("g")
|
||
.attr("class", "node-icon-group")
|
||
.attr("transform", `translate(${iconCenterX}, 0)`);
|
||
iconGroup.append("circle") // Background
|
||
.attr("class", "icon-circle-bg")
|
||
.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) { // Image
|
||
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 { // Fallback Text
|
||
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));
|
||
}
|
||
|
||
// 6. Draw Text Group
|
||
const textGroup = node.append("g")
|
||
.attr("class", "node-text-group")
|
||
.attr("transform", `translate(${textGroupCenterX}, 0)`);
|
||
const startY = - (lines.length - 1) * lineHeight / 2;
|
||
lines.forEach((line, i) => {
|
||
textGroup.append("text")
|
||
.attr("class", "node-text-line").attr("x", 0)
|
||
.attr("y", startY + i * lineHeight).attr("text-anchor", "middle")
|
||
.text(line);
|
||
});
|
||
}); // End of nodesSelection.each()
|
||
|
||
// --- Apply Styles, Events, and Drag AFTER rendering internals ---
|
||
nodesSelection.call(selection => {
|
||
// Apply selection classes
|
||
selection.classed("node--selected", d => selectedNodeId === d.data.id);
|
||
selection.classed("node--multi-selected", d => selectedNodeIds.has(d.data.id));
|
||
|
||
// Tooltip events
|
||
selection.on("mouseover", (event, d) => {
|
||
tooltip.transition().duration(200).style("opacity", 0.9);
|
||
const nodeData = d.data; // Use d.data for tooltip info
|
||
// Ensure formatDate, getStatusClass, getPriorityClass are accessible
|
||
const formatDate = (dateStr) => { /* ... */ };
|
||
const getStatusClass = (status) => { /* ... */ };
|
||
const getPriorityClass = (priority) => { /* ... */ };
|
||
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");
|
||
});
|
||
selection.on("mouseout", () => {
|
||
tooltip.transition().duration(500).style("opacity", 0);
|
||
});
|
||
|
||
// Apply drag handler
|
||
setupDrag(selection); // Ensure setupDrag is accessible
|
||
});
|
||
|
||
// --- Update Total Height (Approximate based on grid) ---
|
||
// Note: This height calculation is still based on the *estimated* grid layout,
|
||
// not the actual dynamic heights of the rendered nodes.
|
||
const approxNodeHeight = 50; // Use the same estimate as for positioning
|
||
const nodeMargin = 30;
|
||
const containerEffectiveWidth = Math.max(200, width - 100);
|
||
const approxNodeWidth = 180;
|
||
const nodesPerRow = Math.max(1, Math.floor(containerEffectiveWidth / (approxNodeWidth + nodeMargin)));
|
||
const rowCount = Math.ceil(singleNodeTrees.length / nodesPerRow);
|
||
// Add a small buffer at the bottom
|
||
const singleNodesTotalHeight = rowCount * (approxNodeHeight + nodeMargin) + nodeMargin;
|
||
totalHeight += singleNodesTotalHeight;
|
||
|
||
}
|
||
// 自动适应视图
|
||
setTimeout(fitView, 300); // 增加延迟确保DOM已更新
|
||
}
|
||
|
||
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 resetNodePosition(element, d) {
|
||
// 获取节点是否为单节点
|
||
const isSingleNode =
|
||
d3.select(element).attr("data-is-single-node") === "true";
|
||
|
||
if (isSingleNode) {
|
||
// 对于单节点,恢复到原始位置
|
||
const treeIndex = parseInt(
|
||
d3.select(element).attr("data-tree-index")
|
||
);
|
||
if (!isNaN(treeIndex)) {
|
||
// 找到原始位置信息
|
||
const singleNodeTrees = treeData.filter(
|
||
(tree) => !tree.children || tree.children.length === 0
|
||
);
|
||
if (
|
||
treeIndex >= multiNodeTrees.length &&
|
||
treeIndex - multiNodeTrees.length < singleNodeTrees.length
|
||
) {
|
||
const index = treeIndex - multiNodeTrees.length;
|
||
// 计算网格位置
|
||
const nodeWidth = 150;
|
||
const nodeHeight = 60;
|
||
const nodeMargin = 20;
|
||
const nodesPerRow = Math.floor(
|
||
(width - 100) / (nodeWidth + nodeMargin)
|
||
);
|
||
const row = Math.floor(index / nodesPerRow);
|
||
const col = index % nodesPerRow;
|
||
const x = 50 + col * (nodeWidth + nodeMargin) + nodeWidth / 2;
|
||
const y = row * (nodeHeight + nodeMargin) + nodeHeight / 2;
|
||
|
||
// 恢复位置
|
||
d3.select(element).attr("transform", `translate(${x}, ${y})`);
|
||
}
|
||
}
|
||
} else {
|
||
// 对于树中的节点,恢复到原始位置
|
||
const x = isNaN(d.x) ? 0 : d.x;
|
||
const y = isNaN(d.y) ? 0 : d.y;
|
||
d3.select(element).attr("transform", `translate(${x}, ${y})`);
|
||
}
|
||
|
||
// 重置拖拽偏移量
|
||
d.dx = 0;
|
||
d.dy = 0;
|
||
|
||
// 保持多选状态
|
||
if (selectedNodeIds.has(d.data.id)) {
|
||
d3.select(element).classed("node--multi-selected", true);
|
||
}
|
||
}
|
||
|
||
// 设置拖拽功能
|
||
function setupDrag(nodes) {
|
||
const dragHandler = d3
|
||
.drag()
|
||
.on("start", dragStarted)
|
||
.on("drag", dragged)
|
||
.on("end", dragEnded);
|
||
|
||
nodes.call(dragHandler);
|
||
}
|
||
|
||
// 拖拽开始
|
||
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;
|
||
tooltip.transition().duration(100).style("opacity", 0);
|
||
}
|
||
|
||
// 拖拽中
|
||
function dragged(event, d) {
|
||
if (!event) return;
|
||
tooltip.transition().style("opacity", 0);
|
||
|
||
const isSingleNode =
|
||
d3.select(this).attr("data-is-single-node") === "true";
|
||
// console.log(d3.select(this).attr("transform"));
|
||
if (isSingleNode) {
|
||
// 修复单节点拖拽坐标计算
|
||
// 获取当前节点的变换矩阵
|
||
const transform = d3.select(this).attr("transform");
|
||
const matches = transform.match(/translate\(([^,]+),([^)]+)\)/);
|
||
|
||
if (matches) {
|
||
// 解析当前位置
|
||
const currentX = parseFloat(matches[1]);
|
||
const currentY = parseFloat(matches[2]);
|
||
|
||
// 计算新位置
|
||
const newX = currentX + event.dx;
|
||
const newY = currentY + event.dy;
|
||
|
||
// 更新位置
|
||
d3.select(this).attr("transform", `translate(${newX}, ${newY})`);
|
||
|
||
// 存储拖拽偏移量用于后续计算
|
||
d.dx = event.dx;
|
||
d.dy = event.dy;
|
||
|
||
// 存储绝对位置用于findClosestNode
|
||
d.draggedX = newX;
|
||
d.draggedY = newY;
|
||
}
|
||
} else {
|
||
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;
|
||
event.sourceEvent.stopPropagation();
|
||
event.sourceEvent.preventDefault();
|
||
// 计算拖拽距离
|
||
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) {
|
||
// 处理点击选中逻辑
|
||
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;
|
||
|
||
|
||
return;
|
||
}
|
||
}
|
||
// 多选模式下不允许拖拽
|
||
if (isMultiSelectMode) {
|
||
// updateTree(treeData); // 重置位置
|
||
resetNodePosition(this, d);
|
||
return;
|
||
}
|
||
// 检查是否拖拽到其他节点附近
|
||
const targetNode = findClosestNode(event, d);
|
||
|
||
if (
|
||
targetNode &&
|
||
targetNode.node !== d &&
|
||
targetNode.node.data.id !== d.data.id
|
||
) {
|
||
// 避免循环引用 - 不能将节点拖动到其子节点
|
||
if (isDescendantOf(d, targetNode.node)) {
|
||
alert("不能将节点移动到其子节点下");
|
||
resetNodePosition(this, d);
|
||
// 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 {
|
||
resetNodePosition(this, d);
|
||
// updateTree(treeData); // 重置位置
|
||
}
|
||
}
|
||
}
|
||
|
||
// 查找最近的节点 - 重写以更精确地处理跨树拖拽
|
||
function findClosestNode(event, sourceNode) {
|
||
// 防御性检查
|
||
if (
|
||
!sourceNode ||
|
||
!treeOffsets ||
|
||
sourceNode.originalTreeIndex === undefined
|
||
) {
|
||
return null;
|
||
}
|
||
const isSingleNode =
|
||
d3
|
||
.select(event.sourceEvent.target.parentNode)
|
||
.attr("data-is-single-node") === "true";
|
||
console.log(isSingleNode, sourceNode.draggedX, sourceNode.draggedY);
|
||
let draggedX, draggedY;
|
||
if (
|
||
isSingleNode &&
|
||
sourceNode.draggedX !== undefined &&
|
||
sourceNode.draggedY !== undefined
|
||
) {
|
||
// 使用存储的绝对位置
|
||
console.log("single");
|
||
draggedX = sourceNode.draggedX;
|
||
draggedY = sourceNode.draggedY;
|
||
|
||
// 对于单节点,需要考虑单节点容器的偏移
|
||
const singleNodesContainer = mainGroup.select(
|
||
".single-nodes-container"
|
||
);
|
||
if (singleNodesContainer.size() > 0) {
|
||
const containerTransform = singleNodesContainer.attr("transform");
|
||
if (containerTransform) {
|
||
const matches = containerTransform.match(
|
||
/translate\(([^,]+),([^)]+)\)/
|
||
);
|
||
if (matches) {
|
||
// 从变换矩阵中提取Y偏移
|
||
const containerY = parseFloat(matches[2]);
|
||
if (!isNaN(containerY)) {
|
||
draggedY += containerY;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// 获取当前拖拽节点的全局坐标
|
||
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;
|
||
|
||
draggedX = srcX + srcDx;
|
||
draggedY = srcY + srcDy + treeOffset;
|
||
}
|
||
|
||
// console.log("draggedX:", draggedX, "draggedY:", draggedY);
|
||
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,
|
||
};
|
||
}
|
||
});
|
||
});
|
||
|
||
const singleNodesContainer = mainGroup.select(
|
||
".single-nodes-container"
|
||
);
|
||
// console.log("singleNodesContainer", singleNodesContainer);
|
||
|
||
if (singleNodesContainer.size() > 0) {
|
||
// 获取单节点区域的垂直偏移
|
||
|
||
// console.log("singlenodecontainer", singleNodesContainer);
|
||
singleNodesContainer.selectAll(".node").each(function (d) {
|
||
// console.log("d",d)
|
||
// 防御性检查
|
||
if (!d || !d.data) return;
|
||
|
||
// 跳过源节点
|
||
if (d === sourceNode || d.data.id === sourceNode.data.id) return;
|
||
|
||
// 获取节点全局坐标
|
||
const nodeTransform = d3.select(this).attr("transform");
|
||
// console.log("nodeTransform", nodeTransform)
|
||
if (!nodeTransform) return;
|
||
|
||
const matches = nodeTransform.match(
|
||
/translate\(([^,]+),([^)]+)\)/
|
||
);
|
||
if (!matches) return;
|
||
|
||
let nodeX = parseFloat(matches[1]);
|
||
let nodeY = parseFloat(matches[2]) + singleNodesOffset;
|
||
|
||
// 确保坐标有效
|
||
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,
|
||
};
|
||
}
|
||
});
|
||
}
|
||
console.log("closestNode", closestNode);
|
||
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/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) => {
|
||
const currentTransform = d3.zoomTransform(svg.node());
|
||
fetchTreeData(currentTransform);
|
||
//updateNodeParentInTree(nodeId, newParentId);
|
||
})
|
||
.catch((error) => {
|
||
console.error("更新节点关系失败:", error);
|
||
// updateTree(treeData);
|
||
resetNodePosition(this, d);
|
||
});
|
||
}
|
||
|
||
// 从API获取数据
|
||
async function fetchTreeData(preserveTransform = null) {
|
||
try {
|
||
console.log("docId", docId);
|
||
const response = await fetch(
|
||
`http://127.0.0.1:5002/api/demand/${docId}`
|
||
);
|
||
|
||
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);
|
||
if (preserveTransform) {
|
||
setTimeout(() => {
|
||
svg.call(zoomBehavior.transform, preserveTransform);
|
||
}, 350); // 稍微延迟一点,确保树已经渲染完成
|
||
}
|
||
} 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/demand/${docId}`, {
|
||
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 selectedData;
|
||
mainGroup.selectAll(".node").each(function (d) {
|
||
if (d && d.data && d.data.id === selectedNodeId) {
|
||
selectedData = d.data;
|
||
}
|
||
});
|
||
|
||
if (!selectedData) {
|
||
alert("找不到选中的节点");
|
||
return;
|
||
}
|
||
|
||
const newName = prompt("请输入新的节点名称:", selectedData.name);
|
||
const newDesc = prompt(
|
||
"请输入新的节点描述:",
|
||
selectedData.description || ""
|
||
);
|
||
|
||
if (newName) {
|
||
fetch(`http://127.0.0.1:5002/api/demand/${selectedNodeId}`, {
|
||
method: "PATCH",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
name: newName,
|
||
description: newDesc,
|
||
}),
|
||
})
|
||
.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.req_id && foundNodeData.req_id !== '-1') {
|
||
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/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 unselectDescendants(node) {
|
||
if (!node.children) return;
|
||
|
||
node.children.forEach((child) => {
|
||
const childId = child.data.id;
|
||
selectedNodeIds.delete(childId);
|
||
|
||
// 查找并更新DOM
|
||
mainGroup.selectAll(".node").each(function (d) {
|
||
if (d && d.data && d.data.id === childId) {
|
||
d3.select(this).classed("node--multi-selected", false);
|
||
}
|
||
});
|
||
|
||
// 递归处理子节点
|
||
unselectDescendants(child);
|
||
});
|
||
}
|
||
|
||
// 添加多选模式的点击处理函数
|
||
function handleMultiSelectClick(element, d) {
|
||
const nodeId = d.data.id;
|
||
const isSelected = d3.select(element).classed("node--multi-selected");
|
||
|
||
if (isSelected) {
|
||
// 如果已选中,则取消选中该节点及其所有子孙节点
|
||
selectedNodeIds.delete(nodeId);
|
||
d3.select(element).classed("node--multi-selected", false);
|
||
|
||
// 取消选中所有子孙节点
|
||
unselectDescendants(d);
|
||
} else {
|
||
// 如果未选中,则选中该节点及其所有子孙节点
|
||
selectedNodeIds.add(nodeId);
|
||
d3.select(element).classed("node--multi-selected", true);
|
||
|
||
// 选中所有子孙节点
|
||
selectDescendants(d);
|
||
}
|
||
|
||
// 更新合并按钮状态
|
||
updateMergeButtonState();
|
||
if (event && event.sourceEvent) {
|
||
event.sourceEvent.stopPropagation();
|
||
}
|
||
}
|
||
|
||
// 选中所有子孙节点
|
||
function selectDescendants(node) {
|
||
if (!node.children) return;
|
||
|
||
node.children.forEach((child) => {
|
||
const childId = child.data.id;
|
||
selectedNodeIds.add(childId);
|
||
|
||
// 查找并更新DOM
|
||
mainGroup.selectAll(".node").each(function (d) {
|
||
if (d && d.data && d.data.id === childId) {
|
||
d3.select(this).classed("node--multi-selected", true);
|
||
}
|
||
});
|
||
|
||
// 递归处理子节点
|
||
selectDescendants(child);
|
||
});
|
||
}
|
||
// 更新合并按钮状态
|
||
function updateMergeButtonState() {
|
||
const mergeBtn = document.getElementById("merge-btn");
|
||
if (selectedNodeIds.size > 0) {
|
||
mergeBtn.style.display = "inline-block";
|
||
} else {
|
||
mergeBtn.style.display = "none";
|
||
}
|
||
}
|
||
|
||
function toggleMultiSelectMode() {
|
||
console.log("toggleMultiSelectMode");
|
||
isMultiSelectMode = !isMultiSelectMode;
|
||
|
||
// 更新UI状态
|
||
if (isMultiSelectMode) {
|
||
document.body.classList.add("multi-select-mode");
|
||
// 清除单选状态
|
||
selectedNodeId = null;
|
||
mainGroup.selectAll(".node").classed("node--selected", false);
|
||
// 显示合并按钮
|
||
document.getElementById("merge-btn").style.display = "none";
|
||
} else {
|
||
document.body.classList.remove("multi-select-mode");
|
||
// 清除多选状态
|
||
selectedNodeIds.clear();
|
||
mainGroup.selectAll(".node").classed("node--multi-selected", false);
|
||
// 隐藏合并按钮
|
||
document.getElementById("merge-btn").style.display = "none";
|
||
}
|
||
}
|
||
/* function mergeNodesToTarget() {
|
||
if (selectedNodeIds.size === 0) {
|
||
alert("请先选择要合并的节点");
|
||
return;
|
||
}
|
||
|
||
// 这里预留合并功能的实现
|
||
alert("合并功能尚未实现,已选择 " + selectedNodeIds.size + " 个节点");
|
||
}*/
|
||
|
||
function mergeNodesToTarget() {
|
||
// 检查是否有选中的节点
|
||
if (selectedNodeIds.size === 0) {
|
||
alert("请先选择要合并的节点");
|
||
return;
|
||
}
|
||
|
||
// 创建并显示模态窗口
|
||
const { modal, treeContainer, confirmButton } = createMergeModal();
|
||
|
||
// 从URL获取文档ID
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const docId = urlParams.get("doc_id");
|
||
|
||
if (!docId) {
|
||
alert("无法获取文档ID,请检查URL参数");
|
||
document.body.removeChild(modal);
|
||
return;
|
||
}
|
||
|
||
// 初始化选中的目标节点ID
|
||
let selectedTargetId = null;
|
||
|
||
// 获取项目ID并加载项目需求树
|
||
loadProjectTree(docId, treeContainer, (targetId) => {
|
||
selectedTargetId = targetId;
|
||
confirmButton.disabled = false;
|
||
confirmButton.style.opacity = "1";
|
||
confirmButton.style.cursor = "pointer";
|
||
});
|
||
|
||
// 设置确认按钮点击事件
|
||
confirmButton.onclick = () =>
|
||
handleMergeConfirm(docId, selectedTargetId, modal);
|
||
}
|
||
|
||
// 创建合并模态窗口
|
||
function createMergeModal() {
|
||
// 创建模态窗口
|
||
const modal = document.createElement("div");
|
||
modal.style.position = "fixed";
|
||
modal.style.left = "0";
|
||
modal.style.top = "0";
|
||
modal.style.width = "100%";
|
||
modal.style.height = "100%";
|
||
modal.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
|
||
modal.style.zIndex = "1000";
|
||
modal.style.display = "flex";
|
||
modal.style.justifyContent = "center";
|
||
modal.style.alignItems = "center";
|
||
|
||
// 创建模态内容
|
||
const modalContent = document.createElement("div");
|
||
modalContent.style.backgroundColor = "white";
|
||
modalContent.style.padding = "20px";
|
||
modalContent.style.borderRadius = "5px";
|
||
modalContent.style.width = "80%";
|
||
modalContent.style.height = "80%";
|
||
modalContent.style.display = "flex";
|
||
modalContent.style.flexDirection = "column";
|
||
modal.appendChild(modalContent);
|
||
|
||
// 创建标题
|
||
const title = document.createElement("h2");
|
||
title.textContent = "选择合并目标节点";
|
||
title.style.marginBottom = "10px";
|
||
modalContent.appendChild(title);
|
||
|
||
// 创建说明文本
|
||
const description = document.createElement("p");
|
||
description.textContent = `您已选择 ${selectedNodeIds.size} 个节点进行合并,请在下方选择一个目标节点作为合并目标。`;
|
||
description.style.marginBottom = "20px";
|
||
modalContent.appendChild(description);
|
||
|
||
// 创建树容器
|
||
const treeContainer = document.createElement("div");
|
||
treeContainer.style.flex = "1";
|
||
treeContainer.style.border = "1px solid #eee";
|
||
treeContainer.style.overflow = "auto";
|
||
treeContainer.style.position = "relative";
|
||
treeContainer.style.marginBottom = "20px";
|
||
modalContent.appendChild(treeContainer);
|
||
|
||
// 创建按钮容器
|
||
const buttonContainer = document.createElement("div");
|
||
buttonContainer.style.display = "flex";
|
||
buttonContainer.style.justifyContent = "flex-end";
|
||
modalContent.appendChild(buttonContainer);
|
||
|
||
// 创建取消按钮
|
||
const cancelButton = document.createElement("button");
|
||
cancelButton.textContent = "取消";
|
||
cancelButton.style.marginRight = "10px";
|
||
cancelButton.style.backgroundColor = "#f5f5f5";
|
||
cancelButton.style.color = "#333";
|
||
cancelButton.onclick = () => {
|
||
document.body.removeChild(modal);
|
||
};
|
||
buttonContainer.appendChild(cancelButton);
|
||
|
||
// 创建确认按钮(初始禁用)
|
||
const confirmButton = document.createElement("button");
|
||
confirmButton.textContent = "确认合并";
|
||
confirmButton.disabled = true;
|
||
confirmButton.style.opacity = "0.5";
|
||
confirmButton.style.cursor = "not-allowed";
|
||
buttonContainer.appendChild(confirmButton);
|
||
|
||
// 添加模态窗口到body
|
||
document.body.appendChild(modal);
|
||
|
||
return { modal, treeContainer, confirmButton };
|
||
}
|
||
|
||
// 加载项目树
|
||
function loadProjectTree(docId, treeContainer, onSelectTarget) {
|
||
// 如果已经有项目ID,直接加载树
|
||
if (projectId) {
|
||
renderProjectTree(projectId, treeContainer, onSelectTarget);
|
||
return;
|
||
}
|
||
|
||
// 通过文档ID获取项目ID
|
||
fetch(`http://127.0.0.1:5002/api/doc/${docId}/project`)
|
||
.then((response) => {
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error ${response.status}`);
|
||
}
|
||
return response.json();
|
||
})
|
||
.then((result) => {
|
||
if (result.code === 0 && result.data && result.data.project_id) {
|
||
projectId = result.data.project_id;
|
||
renderProjectTree(projectId, treeContainer, onSelectTarget);
|
||
} else {
|
||
throw new Error("获取项目ID失败");
|
||
}
|
||
})
|
||
.catch((error) => {
|
||
treeContainer.innerHTML = `<div style="padding: 20px; text-align: center;">获取项目ID错误: ${error.message}</div>`;
|
||
});
|
||
}
|
||
function setProjectId() {
|
||
fetch(`${API_BASE_URL}/doc/${docId}/project`).then((response) => {
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error ${response.status}`);
|
||
}
|
||
return response.json();
|
||
}).then((result) => {
|
||
if (result.code === 0 && result.data && result.data.project_id) {
|
||
projectId = result.data.project_id;
|
||
}
|
||
})
|
||
}
|
||
// 渲染项目需求树
|
||
function renderProjectTree(projectId, treeContainer, onSelectTarget) {
|
||
// 创建SVG
|
||
const svg = d3
|
||
.select(treeContainer)
|
||
.append("svg")
|
||
.attr("width", "100%")
|
||
.attr("height", "100%")
|
||
.on("click", function (event) {
|
||
|
||
// 仅当点击空白区域时触发
|
||
if (event.target === this) {
|
||
// 清除所有节点的选择状态
|
||
treeContainer
|
||
.querySelectorAll(".selected-target")
|
||
.forEach((el) => {
|
||
el.classList.remove("selected-target");
|
||
});
|
||
|
||
// 重置根节点选项样式
|
||
const rootOption =
|
||
treeContainer.querySelector("div:first-child");
|
||
if (rootOption) {
|
||
rootOption.style.backgroundColor = "";
|
||
rootOption.style.borderColor = "#eee";
|
||
}
|
||
|
||
// 重置所有节点的样式
|
||
svg.selectAll(".node text").style("fill", "#333");
|
||
svg
|
||
.selectAll(".node rect")
|
||
.style("fill", "#f5f5f5")
|
||
.style("stroke", "#e0e0e0")
|
||
.style("stroke-width", "1px");
|
||
|
||
// 清除选中的目标ID
|
||
onSelectTarget(null);
|
||
}
|
||
});
|
||
|
||
const mainGroup = svg.append("g");
|
||
|
||
// 添加缩放功能
|
||
const zoomBehavior = d3
|
||
.zoom()
|
||
.scaleExtent([0.1, 3])
|
||
.on("zoom", (event) => {
|
||
mainGroup.attr("transform", event.transform);
|
||
});
|
||
|
||
svg.call(zoomBehavior);
|
||
|
||
// 添加根节点选项
|
||
const rootOption = createRootNodeOption(
|
||
treeContainer,
|
||
onSelectTarget
|
||
);
|
||
|
||
// 获取项目需求树数据
|
||
fetch(`http://127.0.0.1:5002/api/project/demand/${projectId}`)
|
||
.then((response) => {
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error ${response.status}`);
|
||
}
|
||
return response.json();
|
||
})
|
||
.then((result) => {
|
||
if (result.code === 0 && Array.isArray(result.data)) {
|
||
console.log("获取数据成功:", result.data);
|
||
renderTreeData(
|
||
result.data,
|
||
mainGroup,
|
||
treeContainer,
|
||
rootOption,
|
||
onSelectTarget,
|
||
svg
|
||
);
|
||
console.log("渲染树数据成功");
|
||
fitTreeToContainer(mainGroup, treeContainer, zoomBehavior, svg);
|
||
console.log("fitTreeToContainer成功");
|
||
} else {
|
||
treeContainer.innerHTML = `<div style="padding: 20px; text-align: center;">获取数据失败: ${result.message || "未知错误"
|
||
}</div>`;
|
||
}
|
||
})
|
||
.catch((error) => {
|
||
console.error("获取数据错误:", error);
|
||
treeContainer.innerHTML = `<div style="padding: 20px; text-align: center;">获取数据错误: ${error.message}</div>`;
|
||
});
|
||
}
|
||
|
||
// 创建根节点选项
|
||
function createRootNodeOption(treeContainer, onSelectTarget) {
|
||
const rootOption = document.createElement("div");
|
||
rootOption.style.padding = "10px";
|
||
rootOption.style.marginBottom = "10px";
|
||
rootOption.style.border = "1px solid #eee";
|
||
rootOption.style.borderRadius = "4px";
|
||
rootOption.style.cursor = "pointer";
|
||
rootOption.textContent = "根节点 (作为顶级节点合并)";
|
||
|
||
rootOption.onclick = () => {
|
||
// 清除之前的选择
|
||
treeContainer.querySelectorAll(".selected-target").forEach((el) => {
|
||
el.classList.remove("selected-target");
|
||
});
|
||
|
||
// 标记当前选择
|
||
rootOption.classList.add("selected-target");
|
||
rootOption.style.backgroundColor = "#e6f7ff";
|
||
rootOption.style.borderColor = "#1890ff";
|
||
|
||
// 设置选中的目标ID
|
||
onSelectTarget("-1");
|
||
};
|
||
|
||
treeContainer.insertBefore(rootOption, treeContainer.firstChild);
|
||
return rootOption;
|
||
}
|
||
|
||
// 渲染树数据
|
||
// 渲染树数据
|
||
function renderTreeData(
|
||
data,
|
||
mainGroup,
|
||
treeContainer,
|
||
rootOption,
|
||
onSelectTarget,
|
||
svg
|
||
) {
|
||
// 转换数据为D3格式
|
||
const treeData = convertToD3Format(data);
|
||
|
||
// 创建树布局
|
||
const treeLayout = d3.tree().nodeSize([80, 200]);
|
||
|
||
// 创建层次结构
|
||
const root = d3.hierarchy(treeData[0]);
|
||
|
||
// 应用布局
|
||
treeLayout(root);
|
||
|
||
mainGroup
|
||
.selectAll(".link")
|
||
.data(root.links())
|
||
.enter()
|
||
.append("path")
|
||
.attr("class", "link")
|
||
.attr("d", (d) => {
|
||
// 添加空值检查
|
||
if (!d.source || !d.target) return "";
|
||
|
||
const sourceX = d.source.x;
|
||
const sourceY = d.source.y;
|
||
const targetX = d.target.x;
|
||
const targetY = d.target.y;
|
||
const cx = (sourceY + targetY) / 2;
|
||
|
||
// 生成贝塞尔曲线路径
|
||
return `M${sourceY},${sourceX}
|
||
C${cx},${sourceX}
|
||
${cx},${targetX}
|
||
${targetY},${targetX}`;
|
||
})
|
||
.style("stroke", "#91d5ff")
|
||
.style("stroke-width", 2)
|
||
.style("stroke-opacity", 0.6)
|
||
.style("fill", "none");
|
||
// 创建节点
|
||
const nodes = mainGroup
|
||
.selectAll(".node")
|
||
.data(root.descendants())
|
||
.enter()
|
||
.append("g")
|
||
.attr("class", "node")
|
||
.attr("transform", (d) => `translate(${d.y}, ${d.x})`)
|
||
.attr("data-id", (d) => d.data.id)
|
||
|
||
|
||
// 添加节点矩形
|
||
nodes
|
||
.append("rect")
|
||
.attr("width", 120)
|
||
.attr("height", 40)
|
||
.attr("x", -60)
|
||
.attr("y", -20)
|
||
.attr("rx", 4)
|
||
.attr("ry", 4)
|
||
.style("fill", "#f5f5f5") // 设置默认背景为浅灰色
|
||
.style("stroke", "#e0e0e0") // 添加浅色边框
|
||
.style("stroke-width", "1px"); // 设置边框宽度
|
||
|
||
// 添加节点文本
|
||
nodes
|
||
.append("text")
|
||
.attr("text-anchor", "middle")
|
||
.attr("dy", "0.3em")
|
||
.text((d) => d.data.name)
|
||
.style("fill", "#333"); // 设置默认文本颜色
|
||
|
||
// 添加点击事件
|
||
nodes.on("click", function (event, d) {
|
||
console.log("d", d)
|
||
svg.selectAll(".node text").style("fill", "#333");
|
||
svg
|
||
.selectAll(".node rect")
|
||
.style("fill", "#f5f5f5")
|
||
.style("stroke", "#e0e0e0")
|
||
.style("stroke-width", "1px");
|
||
|
||
rootOption.style.backgroundColor = "";
|
||
rootOption.style.borderColor = "#eee";
|
||
// 设置当前节点选中状态
|
||
d3.select(this)
|
||
.classed("selected-target", true)
|
||
.select("rect")
|
||
.style("fill", "#e6f7ff")
|
||
.style("stroke", "#1890ff")
|
||
.style("stroke-width", "2px");
|
||
console.log("d3.select(this)", d3.select(this))
|
||
d3.select(this).select("text").style("fill", "#1890ff");
|
||
|
||
// 设置选中的目标ID
|
||
onSelectTarget(d.data.id);
|
||
});
|
||
// 清除根节点选项样式
|
||
|
||
|
||
|
||
}
|
||
|
||
// 自动调整树视图以适应容器
|
||
function fitTreeToContainer(
|
||
mainGroup,
|
||
treeContainer,
|
||
zoomBehavior,
|
||
svg
|
||
) {
|
||
const bounds = mainGroup.node().getBBox();
|
||
const dx = bounds.width;
|
||
const dy = bounds.height;
|
||
const x = bounds.x + dx / 2;
|
||
const y = bounds.y + dy / 2;
|
||
const scale =
|
||
0.9 /
|
||
Math.max(
|
||
dx / treeContainer.clientWidth,
|
||
dy / treeContainer.clientHeight
|
||
);
|
||
const translate = [
|
||
treeContainer.clientWidth / 2 - scale * x,
|
||
treeContainer.clientHeight / 2 - scale * y,
|
||
];
|
||
|
||
svg.call(
|
||
zoomBehavior.transform,
|
||
d3.zoomIdentity.translate(translate[0], translate[1]).scale(scale)
|
||
);
|
||
}
|
||
|
||
// 处理合并确认
|
||
function handleMergeConfirm(docId, selectedTargetId, modal) {
|
||
if (!selectedTargetId) {
|
||
alert("请选择一个目标节点");
|
||
return;
|
||
}
|
||
|
||
// 执行合并操作
|
||
const sourceNodeIds = Array.from(selectedNodeIds);
|
||
|
||
fetch(`http://127.0.0.1:5002/api/project/demand/${docId}/merge`, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
target_node_id: selectedTargetId,
|
||
source_node_ids: sourceNodeIds,
|
||
}),
|
||
})
|
||
.then((response) => {
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error ${response.status}`);
|
||
}
|
||
return response.json();
|
||
})
|
||
.then((data) => {
|
||
alert("合并成功!");
|
||
// 关闭模态窗口
|
||
document.body.removeChild(modal);
|
||
// 刷新树
|
||
fetchTreeData();
|
||
// 清除选择
|
||
selectedNodeIds.clear();
|
||
mainGroup
|
||
.selectAll(".node")
|
||
.classed("node--multi-selected", false);
|
||
// 隐藏合并按钮
|
||
document.getElementById("merge-btn").style.display = "none";
|
||
})
|
||
.catch((error) => {
|
||
alert(`合并失败: ${error.message}`);
|
||
console.error("合并节点失败:", error);
|
||
});
|
||
}
|
||
// 注册按钮事件
|
||
$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);
|
||
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);
|
||
|
||
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}/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 editButton = $node.querySelector('#edit-btn'); // 或者通过索引找到按钮
|
||
if (editButton) {
|
||
|
||
editButton.addEventListener('click', editNode); // 添加新监听器
|
||
} else {
|
||
console.warn("未能找到编辑按钮来绑定新的模态框事件。");
|
||
}
|
||
// 缩放控制
|
||
$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);
|
||
document
|
||
.getElementById("multi-select-toggle")
|
||
.addEventListener("change", toggleMultiSelectMode);
|
||
document
|
||
.getElementById("merge-btn")
|
||
.addEventListener("click", mergeNodesToTarget);
|
||
// 初始加载数据
|
||
fetchTreeData();
|
||
}
|
||
initD3();
|
||
//document.addEventListener("DOMContentLoaded", initD3);
|
||
//setTimeout(initD3, 2000);
|
||
</script>
|
||
</body>
|
||
|
||
</html> |