// --- static/edit-mode.js --- import { apiAcquireLock, apiReleaseLock, apiSendHeartbeat, apiGetLeafOptions, apiSyncLeafOptions, apiGenerateCli, apiExecuteConfig, apiSyncLeafOptionsStream } from './api.js'; import { getGlobalConnectionInfo } from './utils.js'; // --- 安全跳脫 HTML 特殊字元,防止破版 --- function escapeHTML(str) { if (str === null || str === undefined) return ""; return String(str) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } // ========================================== // 1. 全域狀態與鎖定管理 (Lock Management) // ========================================== export const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9); const ACTIVE_HEARTBEATS = {}; // 🌟 新增:用來防禦「秒按取消」導致的非同步渲染競爭危害 const activeEditSessions = new Set(); export let currentEditPath = null; export let currentEditElementId = null; export let currentEditIsSuccess = false; export let currentEditType = null; export let currentEditDiffs = []; export async function releaseAllLocks() { const keysToRelease = Object.keys(ACTIVE_HEARTBEATS); // 🌟 1. 檢查當前是否處於 God Mode 狀態 const wasGodModeUnlocked = sessionStorage.getItem('godModeUnlocked') === 'true'; // 🌟 2. 只要觸發閒置,立刻無條件清除 God Mode 權限 sessionStorage.removeItem('godModeUnlocked'); if (keysToRelease.length === 0) { // 如果沒有正在編輯的節點,但剛剛是 God Mode,依然要登出並重整 if (wasGodModeUnlocked) { alert("您已閒置超過 5 分鐘,系統已自動為您登出進階維護者模式。"); location.reload(); } return; } const releasePromises = keysToRelease.map(key => { const [host, path] = key.split('@@'); clearInterval(ACTIVE_HEARTBEATS[key]); delete ACTIVE_HEARTBEATS[key]; return apiReleaseLock(path, SESSION_USER_ID, host) .catch(err => console.error(`釋放鎖定 ${path} 失敗:`, err)); }); await Promise.all(releasePromises); alert("您已閒置超過 5 分鐘,系統已自動釋放編輯鎖定並登出進階模式。"); location.reload(); // 重整網頁,確保所有 UI 恢復未解鎖狀態 } async function sendHeartbeat(path, host, intervalId, lockKey) { try { const response = await apiSendHeartbeat(path, SESSION_USER_ID, host); if (!response.ok) { console.warn(`心跳發送失敗 (${path}): 伺服器回傳 ${response.status}`); if (response.status === 404) { clearInterval(intervalId); if (ACTIVE_HEARTBEATS[lockKey] === intervalId) { delete ACTIVE_HEARTBEATS[lockKey]; } } } } catch (error) { console.error(`心跳請求發生錯誤 (${path}):`, error); } } // ========================================== // 2. 編輯模式啟動與取消 (Folder & Leaf) // ========================================== export async function startEditFolder(path, elementId) { // 🚨 防護 1:防止連點或重複執行 if (activeEditSessions.has(elementId)) return; activeEditSessions.add(elementId); // 立即標記進入編輯狀態的意圖 const connInfo = getGlobalConnectionInfo(); const username = connInfo ? connInfo.user : 'admin'; const host = connInfo ? connInfo.host : ''; const lockKey = `${host}@@${path}`; const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running'; // 立即讓按鈕呈現讀取中,防止使用者焦慮連點 const editBtn = document.getElementById(`edit-btn-${elementId}`); if (editBtn) { editBtn.style.pointerEvents = 'none'; editBtn.innerText = "⏳"; } try { const result = await apiAcquireLock(path, SESSION_USER_ID, username, host); // 🚨 防護 2:如果在等待後端上鎖的期間,使用者已經按了取消 if (!activeEditSessions.has(elementId)) { if (result.status === 200 || (result.data && result.data.status === 'success')) { // 🌟 補上防閃爍冷卻,防止輪詢抓到時間差 if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {}; window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now(); apiReleaseLock(path, SESSION_USER_ID, host); // 默默把剛拿到的鎖還回去 } if (editBtn) { editBtn.style.pointerEvents = 'auto'; editBtn.style.opacity = '0.3'; editBtn.title = "鎖定並編輯此項目"; editBtn.innerText = "✏️"; } return; // 立即中斷,絕不觸碰 UI } if (result.status === 409) { activeEditSessions.delete(elementId); if (editBtn) { editBtn.style.pointerEvents = 'auto'; editBtn.innerText = "✏️"; } return alert(`⚠️ ${result.data.detail}`); } if (result.data.status === 'success') { if (ACTIVE_HEARTBEATS[lockKey]) clearInterval(ACTIVE_HEARTBEATS[lockKey]); let intervalId; intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000); ACTIVE_HEARTBEATS[lockKey] = intervalId; // 🌟 立即更新全域鎖定狀態 (加入 Host 隔離) if (!window.globalActiveLocks) window.globalActiveLocks = {}; if (!window.globalActiveLocks[host]) window.globalActiveLocks[host] = {}; window.globalActiveLocks[host][path] = { user_id: SESSION_USER_ID, username: username }; // 🌟 確保不在冷卻名單中 (防止手速過快) if (window.recentlyReleasedLocks) { delete window.recentlyReleasedLocks[`${host}@@${path}`]; } const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); // 🌟 擴大選擇器:連同所有子節點一起秒級鎖定 document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`).forEach(btn => { if (btn.id !== `edit-btn-${elementId}`) { btn.style.pointerEvents = 'none'; btn.style.opacity = '0.2'; const btnPath = btn.getAttribute('data-path'); if (btnPath === path) { btn.title = `🔒 您正在另一個視圖編輯此項目`; } else { btn.title = `🔒 父層級已被鎖定,無法編輯此項目`; } btn.innerText = "🔒"; } }); if (editBtn) editBtn.style.display = 'none'; const actionBtn = document.getElementById(`actions-${elementId}`); if (actionBtn) actionBtn.style.display = 'inline-block'; const detailsEl = document.getElementById(`details-${elementId}`); if (detailsEl) detailsEl.open = true; const contentDiv = document.getElementById(`content-${elementId}`); if (!contentDiv) { console.warn(`[防呆警告] 找不到 content-${elementId} 的容器,請略過此資料夾的編輯。`); return; } const leafContainers = contentDiv.querySelectorAll('.leaf-container'); let optData = {}; try { optData = await apiGetLeafOptions(host, currentMode); } catch (e) {} // 🚨 防護 3:如果等待 API 期間使用者按了取消,立刻中斷後續渲染 if (!activeEditSessions.has(elementId)) return; const pathsToSync = []; const containersArray = Array.from(leafContainers); const CHUNK_SIZE = 50; for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) { // 🚨 防護 4:如果分批渲染期間使用者按了取消,立刻中斷 if (!activeEditSessions.has(elementId)) return; const chunk = containersArray.slice(i, i + CHUNK_SIZE); chunk.forEach(container => { const origVal = container.getAttribute('data-original'); const rawPath = container.getAttribute('data-path'); const safeOrigVal = escapeHTML(origVal); let cacheKey = rawPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); if (rawPath.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal; let leafCache = optData[cacheKey]; if (!leafCache) pathsToSync.push(cacheKey); let inputHtml = ""; let hintAttr = ""; let hintIcon = ""; if (leafCache && leafCache.hint) { const safeHint = leafCache.hint.replace(/"/g, '"'); hintAttr = `title="${safeHint}"`; hintIcon = ``; } if (leafCache && leafCache.options && leafCache.options.length > 0) { let options = leafCache.options; if (origVal && !options.includes(origVal)) options = [origVal, ...options]; inputHtml = `${hintIcon}`; } else { inputHtml = `${hintIcon}`; } container.innerHTML = inputHtml; }); await new Promise(resolve => setTimeout(resolve, 0)); } if (pathsToSync.length > 0) apiSyncLeafOptions(host, pathsToSync, currentMode, username, connInfo.pass); } } catch (error) { activeEditSessions.delete(elementId); if (editBtn) { editBtn.style.pointerEvents = 'auto'; editBtn.innerText = "✏️"; } alert("❌ 無法連線到鎖定伺服器:" + error.message); } } export async function startEditLeaf(path, elementId) { // 🚨 防護 1:防止連點或重複執行 if (activeEditSessions.has(elementId)) return; activeEditSessions.add(elementId); const connInfo = getGlobalConnectionInfo(); const username = connInfo ? connInfo.user : 'admin'; const host = connInfo ? connInfo.host : ''; const lockKey = `${host}@@${path}`; const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running'; const editBtn = document.getElementById(`edit-btn-${elementId}`); if (editBtn) { editBtn.style.pointerEvents = 'none'; editBtn.innerText = "⏳"; } try { const result = await apiAcquireLock(path, SESSION_USER_ID, username, host); // 🚨 防護 2:如果在等待後端上鎖的期間,使用者已經按了取消 if (!activeEditSessions.has(elementId)) { if (result.status === 200 || (result.data && result.data.status === 'success')) { // 🌟 補上防閃爍冷卻,防止輪詢抓到時間差 if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {}; window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now(); apiReleaseLock(path, SESSION_USER_ID, host); } if (editBtn) { editBtn.style.pointerEvents = 'auto'; editBtn.style.opacity = '0.3'; editBtn.title = "鎖定並編輯此項目"; editBtn.innerText = "✏️"; } return; } if (result.status === 409) { activeEditSessions.delete(elementId); if (editBtn) { editBtn.style.pointerEvents = 'auto'; editBtn.innerText = "✏️"; } return alert(`⚠️ ${result.data.detail}`); } if (result.data.status === 'success') { if (ACTIVE_HEARTBEATS[lockKey]) clearInterval(ACTIVE_HEARTBEATS[lockKey]); let intervalId; intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000); ACTIVE_HEARTBEATS[lockKey] = intervalId; if (!window.globalActiveLocks) window.globalActiveLocks = {}; if (!window.globalActiveLocks[host]) window.globalActiveLocks[host] = {}; window.globalActiveLocks[host][path] = { user_id: SESSION_USER_ID, username: username }; if (window.recentlyReleasedLocks) { delete window.recentlyReleasedLocks[`${host}@@${path}`]; } const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`).forEach(btn => { if (btn.id !== `edit-btn-${elementId}`) { btn.style.pointerEvents = 'none'; btn.style.opacity = '0.2'; btn.title = `🔒 您正在另一個視圖編輯此項目`; btn.innerText = "🔒"; } }); if (editBtn) editBtn.style.display = 'none'; document.getElementById(`actions-${elementId}`).style.display = 'inline-block'; const container = document.getElementById(`container-${elementId}`); const origVal = container.getAttribute('data-original'); const safeOrigVal = escapeHTML(origVal); // 🌟 修正:統一為標準橘色並加粗 (原為 #e67e22) container.innerHTML = `⏳ 設備連線與載入選項中...`; try { let optData = await apiGetLeafOptions(host, currentMode); let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); if (path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal; let leafCache = optData[cacheKey]; if (!leafCache) { apiSyncLeafOptions(host, [cacheKey], currentMode, username, connInfo.pass); let found = false; for (let i = 0; i < 10; i++) { if (!activeEditSessions.has(elementId)) return; // 🚨 防護 await new Promise(resolve => setTimeout(resolve, 1000)); optData = await apiGetLeafOptions(host, currentMode); leafCache = optData[cacheKey]; // 🌟 修正:只要取得 hint(說明) 或選項,就立即終止等待,讓 ❓ 提早顯示出來 if (leafCache && (leafCache.hint || (leafCache.options && leafCache.options.length > 0))) { found = true; break; } } } if (!activeEditSessions.has(elementId)) return; // 🚨 防護 let inputHtml = ""; let hintAttr = ""; let hintIcon = ""; if (leafCache && leafCache.hint) { const safeHint = leafCache.hint.replace(/"/g, '"'); hintAttr = `title="${safeHint}"`; hintIcon = ``; } if (leafCache && leafCache.options && leafCache.options.length > 0) { let options = leafCache.options; if (origVal && !options.includes(origVal)) options = [origVal, ...options]; inputHtml = `${hintIcon}`; } else { inputHtml = `${hintIcon}`; } container.innerHTML = inputHtml; } catch (e) { if (!activeEditSessions.has(elementId)) return; // 🚨 防護 container.innerHTML = ``; } } } catch (error) { activeEditSessions.delete(elementId); if (editBtn) { editBtn.style.pointerEvents = 'auto'; editBtn.innerText = "✏️"; } alert("❌ 無法連線到鎖定伺服器:" + error.message); } } export async function cancelEditFolder(path, elementId) { activeEditSessions.delete(elementId); // 🚨 防護:註銷編輯狀態,強制中斷背景渲染迴圈 const connInfo = getGlobalConnectionInfo(); const host = connInfo ? connInfo.host : ''; const lockKey = `${host}@@${path}`; if (ACTIVE_HEARTBEATS[lockKey]) { clearInterval(ACTIVE_HEARTBEATS[lockKey]); delete ACTIVE_HEARTBEATS[lockKey]; } // 🌟 關鍵修復:寫入防閃爍冷卻表必須在 await 之前!防止 API 延遲期間被輪詢重新上鎖 if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {}; window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now(); try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {} // 🌟 立即解除全域鎖定狀態 (加入 Host 隔離) if (window.globalActiveLocks && window.globalActiveLocks[host] && window.globalActiveLocks[host][path]) { delete window.globalActiveLocks[host][path]; } const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); // 🌟 擴大選擇器:連同所有子節點一起秒級解除鎖定 document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`).forEach(btn => { if (btn.id !== `edit-btn-${elementId}`) { const btnPath = btn.getAttribute('data-path'); let stillLockedByOther = false; // 確保解除鎖定時,不會誤解開被其他父節點鎖定的子節點 if (window.globalActiveLocks && window.globalActiveLocks[host]) { const hostLocks = window.globalActiveLocks[host]; if (hostLocks[btnPath]) { stillLockedByOther = true; } else { for (const lockedPath of Object.keys(hostLocks)) { if (btnPath.startsWith(lockedPath + "::")) { stillLockedByOther = true; break; } } } } if (!stillLockedByOther) { btn.style.pointerEvents = 'auto'; btn.style.opacity = '0.3'; btn.title = "鎖定並編輯此項目"; btn.innerText = "✏️"; } } }); const editBtn = document.getElementById(`edit-btn-${elementId}`); if (editBtn) { editBtn.style.display = 'inline-block'; editBtn.style.pointerEvents = 'auto'; editBtn.style.opacity = '0.3'; editBtn.title = "鎖定並編輯此項目"; editBtn.innerText = "✏️"; // 🌟 確保沙漏被清掉 } const actionBtns = document.getElementById(`actions-${elementId}`); if (actionBtns) actionBtns.style.display = 'none'; const contentDiv = document.getElementById(`content-${elementId}`); const leafContainers = contentDiv.querySelectorAll('.leaf-container'); leafContainers.forEach(container => { const origVal = container.getAttribute('data-original'); const safeOrigVal = escapeHTML(origVal); container.innerHTML = `${safeOrigVal}`; }); // 🌟 聯動防呆機制:如果右側預覽視窗正在顯示這個節點的指令,連帶關閉它,防止無鎖寫入 if (currentEditElementId === elementId) { document.querySelectorAll('.tree-view-instance').forEach(pane => { pane.style.flex = '1'; pane.style.width = '100%'; pane.style.maxWidth = 'none'; }); const rightPane = document.getElementById('side-cli-preview'); if (rightPane) { rightPane.style.flex = '1'; rightPane.style.maxWidth = '50%'; rightPane.style.width = ''; rightPane.style.display = 'none'; } document.getElementById('drag-resizer').style.display = 'none'; currentEditPath = null; currentEditElementId = null; currentEditIsSuccess = false; currentEditType = null; } } export async function cancelEditLeaf(path, elementId) { activeEditSessions.delete(elementId); // 🚨 防護:註銷編輯狀態,強制中斷背景渲染迴圈 const connInfo = getGlobalConnectionInfo(); const host = connInfo ? connInfo.host : ''; const lockKey = `${host}@@${path}`; if (ACTIVE_HEARTBEATS[lockKey]) { clearInterval(ACTIVE_HEARTBEATS[lockKey]); delete ACTIVE_HEARTBEATS[lockKey]; } // 🌟 關鍵修復:寫入防閃爍冷卻表必須在 await 之前!防止 API 延遲期間被輪詢重新上鎖 if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {}; window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now(); try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {} // 🌟 立即解除全域鎖定狀態 (加入 Host 隔離) if (window.globalActiveLocks && window.globalActiveLocks[host] && window.globalActiveLocks[host][path]) { delete window.globalActiveLocks[host][path]; } const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`).forEach(btn => { if (btn.id !== `edit-btn-${elementId}`) { btn.style.pointerEvents = 'auto'; btn.style.opacity = '0.3'; btn.title = "鎖定並編輯此項目"; btn.innerText = "✏️"; } }); const editBtn = document.getElementById(`edit-btn-${elementId}`); if (editBtn) { editBtn.style.display = 'inline-block'; editBtn.style.pointerEvents = 'auto'; editBtn.style.opacity = '0.3'; editBtn.title = "鎖定並編輯此項目"; editBtn.innerText = "✏️"; // 🌟 確保沙漏被清掉 } const actionBtns = document.getElementById(`actions-${elementId}`); if (actionBtns) actionBtns.style.display = 'none'; const container = document.getElementById(`container-${elementId}`); const origVal = container.getAttribute('data-original'); const safeOrigVal = escapeHTML(origVal); container.innerHTML = `${safeOrigVal}`; // 🌟 聯動防呆機制:如果右側預覽視窗正在顯示這個節點的指令,連帶關閉它,防止無鎖寫入 if (currentEditElementId === elementId) { document.querySelectorAll('.tree-view-instance').forEach(pane => { pane.style.flex = '1'; pane.style.width = '100%'; pane.style.maxWidth = 'none'; }); const rightPane = document.getElementById('side-cli-preview'); if (rightPane) { rightPane.style.flex = '1'; rightPane.style.maxWidth = '50%'; rightPane.style.width = ''; rightPane.style.display = 'none'; } document.getElementById('drag-resizer').style.display = 'none'; currentEditPath = null; currentEditElementId = null; currentEditIsSuccess = false; currentEditType = null; } } // ========================================== // 3. CLI 生成與預覽 (CLI Generation) // ========================================== function getInterfacesWithAdminState() { const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running'; const activeContainer = document.getElementById(`tree-container-${currentMode}`); if (!activeContainer) return []; const nodes = activeContainer.querySelectorAll('.leaf-container'); const interfaces = new Set(); nodes.forEach(node => { const path = node.getAttribute('data-path'); if (path && path.endsWith('::admin-state')) { const interfacePath = path.replace('::admin-state', '').replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); interfaces.add(interfacePath); } }); return Array.from(interfaces); } export async function previewFolderCLI(path, elementId) { currentEditPath = path; currentEditElementId = elementId; currentEditIsSuccess = false; currentEditType = 'folder'; const contentDiv = document.getElementById(`content-${elementId}`); if (!contentDiv) return; const inputs = contentDiv.querySelectorAll('.edit-input'); const diffs = []; inputs.forEach(input => { const container = input.closest('.leaf-container'); if (!container) return; const originalVal = container.getAttribute('data-original') || ""; const newVal = input.value.trim(); const nodePath = container.getAttribute('data-path'); if (originalVal !== newVal && nodePath) { diffs.push({ path: nodePath, old_val: originalVal, new_val: newVal }); } }); if (diffs.length === 0) return alert("沒有偵測到任何修改,無需生成指令!"); currentEditDiffs = diffs; try { const result = await apiGenerateCli(diffs, getInterfacesWithAdminState()); if (result.status === 'success') { showCliPreviewModal(result.data); } else { alert("CLI 生成失敗: " + result.message); } } catch (error) { console.error("Error generating CLI:", error); alert("網路連線錯誤,無法生成 CLI"); } } export async function previewLeafCLI(path, elementId) { currentEditPath = path; currentEditElementId = elementId; currentEditIsSuccess = false; currentEditType = 'leaf'; const container = document.getElementById(`container-${elementId}`); if (!container) return; const input = container.querySelector('.edit-input'); if (!input) return; const originalVal = container.getAttribute('data-original') || ""; const newVal = input.value.trim(); if (originalVal === newVal) return alert("沒有偵測到任何修改,無需生成指令!"); const diffs = [{ path: path, old_val: originalVal, new_val: newVal }]; currentEditDiffs = diffs; try { const result = await apiGenerateCli(diffs, getInterfacesWithAdminState()); if (result.status === 'success') { showCliPreviewModal(result.data); } else { alert("CLI 生成失敗: " + result.message); } } catch (error) { alert("網路連線錯誤,無法生成 CLI"); } } function showCliPreviewModal(cliScript) { const leftPanes = document.querySelectorAll('.tree-view-instance'); const resizer = document.getElementById('drag-resizer'); const rightPane = document.getElementById('side-cli-preview'); document.getElementById('side-pane-title').innerHTML = '⚠️ 即將寫入的指令'; document.getElementById('side-pane-title').style.color = '#f1c40f'; document.getElementById('btn-side-cancel').style.display = 'inline-block'; document.getElementById('btn-side-confirm').style.display = 'inline-block'; document.getElementById('btn-side-close').style.display = 'none'; document.getElementById('btn-side-close').textContent = '關閉'; document.getElementById('side-cli-textarea').style.display = 'block'; document.getElementById('side-execution-result').style.display = 'none'; const textarea = document.getElementById('side-cli-textarea'); textarea.value = cliScript; textarea.readOnly = false; textarea.style.backgroundColor = '#1e1e1e'; textarea.style.color = '#ecf0f1'; // 🛡️ 安全修改:解除 Flexbox 均分限制,讓 JS 拖拉的 width 生效 leftPanes.forEach(pane => { pane.style.flex = 'none'; pane.style.maxWidth = 'none'; pane.style.width = 'calc(50% - 11px)'; }); rightPane.style.flex = 'none'; rightPane.style.maxWidth = 'none'; rightPane.style.width = 'calc(50% - 11px)'; resizer.style.display = 'flex'; rightPane.style.display = 'block'; if (!window.isResizerInitialized) { initResizer(); window.isResizerInitialized = true; } } // ========================================== // 4. 側邊欄與執行邏輯 // ========================================== export async function hideSideCLI() { // 🌟 完美修復:明確恢復 Flexbox 伸展能力與 100% 寬度 document.querySelectorAll('.tree-view-instance').forEach(pane => { pane.style.flex = '1'; // 恢復 HTML 原本賦予的彈性伸展能力 pane.style.width = '100%'; // 強制撐滿整個父容器 pane.style.maxWidth = 'none'; }); const rightPane = document.getElementById('side-cli-preview'); if (rightPane) { rightPane.style.flex = '1'; rightPane.style.maxWidth = '50%'; // 恢復 HTML 原本的 50% 限制 rightPane.style.width = ''; rightPane.style.display = 'none'; } document.getElementById('drag-resizer').style.display = 'none'; // 以下為原本的取消編輯與還原邏輯,完全保持不變,確保功能安全 if (currentEditIsSuccess && currentEditElementId && currentEditPath) { const wrapper = document.getElementById(`display-wrapper-${currentEditElementId}`); if (wrapper) { const container = document.getElementById(`container-${currentEditElementId}`); const input = container ? container.querySelector('.edit-input') : null; const newCommand = input ? input.value.trim() : (container ? container.getAttribute('data-original') : ''); if (currentEditType === 'folder') await cancelEditFolder(currentEditPath, currentEditElementId); else await cancelEditLeaf(currentEditPath, currentEditElementId); transformNoToActive(currentEditElementId, newCommand); } else { if (currentEditType === 'folder') await applyEditFolder(currentEditPath, currentEditElementId); else if (currentEditType === 'leaf') await applyEditLeaf(currentEditPath, currentEditElementId); } } currentEditPath = null; currentEditElementId = null; currentEditIsSuccess = false; currentEditType = null; } async function applyEditFolder(path, elementId) { const contentDiv = document.getElementById(`content-${elementId}`); if (contentDiv) { const inputs = contentDiv.querySelectorAll('.edit-input'); inputs.forEach(input => { const container = input.closest('.leaf-container'); if (container) container.setAttribute('data-original', input.value.trim()); }); } await cancelEditFolder(path, elementId); } async function applyEditLeaf(path, elementId) { const container = document.getElementById(`container-${elementId}`); if (container) { const input = container.querySelector('.edit-input'); if (input) container.setAttribute('data-original', input.value.trim()); } await cancelEditLeaf(path, elementId); } export function executeSideCLI() { const finalScript = document.getElementById('side-cli-textarea').value; document.getElementById('side-pane-title').innerHTML = '⏳ 正在寫入設備...'; document.getElementById('side-pane-title').style.color = '#f39c12'; // 🌟 統一載入中狀態為橘色 (原為 #3498db) document.getElementById('btn-side-cancel').style.display = 'none'; document.getElementById('btn-side-confirm').style.display = 'none'; document.getElementById('btn-side-close').style.display = 'none'; document.getElementById('side-cli-textarea').style.display = 'none'; const resultBox = document.getElementById('side-execution-result'); resultBox.style.display = 'block'; resultBox.innerHTML = `${finalScript}`; executeGeneratedCLI(finalScript); } async function executeGeneratedCLI(script) { const connInfo = getGlobalConnectionInfo(); if (!connInfo) return; const outputEl = document.getElementById('side-execution-result'); // 1. 建立即時 Log 視窗 UI (無邊界滿版融合風格) outputEl.innerHTML = `
[系統] ⏳ 正在透過 SSH 寫入指令,請勿關閉視窗...
[系統] 準備連線至設備 ${connInfo.host}...
`; const logContainer = document.getElementById('edit-log-container'); // 🌟 修正 1: 捲動目標必須是真正有捲軸的容器 const scrollTarget = document.getElementById('side-execution-result'); try { const response = await fetch('/api/v1/cmts-config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ script: script, host: connInfo.host, username: connInfo.user, password: connInfo.pass }) }); const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; let isSuccess = false; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let lines = buffer.split('\n'); buffer = lines.pop(); for (let line of lines) { if (!line.trim()) continue; try { const data = JSON.parse(line); const timeStr = new Date().toLocaleTimeString('en-US', { hour12: false }); if (data.status === 'progress') { logContainer.innerHTML += `
[${timeStr}] ${data.message}
`; } else if (data.status === 'success') { isSuccess = true; logContainer.innerHTML += `
[${timeStr}] ${data.message}
`; document.getElementById('side-pane-title').innerHTML = '✅ 寫入成功!正在同步快取...'; document.getElementById('side-pane-title').style.color = '#f39c12'; } else if (data.status === 'error') { logContainer.innerHTML += `
[${timeStr}] ${data.message}
`; if (data.output) { logContainer.innerHTML += `
設備回傳: ${data.output}
`; } document.getElementById('btn-side-close').style.display = 'inline-block'; document.getElementById('side-pane-title').innerHTML = '❌ 執行失敗'; document.getElementById('side-pane-title').style.color = '#ff6b6b'; } // 自動捲動到最底部 if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight; } catch (e) { console.warn("解析串流 JSON 失敗:", line); } } } // 4. 串流結束後,執行快取同步 if (isSuccess) { currentEditIsSuccess = true; const pathsToSync = currentEditDiffs.map(d => { let cacheKey = d.path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); if (d.path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + d.old_val; return cacheKey; }); const uniquePaths = [...new Set(pathsToSync)]; try { const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running'; let isSyncDone = false; // 🌟 修正 2: 先啟動 SSE 監聽 (避免錯過 done 事件) const streamPromise = apiSyncLeafOptionsStream(connInfo.host, uniquePaths, (data) => { const titleEl = document.getElementById('side-pane-title'); if (data.event === 'done' || data.status === 'done') { isSyncDone = true; if (titleEl) { titleEl.innerHTML = '✅ 寫入成功!快取同步完成'; titleEl.style.color = '#2ecc71'; } document.getElementById('btn-side-close').style.display = 'inline-block'; logContainer.innerHTML += `
[系統] 快取同步完成!您可以關閉此面板。`; if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight; } else if (data.event === 'error' || data.status === 'error') { isSyncDone = true; if (titleEl) { titleEl.innerHTML = '⚠️ 寫入成功 (但同步快取失敗)'; titleEl.style.color = '#e74c3c'; } document.getElementById('btn-side-close').style.display = 'inline-block'; logContainer.innerHTML += `
[系統] 同步失敗: ${data.message || '未知錯誤'}`; if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight; } }, currentMode); // 🌟 修正 3: 再觸發後端同步任務 apiSyncLeafOptions(connInfo.host, uniquePaths, currentMode, connInfo.user, connInfo.pass); // 🌟 修正 4: 加入 8 秒防呆機制,如果後端默默做完沒通知,自動放行 setTimeout(() => { if (!isSyncDone) { const titleEl = document.getElementById('side-pane-title'); if (titleEl) { titleEl.innerHTML = '✅ 寫入成功!(快取同步已於背景完成)'; titleEl.style.color = '#2ecc71'; } document.getElementById('btn-side-close').style.display = 'inline-block'; logContainer.innerHTML += `
[系統] 快取同步已於背景執行,您可以關閉此面板。`; if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight; } }, 8000); await streamPromise; } catch (syncErr) { console.error("同步設定失敗:", syncErr); document.getElementById('btn-side-close').style.display = 'inline-block'; } } else { currentEditIsSuccess = false; } } catch (error) { document.getElementById('btn-side-close').style.display = 'inline-block'; document.getElementById('side-pane-title').innerHTML = '❌ 連線錯誤'; document.getElementById('side-pane-title').style.color = '#ff6b6b'; logContainer.innerHTML += `
連線或伺服器錯誤: ${error}
`; } } // ========================================== // 5. 輔助 UI 函數 // ========================================== function initResizer() { const resizer = document.getElementById('drag-resizer'); const leftPanes = document.querySelectorAll('.tree-view-instance'); const rightPane = document.getElementById('side-cli-preview'); const container = document.getElementById('split-container'); let isResizing = false; resizer.addEventListener('mousedown', (e) => { isResizing = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; }); document.addEventListener('mousemove', (e) => { if (!isResizing) return; const containerRect = container.getBoundingClientRect(); let newLeftWidth = e.clientX - containerRect.left; const minWidth = containerRect.width * 0.2; const maxWidth = containerRect.width * 0.8; if (newLeftWidth < minWidth) newLeftWidth = minWidth; if (newLeftWidth > maxWidth) newLeftWidth = maxWidth; const leftPercentage = (newLeftWidth / containerRect.width) * 100; const rightPercentage = 100 - leftPercentage; leftPanes.forEach(pane => pane.style.width = `calc(${leftPercentage}% - 11px)`); rightPane.style.width = `calc(${rightPercentage}% - 11px)`; }); document.addEventListener('mouseup', () => { if (isResizing) { isResizing = false; document.body.style.cursor = 'default'; document.body.style.userSelect = 'auto'; } }); } function highlightTerminalOutput(text) { if (!text) return ''; const lines = text.split('\n'); const formattedLines = lines.map(line => { if (line.match(/(Aborted|Error|Warning|Failed|% Invalid|% Unknown|Bad command)/i)) { return `${line}`; } return line; }); return formattedLines.join('\n'); } function transformNoToActive(elementId, newCommand) { const wrapper = document.getElementById(`display-wrapper-${elementId}`); if (!wrapper) return; const parts = newCommand.trim().split(' '); const firstWord = parts[0]; const restCommand = parts.slice(1).join(' '); const safeRestCommand = escapeHTML(restCommand); const svgEnabled = ``; const iconHtml = `${svgEnabled}`; wrapper.innerHTML = ` ${iconHtml} ${firstWord} ${safeRestCommand} (已啟用,重整後歸檔) `; const actionBtns = document.getElementById(`actions-${elementId}`); const editBtn = document.getElementById(`edit-btn-${elementId}`); if (actionBtns) actionBtns.style.display = 'none'; if (editBtn) editBtn.style.display = 'none'; } // ============================================================================ // 💻 開發者工具:預覽節點完整指令 (唯讀 Debug 模式) // ============================================================================ export async function previewNodeCLI(btnElement, rootPath) { let container = btnElement.closest('details'); if (container) { const elementId = container.id.replace('details-', ''); if (container.dataset.loaded !== 'true') { await window.lazyLoadFolder(elementId, false); } } const targetNode = container || btnElement.closest('.tree-leaf-node'); if (!targetNode) return; const leafNodes = targetNode.querySelectorAll('.leaf-container'); const interfaceGroups = {}; leafNodes.forEach(node => { const path = node.getAttribute('data-path') || ""; const val = node.getAttribute('data-original'); if (!path) return; const parts = path.split('::'); const paramName = parts.pop(); let interfacePath = parts.join(' ').replace(/\s*\[\d+\]/g, ''); if (!interfaceGroups[interfacePath]) { interfaceGroups[interfacePath] = []; } interfaceGroups[interfacePath].push({ param: paramName, val: val }); }); if (Object.keys(interfaceGroups).length === 0 && targetNode.classList.contains('tree-leaf-node')) { const singleNode = targetNode.querySelector('.leaf-container'); if (singleNode) { const path = singleNode.getAttribute('data-path'); const val = singleNode.getAttribute('data-original'); const parts = path.split('::'); const paramName = parts.pop(); let interfacePath = parts.join(' ').replace(/\s*\[\d+\]/g, ''); interfaceGroups[interfacePath] = [{ param: paramName, val: val }]; } } if (Object.keys(interfaceGroups).length === 0) { return alert("此節點下沒有任何數值可供預覽!"); } let cliLines = []; cliLines.push("! --- Read-Only Configuration Preview ---"); for (const [interfacePath, params] of Object.entries(interfaceGroups)) { cliLines.push(`! Node: ${interfacePath || 'Global'}`); params.forEach(({param, val}) => { if (/^\[\d+\]$/.test(param)) { if (val) { cliLines.push(interfacePath ? `${interfacePath} ${val}` : val); } } else if (param === 'no') { cliLines.push(interfacePath ? `${interfacePath} no ${val}` : `no ${val}`); } else { let line = interfacePath ? `${interfacePath} ${param}` : param; if (val) line += ` ${val}`; cliLines.push(line); } }); cliLines.push("!"); } const finalScript = cliLines.join('\n'); showReadOnlyCliPreviewModal(finalScript, rootPath); } function showReadOnlyCliPreviewModal(cliScript, rootPath) { const leftPanes = document.querySelectorAll('.tree-view-instance'); const resizer = document.getElementById('drag-resizer'); const rightPane = document.getElementById('side-cli-preview'); document.getElementById('side-pane-title').innerHTML = `🔍 [唯讀預覽] ${rootPath}`; document.getElementById('side-pane-title').style.color = '#3498db'; document.getElementById('btn-side-confirm').style.display = 'none'; document.getElementById('btn-side-cancel').style.display = 'none'; const closeBtn = document.getElementById('btn-side-close'); closeBtn.style.display = 'inline-block'; closeBtn.textContent = '關閉預覽'; const textarea = document.getElementById('side-cli-textarea'); textarea.style.display = 'block'; textarea.value = cliScript; textarea.readOnly = true; textarea.style.backgroundColor = '#2c3e50'; document.getElementById('side-execution-result').style.display = 'none'; // 🛡️ 安全修改:解除 Flexbox 均分限制,讓 JS 拖拉的 width 生效 leftPanes.forEach(pane => { pane.style.flex = 'none'; pane.style.maxWidth = 'none'; pane.style.width = 'calc(50% - 11px)'; }); if (rightPane) { rightPane.style.flex = 'none'; rightPane.style.maxWidth = 'none'; rightPane.style.width = 'calc(50% - 11px)'; rightPane.style.display = 'block'; } if (resizer) resizer.style.display = 'flex'; if (!window.isResizerInitialized && typeof initResizer === 'function') { initResizer(); window.isResizerInitialized = true; } } // --- EOF (檔案結束) ---