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

1586 lines
52 KiB
HTML
Raw Permalink 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="/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>