164 lines
5.6 KiB
JavaScript
164 lines
5.6 KiB
JavaScript
/**
|
||
* 树形组件
|
||
* 将配置项按“路径映射维度前缀 + 嵌套键”构建成注册表风格的树并渲染
|
||
* 说明:仅映射为 path 的维度进入路径;label 维度由维度栏筛选,不进入树层级;
|
||
* 配置键 key 自身按 / 拆分为多级目录,叶子节点为真正的配置项。
|
||
*/
|
||
const Tree = {
|
||
/**
|
||
* 将配置项列表构建为树形数据结构
|
||
* 每个节点结构: { name, path, type:"dir"|"leaf", children:{}, config }
|
||
* @param {Object} project - 项目对象
|
||
* @param {Array} configs - 已按 label 维度筛选后的配置项列表(含 path 字段)
|
||
* @returns {Object} 树根节点
|
||
*/
|
||
buildTree(project, configs) {
|
||
const root = { name: project.name, path: "", type: "dir", children: {}, config: null };
|
||
|
||
configs.forEach((config) => {
|
||
// path 已由后端按映射规则生成,直接按 / 拆分为层级
|
||
const segments = String(config.path || config.key).split("/").filter((s) => s !== "");
|
||
let node = root;
|
||
let acc = "";
|
||
segments.forEach((seg, idx) => {
|
||
acc = acc ? acc + "/" + seg : seg;
|
||
const isLast = idx === segments.length - 1;
|
||
if (!node.children[seg]) {
|
||
node.children[seg] = { name: seg, path: acc, type: "dir", children: {}, config: null };
|
||
}
|
||
node = node.children[seg];
|
||
if (isLast) {
|
||
// 叶子节点绑定真实配置项
|
||
node.type = "leaf";
|
||
node.config = config;
|
||
}
|
||
});
|
||
});
|
||
|
||
return root;
|
||
},
|
||
|
||
/**
|
||
* 渲染树到指定容器
|
||
* @param {HTMLElement} container - 容器元素
|
||
* @param {Object} project - 项目对象
|
||
* @param {Array} configs - 配置项列表
|
||
* @param {Function} onSelect - 节点选中回调,参数为(配置列表, 路径, 节点)
|
||
*/
|
||
render(container, project, configs, onSelect) {
|
||
container.innerHTML = "";
|
||
if (!project) {
|
||
container.innerHTML = '<div class="tree-empty">请先选择一个项目</div>';
|
||
return;
|
||
}
|
||
const tree = this.buildTree(project, configs);
|
||
const rootEl = this._renderNode(tree, onSelect, true);
|
||
container.appendChild(rootEl);
|
||
},
|
||
|
||
/**
|
||
* 收集某节点及其子孙下的全部叶子配置项
|
||
* @param {Object} node - 树节点
|
||
* @returns {Array} 配置项列表
|
||
*/
|
||
collectConfigs(node) {
|
||
let list = [];
|
||
if (node.type === "leaf" && node.config) {
|
||
list.push(node.config);
|
||
}
|
||
Object.values(node.children).forEach((child) => {
|
||
list = list.concat(this.collectConfigs(child));
|
||
});
|
||
return list;
|
||
},
|
||
|
||
/**
|
||
* 递归渲染单个树节点
|
||
* @param {Object} node - 树节点
|
||
* @param {Function} onSelect - 选中回调
|
||
* @param {boolean} isRoot - 是否为根节点
|
||
* @returns {HTMLElement} 节点元素
|
||
*/
|
||
_renderNode(node, onSelect, isRoot) {
|
||
const nodeEl = document.createElement("div");
|
||
nodeEl.className = "tree-node";
|
||
|
||
const childKeys = Object.keys(node.children);
|
||
const hasChildren = childKeys.length > 0;
|
||
const isLeaf = node.type === "leaf" && !hasChildren;
|
||
|
||
const label = document.createElement("div");
|
||
label.className = "tree-node-label" + (isLeaf ? " is-leaf" : "");
|
||
label.dataset.path = node.path;
|
||
|
||
const toggle = document.createElement("span");
|
||
toggle.className = "tree-toggle";
|
||
toggle.textContent = hasChildren ? "▼" : "";
|
||
|
||
const icon = document.createElement("span");
|
||
icon.className = "tree-icon";
|
||
icon.textContent = isRoot ? "📦" : isLeaf ? "🔑" : "📁";
|
||
|
||
const text = document.createElement("span");
|
||
text.textContent = node.name;
|
||
|
||
label.appendChild(toggle);
|
||
label.appendChild(icon);
|
||
label.appendChild(text);
|
||
nodeEl.appendChild(label);
|
||
|
||
label.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
document.querySelectorAll(".tree-node-label.selected").forEach((el) => el.classList.remove("selected"));
|
||
label.classList.add("selected");
|
||
onSelect(this.collectConfigs(node), node.path || "/", node);
|
||
});
|
||
|
||
if (hasChildren) {
|
||
const childrenEl = document.createElement("div");
|
||
childrenEl.className = "tree-children";
|
||
childKeys.sort().forEach((key) => {
|
||
childrenEl.appendChild(this._renderNode(node.children[key], onSelect, false));
|
||
});
|
||
nodeEl.appendChild(childrenEl);
|
||
|
||
toggle.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
childrenEl.classList.toggle("collapsed");
|
||
toggle.textContent = childrenEl.classList.contains("collapsed") ? "▶" : "▼";
|
||
});
|
||
}
|
||
|
||
return nodeEl;
|
||
},
|
||
|
||
/**
|
||
* 根据路径展开并选中对应节点(供地址栏跳转使用)
|
||
* @param {HTMLElement} container - 树容器
|
||
* @param {string} targetPath - 目标路径
|
||
* @returns {boolean} 是否命中并选中节点
|
||
*/
|
||
selectByPath(container, targetPath) {
|
||
const normalized = String(targetPath || "").split("/").map((s) => s.trim()).filter((s) => s !== "").join("/");
|
||
const labels = container.querySelectorAll(".tree-node-label");
|
||
for (const label of labels) {
|
||
if (label.dataset.path === normalized) {
|
||
// 展开所有祖先节点
|
||
let parent = label.parentElement;
|
||
while (parent && parent !== container) {
|
||
if (parent.classList.contains("tree-children")) {
|
||
parent.classList.remove("collapsed");
|
||
const pToggle = parent.previousElementSibling?.querySelector(".tree-toggle");
|
||
if (pToggle && pToggle.textContent === "▶") pToggle.textContent = "▼";
|
||
}
|
||
parent = parent.parentElement;
|
||
}
|
||
label.click();
|
||
label.scrollIntoView({ block: "center" });
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
},
|
||
};
|