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;
|
|
},
|
|
};
|