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

296 lines
23 KiB
JavaScript

// --- static/tree-ui.js ---
// ==========================================
// 🌟 輔助函數:全部展開與全部收合
// ==========================================
export function expandAll(elementId) {
const details = document.getElementById(`details-${elementId}`);
if (details) {
details.open = true; // 展開自己
const subDetails = details.querySelectorAll('details');
subDetails.forEach(d => d.open = true); // 展開所有子項目
}
}
export function collapseAll(elementId) {
const details = document.getElementById(`details-${elementId}`);
if (details) {
const subDetails = details.querySelectorAll('details');
subDetails.forEach(d => d.open = false); // 收合所有子項目
details.open = false; // 收合自己
}
}
// ==========================================
// 🌟 核心函數:生成完整設備配置樹狀圖
// ==========================================
export function buildTree(node, path = '', isParentCommandGroup = false) {
let html = '';
if (!node || typeof node !== 'object') return html;
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 = `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>' : '';
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 iconClosed = isCommandGroup ? svgGroupClosed : svgFolderClosed;
const iconOpen = isCommandGroup ? svgGroupOpen : svgFolderOpen;
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 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>
`;
html += `
<details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;"
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none';">
<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: inline-block;">${iconClosed}</span>
<span class="icon-open" style="display: none;">${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}" onclick="event.stopPropagation(); event.preventDefault(); startEditFolder('${currentPath}', '${elementId}')"
style="cursor: pointer; font-size: 14px; opacity: 0.3; transition: opacity 0.2s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3" title="鎖定並編輯此群組">
✏️
</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;">
${buildTree(value, currentPath, isCommandGroup)}
</div>
</details>
`;
} else {
const isVirtualIndex = /^\[\d+\]$/.test(key);
const isNoCommand = (key === 'no');
const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;");
const elementId = `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 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>`;
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);`;
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>
`;
}
html += `
<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}" onclick="event.stopPropagation(); event.preventDefault(); startEditLeaf('${currentPath}', '${elementId}')"
style="cursor: pointer; font-size: 14px; opacity: 0.3; transition: opacity 0.2s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3" title="鎖定並編輯此項目">
✏️
</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 html;
}
// ==========================================
// 🌟 核心函數:生成系統設定的過濾樹狀圖
// ==========================================
export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isParentCommandGroup = false) {
if (typeof data !== 'object' || data === null) return '';
let html = `<div style="margin-left: ${parentPath ? '24px' : '0'};">`;
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 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 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-folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
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 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 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>
`;
html += `
<details id="details-${elementId}" class="tree-folder" ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none';">
<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: inline-block;">${iconClosed}</span>
<span class="icon-open" style="display: none;">${iconOpen}</span>
</span>
<span>${key}${folderSuffix}</span>
${expandCollapseBtns}
</summary>
<div class="filter-children-container" style="border-left: 1px dashed #bdc3c7; margin-left: 7px; padding-left: 16px;">
${buildRealFilterTree(value, currentPath, hiddenKeys, isCommandGroup)}
</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>`;
}
html += `
<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>
`;
}
}
html += '</div>';
return html;
}