scm-harmonic-cmts-admin/static/tree-ui.js

487 lines
33 KiB
JavaScript
Raw 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.

// --- static/tree-ui.js ---
import { SESSION_USER_ID, currentEditElementId } from './edit-mode.js';
// ==========================================
// 🌟 效能終極優化:全域快取與延遲渲染 (Lazy Rendering)
// ==========================================
export const folderDataCache = {};
export const filterFolderCache = {};
// 🧹 [修復 Leak] 徹底清空樹狀圖快取,釋放記憶體
export function clearTreeCache() {
for (let key in folderDataCache) delete folderDataCache[key];
for (let key in filterFolderCache) delete filterFolderCache[key];
}
// 魔法函數:當資料夾被點擊展開時,才將 HTML 渲染進去
window.lazyLoadFolder = function(elementId, isFilter = false) {
const detailsEl = document.getElementById(`details-${elementId}`);
// 如果已經載入過,就不重複渲染
if (!detailsEl || detailsEl.dataset.loaded === 'true') return;
// 從 dataset 讀取當初存下來的屬性
const currentPath = detailsEl.dataset.path;
const isCommandGroup = detailsEl.dataset.isGroup === 'true';
const mode = detailsEl.dataset.mode;
const contentDiv = document.getElementById(isFilter ? `filter-content-${elementId}` : `content-${elementId}`);
const cache = isFilter ? filterFolderCache[elementId] : folderDataCache[elementId];
if (contentDiv && cache) {
// 🌟 統一渲染作法:單點展開也套用非同步渲染保護
contentDiv.innerHTML = `<span style="color: #f39c12; font-size: 13px; font-weight: bold; margin-left: 5px;">⏳ 載入中...</span>`;
document.body.style.cursor = 'wait';
setTimeout(() => {
if (isFilter) {
contentDiv.innerHTML = buildRealFilterTree(cache.data, currentPath, cache.hiddenKeys, isCommandGroup, mode);
} else {
contentDiv.innerHTML = buildTree(cache, currentPath, isCommandGroup, mode);
}
detailsEl.dataset.loaded = 'true'; // 標記為已載入
document.body.style.cursor = 'default';
}, 10);
}
};
// ==========================================
// 🌟 輔助函數:全部展開與全部收合 (支援延遲渲染)
// ==========================================
export function expandAll(elementId) {
const details = document.getElementById(`details-${elementId}`);
if (!details) return;
const isFilter = details.id.includes('filter-');
const cache = isFilter ? filterFolderCache[elementId] : folderDataCache[elementId];
if (!cache) return;
// 🌟 讓滑鼠變成讀取狀態,給予使用者即時回饋
document.body.style.cursor = 'wait';
const contentDiv = document.getElementById(isFilter ? `filter-content-${elementId}` : `content-${elementId}`);
if (contentDiv) {
contentDiv.innerHTML = `<span style="color: #f39c12; font-weight: bold; font-size: 13px;">⏳ 正在極速展開中,請稍候...</span>`;
}
// 🌟 使用 setTimeout 讓出一個 Frame讓瀏覽器能渲染出「讀取中」的文字與滑鼠狀態
setTimeout(() => {
const currentPath = details.dataset.path;
const isCommandGroup = details.dataset.isGroup === 'true';
const mode = details.dataset.mode;
// 🚀 核心魔法:在記憶體中一次性遞迴生成所有子節點的 HTML 字串 (傳入 forceExpand = true)
let fullHtml = '';
if (isFilter) {
fullHtml = buildRealFilterTree(cache.data, currentPath, cache.hiddenKeys, isCommandGroup, mode, true);
} else {
fullHtml = buildTree(cache, currentPath, isCommandGroup, mode, true);
}
// 🚀 終極效能:只執行 1 次 DOM 寫入!
if (contentDiv) contentDiv.innerHTML = fullHtml;
details.dataset.loaded = 'true';
details.open = true;
const iconClosed = details.querySelector('.icon-closed');
const iconOpen = details.querySelector('.icon-open');
if (iconClosed) iconClosed.style.display = 'none';
if (iconOpen) iconOpen.style.display = 'inline-block';
document.body.style.cursor = 'default';
}, 20);
}
export function collapseAll(elementId) {
const details = document.getElementById(`details-${elementId}`);
if (details) {
// 🌟 效能優化:只尋找目前是 open 狀態的 details 進行關閉,減少 DOM 操作
const openSubDetails = details.querySelectorAll('details[open]');
openSubDetails.forEach(d => {
d.open = false;
const iconClosed = d.querySelector('.icon-closed');
const iconOpen = d.querySelector('.icon-open');
if (iconClosed) iconClosed.style.display = 'inline-block';
if (iconOpen) iconOpen.style.display = 'none';
});
details.open = false;
const mainIconClosed = details.querySelector('.icon-closed');
const mainIconOpen = details.querySelector('.icon-open');
if (mainIconClosed) mainIconClosed.style.display = 'inline-block';
if (mainIconOpen) mainIconOpen.style.display = 'none';
}
}
// ==========================================
// 🌟 核心函數:生成完整設備配置樹狀圖 (Lazy 版)
// ==========================================
// 🌟 修改函數宣告,加入 renderAll 參數
export function buildTree(node, path = '', isParentCommandGroup = false, mode = 'running', forceExpand = false, renderAll = false) {
const htmlParts = [];
if (!node || typeof node !== 'object') return htmlParts.join('');
const svgFolderClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
const svgFolderOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M19 8H8.99C8.04 8 7.19 8.59 6.81 9.46L2.81 18.55C2.62 18.98 2.94 19.5 3.41 19.5H16.99C17.94 19.5 18.79 18.91 19.17 18.04L23.17 8.95C23.36 8.52 23.04 8 22.57 8H19zM4 4c-1.1 0-2 .9-2 2v10.59l3.09-7.04C5.58 8.36 6.27 8 7.01 8H19v-2c0-1.1-.9-2-2-2h-7l-2-2H4c-1.1 0-2 .9-2 2v12z"/></svg>`;
const svgGroupClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#95a5a6"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
const svgGroupOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#3498db"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
const expandAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 13 12 18 17 13"></polyline><polyline points="7 6 12 11 17 6"></polyline></svg>`;
const collapseAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 11 12 6 7 11"></polyline><polyline points="17 18 12 13 7 18"></polyline></svg>`;
const svgLeaf = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#bdc3c7"><path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/></svg>`;
const svgDisabled = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#e74c3c"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"/></svg>`;
for (const [key, value] of Object.entries(node)) {
const currentPath = path ? `${path}::${key}` : key;
let cliCommand = currentPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
if (typeof value === 'object' && value !== null) {
const hasNestedObject = Object.values(value).some(v => typeof v === 'object' && v !== null);
const isCommandGroup = isParentCommandGroup || !hasNestedObject;
const elementId = `${mode}-folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
const folderSuffix = isCommandGroup ? '<span style="color: #95a5a6; font-size: 0.85em; font-weight: normal; margin-left: 5px;">(指令群組)</span>' : '';
folderDataCache[elementId] = value;
const iconClosed = isCommandGroup ? svgGroupClosed : svgFolderClosed;
const iconOpen = isCommandGroup ? svgGroupOpen : svgFolderOpen;
const expandCollapseBtns = `
<span style="margin-left: 8px; display: inline-flex; gap: 6px; align-items: center;">
<span onclick="event.stopPropagation(); event.preventDefault(); expandAll('${elementId}')"
style="cursor: pointer; color: #95a5a6; transition: color 0.2s; display: flex; align-items: center;"
onmouseover="this.style.color='#3498db'" onmouseout="this.style.color='#95a5a6'" title="展開全部子項目">
${expandAllIcon}
</span>
<span onclick="event.stopPropagation(); event.preventDefault(); collapseAll('${elementId}')"
style="cursor: pointer; color: #95a5a6; transition: color 0.2s; display: flex; align-items: center;"
onmouseover="this.style.color='#e67e22'" onmouseout="this.style.color='#95a5a6'" title="收合全部子項目">
${collapseAllIcon}
</span>
</span>
`;
const currentHost = document.getElementById('cmtsHost')?.value.trim();
let isLocked = false;
let lockTitle = "鎖定並編輯此群組";
let lockText = "✏️";
let lockOpacity = "0.3";
let lockPointer = "auto";
if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost]) {
const hostLocks = window.globalActiveLocks[currentHost];
if (hostLocks[currentPath]) {
const lockInfo = hostLocks[currentPath];
if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) {
isLocked = true; lockText = "🔒"; lockOpacity = "0.2"; lockPointer = "none";
lockTitle = lockInfo.user_id !== SESSION_USER_ID ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中` : `🔒 您正在另一個視圖編輯此項目`;
}
} else {
for (const [lockedPath, lockInfo] of Object.entries(hostLocks)) {
if (currentPath.startsWith(lockedPath + "::")) {
isLocked = true; lockText = "🔒"; lockOpacity = "0.2"; lockPointer = "none";
lockTitle = `🔒 父層級已被鎖定,無法編輯此項目`; break;
}
}
}
}
// 🚀 核心魔法:如果 forceExpand 為 true直接在記憶體中遞迴生成子節點 HTML
const isOpenAttr = forceExpand ? 'open' : '';
const isLoadedAttr = (forceExpand || renderAll) ? 'true' : 'false';
const displayClosed = forceExpand ? 'none' : 'inline-block';
const displayOpen = forceExpand ? 'inline-block' : 'none';
let innerContent = '<!-- 🌟 延遲渲染 -->';
if (forceExpand || renderAll) {
// 🌟 遞迴呼叫時,把 renderAll 傳遞下去
innerContent = buildTree(value, currentPath, isCommandGroup, mode, forceExpand, renderAll);
}
htmlParts.push(`
<details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;"
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}" data-loaded="${isLoadedAttr}" ${isOpenAttr}
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', false);">
<summary class="tree-folder-header"
title="${cliCommand}"
onmouseover="this.style.backgroundColor='#f1f2f6'"
onmouseout="this.style.backgroundColor='transparent'"
style="cursor: pointer; padding: 4px 8px; display: flex; align-items: center; outline: none; border-radius: 4px; transition: background-color 0.2s;">
<span style="margin-right: 6px; display: inline-flex; align-items: center; width: 18px; height: 18px;">
<span class="icon-closed" style="display: ${displayClosed};">${iconClosed}</span>
<span class="icon-open" style="display: ${displayOpen};">${iconOpen}</span>
</span>
<b style="color: #2c3e50;">${key}</b>${folderSuffix}
${expandCollapseBtns}
<div style="margin-left: auto; display: flex; align-items: center;">
<span class="debug-preview-btn admin-only"
onclick="event.stopPropagation(); event.preventDefault(); previewNodeCLI(this, '${currentPath}')"
style="display: none; cursor: pointer; margin-right: 8px; font-size: 14px; transition: transform 0.2s;"
title="[開發者] 預覽此節點下所有指令"
onmouseover="this.style.transform='scale(1.2)'"
onmouseout="this.style.transform='scale(1)'">
🔍
</span>
<span id="edit-btn-${elementId}" class="edit-btn" data-path="${currentPath}" onclick="event.stopPropagation(); event.preventDefault(); startEditFolder('${currentPath}', '${elementId}')"
style="cursor: pointer; font-size: 14px; opacity: ${lockOpacity}; pointer-events: ${lockPointer}; transition: opacity 0.2s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=${lockOpacity}" title="${lockTitle}">
${lockText}
</span>
<span id="actions-${elementId}" style="display:none; margin-left: 10px;">
<button onclick="event.stopPropagation(); event.preventDefault(); previewFolderCLI('${currentPath}', '${elementId}')" style="background-color: #27ae60; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">✅ 預覽指令</button>
<button onclick="event.stopPropagation(); event.preventDefault(); cancelEditFolder('${currentPath}', '${elementId}')" style="background-color: #e74c3c; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px; margin-left: 5px;">❌ 取消</button>
</span>
</div>
</summary>
<div id="content-${elementId}" class="tree-folder-content" style="margin-left: 12px; border-left: 1px dashed #bdc3c7; padding-left: 12px;">
${innerContent}
</div>
</details>
`);
} else {
const isVirtualIndex = /^\[\d+\]$/.test(key);
const isNoCommand = (key === 'no');
const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;");
const elementId = `${mode}-leaf-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
if (isNoCommand) {
let baseCmd = cliCommand.replace(/(^|\s)no$/, '').trim();
cliCommand = baseCmd ? `${baseCmd} no ${safeValue}` : `no ${safeValue}`;
}
const iconHtml = (icon) => `<span style="margin-right: 6px; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px;">${icon}</span>`;
const valueStyle = `color: #16a085; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);`;
const currentHost = document.getElementById('cmtsHost')?.value.trim();
let isLocked = false;
let lockTitle = "鎖定並編輯此項目";
let lockText = "✏️";
let lockOpacity = "0.3";
let lockPointer = "auto";
if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost]) {
const hostLocks = window.globalActiveLocks[currentHost];
if (hostLocks[currentPath]) {
const lockInfo = hostLocks[currentPath];
if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) {
isLocked = true; lockText = "🔒"; lockOpacity = "0.2"; lockPointer = "none";
lockTitle = lockInfo.user_id !== SESSION_USER_ID ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中` : `🔒 您正在另一個視圖編輯此項目`;
}
} else {
for (const [lockedPath, lockInfo] of Object.entries(hostLocks)) {
if (currentPath.startsWith(lockedPath + "::")) {
isLocked = true; lockText = "🔒"; lockOpacity = "0.2"; lockPointer = "none";
lockTitle = `🔒 父層級已被鎖定,無法編輯此項目`; break;
}
}
}
}
let displayContent = '';
if (isVirtualIndex) {
displayContent = `
${iconHtml(svgLeaf)}
<span class="leaf-container" id="container-${elementId}" data-path="${currentPath}" data-original="${safeValue}" style="display: inline-flex; align-items: center; min-height: 24px;">
<span class="leaf-value" style="${valueStyle}">${safeValue}</span>
</span>
`;
} else if (isNoCommand) {
displayContent = `
<span id="display-wrapper-${elementId}" style="display: inline-flex; align-items: center;">
${iconHtml(svgDisabled)}
<b style="color: #e74c3c;">no:</b>
<span class="leaf-container" id="container-${elementId}" data-path="${currentPath}" data-original="${safeValue}" style="display: inline-flex; align-items: center; min-height: 24px;">
<span class="leaf-value" style="color: #7f8c8d; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);">${safeValue}</span>
</span>
</span>
`;
} else {
displayContent = `
${iconHtml(svgLeaf)} <b>${key}</b>:
<span class="leaf-container" id="container-${elementId}" data-path="${currentPath}" data-original="${safeValue}" style="display: inline-flex; align-items: center; min-height: 24px;">
<span class="leaf-value" style="${valueStyle}">${safeValue}</span>
</span>
`;
}
htmlParts.push(`
<div class="tree-leaf-node"
title="${cliCommand}"
onmouseover="this.style.backgroundColor='#f1f2f6'"
onmouseout="this.style.backgroundColor='transparent'"
style="padding: 4px 8px; color: #34495e; margin-left: 20px; border-left: 1px solid #ecf0f1; display: flex; align-items: center; border-radius: 4px; transition: background-color 0.2s;">
${displayContent}
<div style="margin-left: auto; display: flex; align-items: center;">
<span class="debug-preview-btn admin-only"
onclick="event.stopPropagation(); event.preventDefault(); previewNodeCLI(this, '${currentPath}')"
style="display: none; cursor: pointer; margin-right: 8px; font-size: 14px; transition: transform 0.2s;"
title="[開發者] 預覽此節點下所有指令"
onmouseover="this.style.transform='scale(1.2)'"
onmouseout="this.style.transform='scale(1)'">
🔍
</span>
<span id="edit-btn-${elementId}" class="edit-btn" data-path="${currentPath}" onclick="event.stopPropagation(); event.preventDefault(); startEditLeaf('${currentPath}', '${elementId}')"
style="cursor: pointer; font-size: 14px; opacity: ${lockOpacity}; pointer-events: ${lockPointer}; transition: opacity 0.2s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=${lockOpacity}" title="${lockTitle}">
${lockText}
</span>
<span id="actions-${elementId}" style="display:none; margin-left: 10px;">
<button onclick="event.stopPropagation(); event.preventDefault(); previewLeafCLI('${currentPath}', '${elementId}')" style="background-color: #27ae60; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">✅ 預覽指令</button>
<button onclick="event.stopPropagation(); event.preventDefault(); cancelEditLeaf('${currentPath}', '${elementId}')" style="background-color: #e74c3c; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px; margin-left: 5px;">❌ 取消</button>
</span>
</div>
</div>
`);
}
}
return htmlParts.join('');
}
// ==========================================
// 🌟 核心函數:生成系統設定的過濾樹狀圖 (Lazy 版)
// ==========================================
// 🌟 新增 forceExpand 參數,預設為 false
export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isParentCommandGroup = false, mode = 'running', forceExpand = false) {
if (typeof data !== 'object' || data === null) return '';
const htmlParts = [];
htmlParts.push(`<div style="margin-left: ${parentPath ? '24px' : '0'};">`);
const svgFolderClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
const svgFolderOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M19 8H8.99C8.04 8 7.19 8.59 6.81 9.46L2.81 18.55C2.62 18.98 2.94 19.5 3.41 19.5H16.99C17.94 19.5 18.79 18.91 19.17 18.04L23.17 8.95C23.36 8.52 23.04 8 22.57 8H19zM4 4c-1.1 0-2 .9-2 2v10.59l3.09-7.04C5.58 8.36 6.27 8 7.01 8H19v-2c0-1.1-.9-2-2-2h-7l-2-2H4c-1.1 0-2 .9-2 2v12z"/></svg>`;
const svgGroupClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#95a5a6"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
const svgGroupOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#3498db"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
const svgLeaf = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#bdc3c7"><path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/></svg>`;
const expandAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 13 12 18 17 13"></polyline><polyline points="7 6 12 11 17 6"></polyline></svg>`;
const collapseAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 11 12 6 7 11"></polyline><polyline points="17 18 12 13 7 18"></polyline></svg>`;
for (const key in data) {
const value = data[key];
const currentPath = parentPath ? `${parentPath}::${key}` : key;
const isChecked = hiddenKeys.includes(currentPath) ? "checked" : "";
let cliCommand = currentPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
const isFolder = typeof value === 'object' && value !== null && Object.keys(value).length > 0;
const onChangeEvent = isFolder ? 'toggleChildCheckboxes(this)' : 'updateParentCheckboxState(this)';
const checkboxHtml = `<input type="checkbox" value="${currentPath}" class="filter-checkbox" ${isChecked} onclick="event.stopPropagation();" onchange="${onChangeEvent}" style="margin-right: 8px; cursor: pointer; width: 14px; height: 14px; flex-shrink: 0;">`;
if (isFolder) {
const hasNestedObject = Object.values(value).some(v => typeof v === 'object' && v !== null);
const isCommandGroup = isParentCommandGroup || !hasNestedObject;
const elementId = `filter-${mode}-folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
filterFolderCache[elementId] = { data: value, hiddenKeys: hiddenKeys };
const iconClosed = isCommandGroup ? svgGroupClosed : svgFolderClosed;
const iconOpen = isCommandGroup ? svgGroupOpen : svgFolderOpen;
const folderSuffix = isCommandGroup ? `<span style="color: #95a5a6; font-size: 0.85em; font-weight: normal; margin-left: 5px;">(指令群組)</span>` : '';
const expandCollapseBtns = `
<span style="margin-left: 8px; display: inline-flex; gap: 6px; align-items: center;">
<span onclick="event.stopPropagation(); event.preventDefault(); expandAll('${elementId}')"
style="cursor: pointer; color: #95a5a6; transition: color 0.2s; display: flex; align-items: center;"
onmouseover="this.style.color='#3498db'" onmouseout="this.style.color='#95a5a6'" title="展開全部子項目">
${expandAllIcon}
</span>
<span onclick="event.stopPropagation(); event.preventDefault(); collapseAll('${elementId}')"
style="cursor: pointer; color: #95a5a6; transition: color 0.2s; display: flex; align-items: center;"
onmouseover="this.style.color='#e67e22'" onmouseout="this.style.color='#95a5a6'" title="收合全部子項目">
${collapseAllIcon}
</span>
</span>
`;
// 🚀 核心魔法:如果 forceExpand 為 true直接在記憶體中遞迴生成子節點 HTML
const isOpenAttr = forceExpand ? 'open' : '';
const isLoadedAttr = forceExpand ? 'true' : 'false';
const displayClosed = forceExpand ? 'none' : 'inline-block';
const displayOpen = forceExpand ? 'inline-block' : 'none';
let innerContent = '<!-- 🌟 延遲渲染 -->';
if (forceExpand) {
innerContent = buildRealFilterTree(value, currentPath, hiddenKeys, isCommandGroup, mode, true);
}
htmlParts.push(`
<details id="details-${elementId}" class="tree-folder"
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}" data-loaded="${isLoadedAttr}" ${isOpenAttr}
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', true);">
<summary class="tree-node-header" title="${cliCommand}" style="font-weight: bold; color: #2c3e50; margin-top: 4px; list-style: none; display: flex; align-items: center; cursor: pointer; outline: none; padding: 4px 0;">
<style>#details-${elementId} > summary::-webkit-details-marker { display: none; }</style>
${checkboxHtml}
<span style="margin-right: 6px; display: inline-flex; align-items: center; width: 18px; height: 18px;">
<span class="icon-closed" style="display: ${displayClosed};">${iconClosed}</span>
<span class="icon-open" style="display: ${displayOpen};">${iconOpen}</span>
</span>
<span>${key}${folderSuffix}</span>
${expandCollapseBtns}
</summary>
<div id="filter-content-${elementId}" class="filter-children-container" style="border-left: 1px dashed #bdc3c7; margin-left: 7px; padding-left: 16px;">
${innerContent}
</div>
</details>
`);
} else {
const isVirtualIndex = /^\[\d+\]$/.test(key);
let displayName = isVirtualIndex ? `<span style="color: #95a5a6; font-weight: normal;">項目 ${key}</span>` : `<b>${key}</b>`;
const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;");
const isNoCommand = (key === 'no');
let valueHtml = `<span style="color: #16a085; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);">${safeValue}</span>`;
let colonHtml = `:`;
let currentIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#bdc3c7"><path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/></svg>`;
if (isNoCommand) {
let baseCmd = cliCommand.replace(/(^|\s)no$/, '').trim();
cliCommand = baseCmd ? `${baseCmd} no ${safeValue}` : `no ${safeValue}`;
displayName = `<b style="color: #e74c3c;">no</b>`;
colonHtml = `:`;
valueHtml = `<span style="color: #7f8c8d; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);">${safeValue}</span>`;
currentIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#e74c3c"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"/></svg>`;
}
htmlParts.push(`
<div class="tree-leaf-node"
onmouseover="this.style.backgroundColor='#f1f2f6'"
onmouseout="this.style.backgroundColor='transparent'"
style="padding: 2px 0; color: #34495e; display: flex; align-items: center; margin-top: 2px; border-radius: 4px; transition: background-color 0.2s;"
title="${cliCommand}">
${checkboxHtml}
<span style="margin-right: 6px; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; flex-shrink: 0;">
${currentIcon}
</span>
<span style="margin-right: 5px;">${displayName}${colonHtml}</span>
${valueHtml}
</div>
`);
}
}
htmlParts.push('</div>');
return htmlParts.join('');
}
// ==========================================
// 🌟 輔助函數:強制預先渲染資料夾 HTML (供編輯模式使用)
// ==========================================
window.forceRenderFolderHTML = function(elementId) {
const detailsEl = document.getElementById(`details-${elementId}`);
const contentDiv = document.getElementById(`content-${elementId}`);
const cache = folderDataCache[elementId];
if (detailsEl && contentDiv && cache && detailsEl.dataset.loaded !== 'true') {
const currentPath = detailsEl.dataset.path;
const isCommandGroup = detailsEl.dataset.isGroup === 'true';
const mode = detailsEl.dataset.mode;
// 重新渲染,傳入 renderAll = true (生成 HTML 但不展開)
contentDiv.innerHTML = buildTree(cache, currentPath, isCommandGroup, mode, false, true);
detailsEl.dataset.loaded = 'true';
}
};