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