/**
* 树形组件
* 将配置项按“路径映射维度前缀 + 嵌套键”构建成注册表风格的树并渲染
* 说明:仅映射为 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 = '
请先选择一个项目
';
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;
},
};