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

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