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

3153 lines
111 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>需求树形结构可视化</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>