491 lines
34 KiB
JavaScript
491 lines
34 KiB
JavaScript
// --- 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, """);
|
||
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, """);
|
||
|
||
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];
|
||
|
||
// 🌟 關鍵修復:移除 detailsEl.dataset.loaded !== 'true' 的限制!
|
||
// 因為如果使用者先手動展開了父資料夾,再點擊編輯,
|
||
// 原本的邏輯會因為 loaded === true 而拒絕往下遞迴渲染子資料夾,
|
||
// 導致子資料夾內的項目沒有被轉成輸入框。
|
||
if (detailsEl && contentDiv && cache) {
|
||
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';
|
||
}
|
||
}; |