1586 lines
52 KiB
HTML
1586 lines
52 KiB
HTML
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<title>需求树形结构可视化</title>
|
||
<script src="/ui/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;
|
||
}
|
||
|
||
#tree-container {
|
||
width: 100%;
|
||
height: 800px;
|
||
border: 1px solid #eee;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
/* 卡片式节点样式 */
|
||
.node rect {
|
||
fill: #fff;
|
||
stroke: #1890ff;
|
||
stroke-width: 1.5px;
|
||
rx: 4;
|
||
ry: 4;
|
||
transition: all 0.3s;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.node text {
|
||
font: 14px sans-serif;
|
||
dominant-baseline: middle;
|
||
pointer-events: none; /* 防止文本拦截点击事件 */
|
||
}
|
||
.node-text-line {
|
||
font: 14px sans-serif;
|
||
text-anchor: middle;
|
||
dominant-baseline: middle;
|
||
}
|
||
.link {
|
||
fill: none;
|
||
stroke: #cacaca;
|
||
stroke-width: 1.5px;
|
||
}
|
||
|
||
.control-panel {
|
||
margin-bottom: 20px;
|
||
padding: px;
|
||
background: #f8f8f8;
|
||
border-radius: 5px;
|
||
}
|
||
|
||
button {
|
||
padding: 6px 15px;
|
||
margin-right: 8px;
|
||
background-color: #1890ff;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
transition: background-color 0.3s;
|
||
}
|
||
|
||
button:hover {
|
||
background-color: #40a9ff;
|
||
}
|
||
|
||
/* 选中和拖拽状态 */
|
||
.node--selected rect {
|
||
fill: #e6f7ff;
|
||
stroke: #1890ff;
|
||
stroke-width: 2.5px;
|
||
}
|
||
|
||
.dragging rect {
|
||
fill: #fff1f0;
|
||
stroke: #ff4d4f;
|
||
}
|
||
|
||
/* 潜在父节点高亮 */
|
||
.potential-parent rect {
|
||
fill: rgba(24, 144, 255, 0.15);
|
||
stroke: #1890ff;
|
||
stroke-width: 2px;
|
||
}
|
||
|
||
/* 根节点高亮区域 */
|
||
.root-hint {
|
||
fill: rgba(82, 196, 26, 0.15);
|
||
stroke: #52c41a;
|
||
stroke-width: 2px;
|
||
rx: 4;
|
||
ry: 4;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.zoom-controls {
|
||
position: absolute;
|
||
right: 20px;
|
||
top: 80px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
z-index: 100;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.tree-label {
|
||
font-size: 14px;
|
||
font-weight: bold;
|
||
fill: #666;
|
||
}
|
||
|
||
.tree-container {
|
||
border-top: 1px dashed #ddd;
|
||
padding-top: 30px;
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
/* 添加表格样式 */
|
||
.tooltip-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
}
|
||
|
||
.tooltip-table td {
|
||
padding: 2px 5px;
|
||
vertical-align: top;
|
||
}
|
||
|
||
.tooltip-table td:first-child {
|
||
font-weight: bold;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* 添加状态标签样式 */
|
||
.status-tag {
|
||
display: inline-block;
|
||
padding: 2px 6px;
|
||
border-radius: 3px;
|
||
font-size: 11px;
|
||
margin-left: 5px;
|
||
}
|
||
|
||
.status-pending {
|
||
background-color: #faad14;
|
||
color: #fff;
|
||
}
|
||
|
||
.status-done {
|
||
background-color: #52c41a;
|
||
color: #fff;
|
||
}
|
||
|
||
.status-progress {
|
||
background-color: #1890ff;
|
||
color: #fff;
|
||
}
|
||
|
||
.priority-high {
|
||
background-color: #f5222d;
|
||
color: #fff;
|
||
}
|
||
|
||
.priority-medium {
|
||
background-color: #fa8c16;
|
||
color: #fff;
|
||
}
|
||
|
||
.priority-low {
|
||
background-color: #52c41a;
|
||
color: #fff;
|
||
}
|
||
</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">刷新</button>
|
||
<button id="fit-btn">适应视图</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>
|
||
|
||
<script>
|
||
function initD3() {
|
||
// 全局变量
|
||
console.log("init");
|
||
let treeData = [];
|
||
let selectedNodeId = null; // 存储选中节点的ID而非DOM元素
|
||
let zoomBehavior;
|
||
let lastPotentialParent = null; // 跟踪上一个潜在父节点
|
||
let treeGroups = []; // 存储每棵树的组引用
|
||
let treeOffsets = []; // 存储每棵树的垂直偏移
|
||
let singleNodesOffset = 0;
|
||
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
let docId = urlParams.get("doc_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;
|
||
}
|
||
|
||
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) {
|
||
// 清除节点和连接
|
||
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)
|
||
.enter()
|
||
.append("g")
|
||
.attr("class", (d) => {
|
||
// 如果当前节点ID与selectedNodeId匹配,添加选中样式
|
||
return d.data.id === selectedNodeId
|
||
? "node node--selected"
|
||
: "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");
|
||
// 测量文本宽度
|
||
const textWidths = {};
|
||
nodes.forEach((d) => {
|
||
const name = d.data.name || "未命名";
|
||
const textWidth = name.length * 8 + 40; // 估算宽度
|
||
textWidths[d.data.id] = textWidth;
|
||
});
|
||
|
||
// 添加卡片矩形
|
||
nodeGroups
|
||
.append("rect")
|
||
.attr("width", (d) =>
|
||
Math.max(100, textWidths[d.data.id] || 100)
|
||
)
|
||
.attr("height", 40)
|
||
.attr(
|
||
"x",
|
||
(d) => -(Math.max(100, textWidths[d.data.id] || 100) / 2)
|
||
)
|
||
.attr("y", -20);
|
||
|
||
// 添加节点文本
|
||
nodeGroups.each(function (d) {
|
||
const node = d3.select(this);
|
||
const nodeName = d.data.name || "未命名";
|
||
const nodeWidth =
|
||
Math.max(100, textWidths[d.data.id] || 100) - 68; // 留出边距
|
||
|
||
// 将文本按宽度拆分成多行
|
||
const lines = splitTextIntoLines(nodeName, nodeWidth / 8); // 估算每个字符宽度约8px
|
||
|
||
// 根据行数调整矩形高度
|
||
const lineHeight = 18;
|
||
const rectHeight = Math.max(40, lines.length * lineHeight + 10);
|
||
|
||
// 更新矩形高度
|
||
node
|
||
.select("rect")
|
||
.attr("height", rectHeight)
|
||
.attr("y", -rectHeight / 2);
|
||
|
||
// 添加每一行文本
|
||
lines.forEach((line, i) => {
|
||
const yPos = -rectHeight / 2 + 15 + i * lineHeight;
|
||
node
|
||
.append("text")
|
||
.attr("class", "node-text-line")
|
||
.attr("dy", yPos)
|
||
.text(line);
|
||
});
|
||
});
|
||
|
||
// 文本分行函数
|
||
function splitTextIntoLines(text, maxCharsPerLine) {
|
||
if (!text) return [""];
|
||
|
||
const words = text.split("");
|
||
const lines = [];
|
||
let currentLine = "";
|
||
|
||
words.forEach((char) => {
|
||
if (currentLine.length >= maxCharsPerLine) {
|
||
lines.push(currentLine);
|
||
currentLine = char;
|
||
} else {
|
||
currentLine += char;
|
||
}
|
||
});
|
||
|
||
if (currentLine) {
|
||
lines.push(currentLine);
|
||
}
|
||
|
||
return lines;
|
||
}
|
||
|
||
// 添加悬停事件
|
||
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);
|
||
});
|
||
|
||
// 添加拖拽功能
|
||
setupDrag(nodeGroups);
|
||
} catch (e) {
|
||
console.error(`Error processing tree at index ${index}:`, e);
|
||
}
|
||
});
|
||
|
||
// 如果有单节点树,创建单节点树区域
|
||
if (singleNodeTrees.length > 0) {
|
||
// 添加分隔线和标题
|
||
const separatorHeight = 50;
|
||
|
||
// 添加分隔线
|
||
mainGroup
|
||
.append("line")
|
||
.attr("x1", 0)
|
||
.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;
|
||
|
||
// 创建单节点树容器
|
||
const singleNodesContainer = mainGroup
|
||
.append("g")
|
||
.attr("class", "single-nodes-container")
|
||
.attr("transform", `translate(0, ${totalHeight})`);
|
||
singleNodesOffset = totalHeight;
|
||
// 定义网格布局参数
|
||
const nodeWidth = 150;
|
||
const nodeHeight = 60;
|
||
const nodeMargin = 20;
|
||
const nodesPerRow = Math.floor(
|
||
(width - 100) / (nodeWidth + nodeMargin)
|
||
);
|
||
|
||
// 创建单节点树网格
|
||
singleNodeTrees.forEach((nodeData, index) => {
|
||
// 计算网格位置
|
||
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;
|
||
const hierarchyData = d3.hierarchy(nodeData);
|
||
// 创建节点组
|
||
const nodeGroup = singleNodesContainer
|
||
.append("g")
|
||
.datum(hierarchyData)
|
||
.attr(
|
||
"class",
|
||
nodeData.id === selectedNodeId
|
||
? "node node--selected"
|
||
: "node"
|
||
)
|
||
.attr("transform", `translate(${x}, ${y})`)
|
||
.attr("data-id", nodeData.id)
|
||
.attr("data-tree-index", multiNodeTrees.length + index) // 使用不同的索引范围
|
||
.attr("data-is-single-node", "true");
|
||
// 添加节点矩形
|
||
nodeGroup
|
||
.append("rect")
|
||
.attr("width", nodeWidth)
|
||
.attr("height", nodeHeight)
|
||
.attr("x", -nodeWidth / 2)
|
||
.attr("y", -nodeHeight / 2)
|
||
.attr("rx", 5) // 圆角
|
||
.attr("ry", 5); // 圆角
|
||
|
||
// 添加节点文本
|
||
nodeGroup.each(function (d) {
|
||
const node = d3.select(this);
|
||
const nodeName = d.data.name || "未命名";
|
||
const maxWidth = nodeWidth - 80; // 留出边距
|
||
console.log("max", maxWidth);
|
||
// 将文本按宽度拆分成多行
|
||
const lines = splitTextIntoLines(nodeName, maxWidth / 8);
|
||
|
||
// 根据行数调整矩形高度
|
||
const lineHeight = 18;
|
||
const rectHeight = Math.max(
|
||
nodeHeight,
|
||
lines.length * lineHeight + 10
|
||
);
|
||
|
||
// 更新矩形高度
|
||
node
|
||
.select("rect")
|
||
.attr("height", rectHeight)
|
||
.attr("y", -rectHeight / 2);
|
||
|
||
// 添加每一行文本
|
||
lines.forEach((line, i) => {
|
||
const yPos = -rectHeight / 2 + 15 + i * lineHeight;
|
||
node
|
||
.append("text")
|
||
.attr("class", "node-text-line")
|
||
.attr("dy", yPos)
|
||
.text(line);
|
||
});
|
||
});
|
||
function splitTextIntoLines(text, maxCharsPerLine) {
|
||
if (!text) return [""];
|
||
|
||
const words = text.split("");
|
||
const lines = [];
|
||
let currentLine = "";
|
||
|
||
words.forEach((char) => {
|
||
if (currentLine.length >= maxCharsPerLine) {
|
||
lines.push(currentLine);
|
||
currentLine = char;
|
||
} else {
|
||
currentLine += char;
|
||
}
|
||
});
|
||
|
||
if (currentLine) {
|
||
lines.push(currentLine);
|
||
}
|
||
|
||
return lines;
|
||
}
|
||
// 添加与多节点树相同的悬停事件处理
|
||
nodeGroup
|
||
.on("mouseover", (event, d) => {
|
||
// 显示工具提示
|
||
console.log("singled", 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);
|
||
});
|
||
|
||
// 添加单节点的拖拽功能
|
||
setupDrag(nodeGroup);
|
||
});
|
||
|
||
// 更新总高度,以便正确计算画布大小
|
||
const rowCount = Math.ceil(singleNodeTrees.length / nodesPerRow);
|
||
const singleNodesHeight =
|
||
rowCount * (nodeHeight + nodeMargin) + nodeMargin;
|
||
totalHeight += singleNodesHeight;
|
||
}
|
||
|
||
// 自动适应视图
|
||
setTimeout(fitView, 300); // 增加延迟确保DOM已更新
|
||
}
|
||
// 设置拖拽功能
|
||
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;
|
||
|
||
// 计算拖拽距离
|
||
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 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) => {
|
||
fetchTreeData();
|
||
})
|
||
.catch((error) => {
|
||
console.error("更新节点关系失败:", error);
|
||
updateTree(treeData);
|
||
});
|
||
}
|
||
|
||
// 从API获取数据
|
||
async function fetchTreeData() {
|
||
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);
|
||
} 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 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);
|
||
});
|
||
}
|
||
}
|
||
|
||
// 注册按钮事件
|
||
$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>
|