/* --- static/app.js --- */ // ========================================== // 1. 全域變數與初始化 // ========================================== let term, fitAddon, ws; let currentMacDomainData = null; let isConnected = false; // 💡 新增:追蹤當前編輯狀態 let currentEditPath = null; let currentEditElementId = null; let currentEditIsSuccess = false; let currentEditType = null; // 💡 新增:用來記錄是 'folder' 還是 'leaf' window.onload = initTerminal; window.onresize = () => { if (fitAddon) fitAddon.fit(); }; // ========================================== // 💡 全域狀態與鎖定管理 (Lock Management) // ========================================== const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9); const ACTIVE_HEARTBEATS = {}; async function sendHeartbeat(path) { try { await fetch('/api/v1/locks/heartbeat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: path, user_id: SESSION_USER_ID }) }); } catch (error) {} } // 🎨 輔助函數:解析終端機輸出,僅將錯誤/警告行標紅 function highlightTerminalOutput(text) { if (!text) return ''; // 將多行字串拆分成陣列,逐行檢查 const lines = text.split('\n'); const formattedLines = lines.map(line => { // 偵測常見的錯誤與警告關鍵字 (不分大小寫) // 您可以隨時在這裡擴充設備專屬的錯誤代碼,例如 '% Invalid' if (line.match(/(Aborted|Error|Warning|Failed|% Invalid|% Unknown|Bad command)/i)) { return `${line}`; } return line; // 沒有關鍵字的行,維持預設顏色 (白色) }); return formattedLines.join('\n'); } // ========================================== // 💡 資料夾層級的編輯模式邏輯 (支援區塊級預先抓取) // ========================================== async function startEditFolder(path, elementId) { const connInfo = getGlobalConnectionInfo(); const username = connInfo ? connInfo.user : 'admin'; try { const response = await fetch('/api/v1/locks/acquire', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: path, user_id: SESSION_USER_ID, username: username }) }); const result = await response.json(); if (response.status === 409) { alert(`⚠️ ${result.detail}`); return; } if (result.status === 'success') { ACTIVE_HEARTBEATS[path] = setInterval(() => sendHeartbeat(path), 15000); document.getElementById(`edit-btn-${elementId}`).style.display = 'none'; document.getElementById(`actions-${elementId}`).style.display = 'inline-block'; document.getElementById(`details-${elementId}`).open = true; const contentDiv = document.getElementById(`content-${elementId}`); const leafContainers = contentDiv.querySelectorAll('.leaf-container'); // 🌟 1. 先取得目前的快取資料 let optData = {}; try { const optRes = await fetch('/api/v1/cmts-leaf-options'); optData = await optRes.json(); } catch (e) { console.warn("無法取得選項快取", e); } const pathsToSync = []; const currentTime = Math.floor(Date.now() / 1000); const CACHE_TTL = 86400 * 7; // 🌟 2. 歷遍所有欄位,渲染 UI 並收集缺少的路徑 leafContainers.forEach(container => { const origVal = container.getAttribute('data-original'); const rawPath = container.getAttribute('data-path'); // 🌟 轉換為空白格式後,全域拔除路徑中所有的虛擬索引 [0], [1] const cacheKey = rawPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); let leafCache = optData[cacheKey]; // 判斷是否需要排程更新 (沒快取,或是快取過期) if (!leafCache || (currentTime - leafCache.updated_at >= CACHE_TTL)) { pathsToSync.push(cacheKey); } // 渲染 UI (加入提示與下拉選單邏輯) 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; }); // 🌟 3. 觸發背景抓取:將缺少的路徑一次性送給後端 if (pathsToSync.length > 0) { console.log(`🚀 將 ${pathsToSync.length} 個欄位送入背景排程抓取...`); fetch('/api/v1/cmts-leaf-options/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ leaf_paths: pathsToSync }) }); } } } catch (error) { alert("❌ 無法連線到鎖定伺服器:" + error.message); } } // 🔓 鎖定並開始編輯單一項目 (加入智慧等待機制) async function startEditLeaf(path, elementId) { const connInfo = getGlobalConnectionInfo(); const username = connInfo ? connInfo.user : 'admin'; try { const response = await fetch('/api/v1/locks/acquire', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: path, user_id: SESSION_USER_ID, username: username }) }); const result = await response.json(); if (response.status === 409) return alert(`⚠️ ${result.detail}`); if (result.status === 'success') { ACTIVE_HEARTBEATS[path] = setInterval(() => sendHeartbeat(path), 15000); document.getElementById(`edit-btn-${elementId}`).style.display = 'none'; document.getElementById(`actions-${elementId}`).style.display = 'inline-block'; const container = document.getElementById(`container-${elementId}`); const origVal = container.getAttribute('data-original'); // 💡 顯示載入中提示 container.innerHTML = `⏳ 設備連線與載入選項中...`; try { let optRes = await fetch('/api/v1/cmts-leaf-options'); let optData = await optRes.json(); // 🌟 轉換為空白格式後,全域拔除路徑中所有的虛擬索引 [0], [1] const cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); let leafCache = optData[cacheKey]; const currentTime = Math.floor(Date.now() / 1000); const CACHE_TTL = 86400 * 7; // 💡 如果沒快取,觸發背景抓取,並啟動「智慧等待」 if (!leafCache || (currentTime - leafCache.updated_at >= CACHE_TTL)) { // 1. 呼叫背景任務 fetch('/api/v1/cmts-leaf-options/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ leaf_paths: [cacheKey] }) }); // 2. 每秒檢查一次,最多等 10 秒 let found = false; for (let i = 0; i < 10; i++) { await new Promise(resolve => setTimeout(resolve, 1000)); // 暫停 1 秒 optRes = await fetch('/api/v1/cmts-leaf-options'); optData = await optRes.json(); leafCache = optData[cacheKey]; if (leafCache && leafCache.options && leafCache.options.length > 0) { found = true; break; // 找到了!跳出等待迴圈 } } if (!found) { console.warn("⏳ 等待選項超時,退回純文字模式"); } } let inputHtml = ""; let hintAttr = ""; let hintIcon = ""; // 💡 處理 Hint 屬性與提示圖示 if (leafCache && leafCache.hint) { // 替換雙引號避免 HTML 屬性破裂 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 { // ❌ 若為 list 且無選項,退回純文字輸入,但依然綁定提示 inputHtml = `${hintIcon}`; } container.innerHTML = inputHtml; } catch (e) { console.warn("選項載入失敗", e); container.innerHTML = ``; } } } catch (error) { alert("❌ 無法連線到鎖定伺服器:" + error.message); } } async function cancelEditFolder(path, elementId) { if (ACTIVE_HEARTBEATS[path]) { clearInterval(ACTIVE_HEARTBEATS[path]); delete ACTIVE_HEARTBEATS[path]; } try { await fetch('/api/v1/locks/release', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: path, user_id: SESSION_USER_ID }) }); } catch (error) {} document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block'; document.getElementById(`actions-${elementId}`).style.display = 'none'; const contentDiv = document.getElementById(`content-${elementId}`); const leafContainers = contentDiv.querySelectorAll('.leaf-container'); leafContainers.forEach(container => { const origVal = container.getAttribute('data-original'); container.innerHTML = ` ${origVal} `; }); } // 🔓 取消編輯單一項目 async function cancelEditLeaf(path, elementId) { if (ACTIVE_HEARTBEATS[path]) { clearInterval(ACTIVE_HEARTBEATS[path]); delete ACTIVE_HEARTBEATS[path]; } try { await fetch('/api/v1/locks/release', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: path, user_id: SESSION_USER_ID }) }); } catch (error) {} document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block'; document.getElementById(`actions-${elementId}`).style.display = 'none'; const container = document.getElementById(`container-${elementId}`); const origVal = container.getAttribute('data-original'); container.innerHTML = `${origVal}`; } // 💡 輔助函數:掃描畫面上所有支援 admin-state 的區塊路徑 function getInterfacesWithAdminState() { const nodes = document.querySelectorAll('.leaf-container'); const interfaces = new Set(); nodes.forEach(node => { const path = node.getAttribute('data-path'); // 如果這個節點是 admin-state if (path && path.endsWith('::admin-state')) { // 將路徑轉換為後端認得的介面格式 (拔除 ::admin-state、替換空白、拔除虛擬索引) const interfacePath = path.replace('::admin-state', '').replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); interfaces.add(interfacePath); } }); return Array.from(interfaces); } // 🚀 Phase 3: 收集修改並呼叫後端轉譯 API 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) { alert("沒有偵測到任何修改,無需生成指令!"); return; } try { const response = await fetch('/api/v1/cmts-generate-cli', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diffs: diffs, interfaces_with_admin_state: getInterfacesWithAdminState() // 💡 附上動態探測名單 }) }); const result = await response.json(); if (result.status === 'success') { showCliPreviewModal(result.data); } else { alert("CLI 生成失敗: " + result.message); } } catch (error) { console.error("Error generating CLI:", error); alert("網路連線錯誤,無法生成 CLI"); } } // 🚀 預覽單一項目的 CLI 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 }]; try { const response = await fetch('/api/v1/cmts-generate-cli', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diffs: diffs, interfaces_with_admin_state: getInterfacesWithAdminState() // 💡 附上動態探測名單 }) }); const result = await response.json(); if (result.status === 'success') { showCliPreviewModal(result.data); } else { alert("CLI 生成失敗: " + result.message); } } catch (error) { alert("網路連線錯誤,無法生成 CLI"); } } // 🚀 Phase 3: 顯示側邊預覽區塊 (初始化為「預覽模式」) function showCliPreviewModal(cliScript) { const leftPane = document.getElementById('tree-container'); const resizer = document.getElementById('drag-resizer'); const rightPane = document.getElementById('side-cli-preview'); // --- UI 狀態重置為「預覽模式」 --- 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('side-cli-textarea').style.display = 'block'; document.getElementById('side-execution-result').style.display = 'none'; // 填入指令 document.getElementById('side-cli-textarea').value = cliScript; // 設定初始 50/50 比例 leftPane.style.width = 'calc(50% - 11px)'; rightPane.style.width = 'calc(50% - 11px)'; resizer.style.display = 'flex'; rightPane.style.display = 'block'; if (!window.isResizerInitialized) { initResizer(); window.isResizerInitialized = true; } } // ❌ 隱藏側邊預覽區塊 (恢復左側 100% 寬度) function hideSideCLI() { document.getElementById('tree-container').style.width = '100%'; document.getElementById('drag-resizer').style.display = 'none'; document.getElementById('side-cli-preview').style.display = 'none'; // 💡 根據編輯類型,呼叫對應的套用函數 if (currentEditIsSuccess && currentEditElementId && currentEditPath) { if (currentEditType === 'folder') { applyEditFolder(currentEditPath, currentEditElementId); } else if (currentEditType === 'leaf') { 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) { const newVal = input.value.trim(); // 關鍵:將新值寫入 data-original // 這樣稍後呼叫 cancelEditFolder 時,就會用這個新值來還原畫面 container.setAttribute('data-original', newVal); } }); } // 呼叫現有的 cancelEditFolder 來解除後端鎖定,並將輸入框變回純文字 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); } // 🚀 執行側邊區塊的指令 (切換為「執行模式」) function executeSideCLI() { const finalScript = document.getElementById('side-cli-textarea').value; // --- UI 狀態切換為「執行結果模式」 --- document.getElementById('side-pane-title').innerHTML = '⏳ 正在寫入設備...'; document.getElementById('side-pane-title').style.color = '#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}`; // 呼叫 API executeGeneratedCLI(finalScript); } // 🖱️ 實作左右拖曳調整寬度引擎 function initResizer() { const resizer = document.getElementById('drag-resizer'); const leftPane = document.getElementById('tree-container'); 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; // 限制左右面板的最小/最大寬度 (20% ~ 80%),防止拉過頭 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; // 即時更新左右面板寬度 leftPane.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'; // 恢復文字反白功能 } }); } // 🚀 Phase 3: 將腳本發送給設備執行 (輸出至側邊欄) async function executeGeneratedCLI(script) { const connInfo = getGlobalConnectionInfo(); if (!connInfo) return; const outputEl = 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 result = await response.json(); // 執行完畢,顯示「完成並關閉」按鈕 document.getElementById('btn-side-close').style.display = 'inline-block'; if (result.status === 'success') { currentEditIsSuccess = true; // 💡 標記執行成功 document.getElementById('side-pane-title').innerHTML = '✅ 執行成功'; document.getElementById('side-pane-title').style.color = '#2ecc71'; // 💡 成功時,移除內部提示,直接顯示純淨的白色 Log outputEl.innerHTML = `${result.data}`; } else { currentEditIsSuccess = false; // 💡 標記執行失敗 document.getElementById('side-pane-title').innerHTML = '❌ 執行失敗'; document.getElementById('side-pane-title').style.color = '#ff6b6b'; // 💡 失敗時,移除內部提示,並透過輔助函數「僅針對錯誤行」進行紅色高亮 const rawLog = result.detail || result.message || ''; outputEl.innerHTML = `${highlightTerminalOutput(rawLog)}`; } } 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'; outputEl.innerHTML = `連線或伺服器錯誤: ${error}`; } } // 🔌 外掛函數:為現有的 input 加上下拉選項 (不破壞原有結構) async function enhanceInputWithOptions(path, elementId) { try { const optRes = await fetch('/api/v1/cmts-leaf-options'); const optData = await optRes.json(); // 🌟 建立乾淨的 cacheKey (轉換為空白格式,並全域拔除虛擬索引) const cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); const leafCache = optData[cacheKey]; const currentTime = Math.floor(Date.now() / 1000); const CACHE_TTL = 86400 * 7; if (leafCache && leafCache.options && leafCache.options.length > 0 && (currentTime - leafCache.updated_at < CACHE_TTL)) { // ✅ 找到選項:建立 並綁定到現有的 input 上 const container = document.getElementById(`container-${elementId}`); const input = container.querySelector('.edit-input'); if (input) { const listId = `dl-${elementId}`; input.setAttribute('list', listId); // 讓 input 知道要用哪個清單 // 建立清單元素 let dataList = document.getElementById(listId); if (!dataList) { dataList = document.createElement('datalist'); dataList.id = listId; container.appendChild(dataList); } // 填入選項 dataList.innerHTML = leafCache.options.map(opt => `'; statusSpan.textContent = "正在自動抓取現有 MAC Domain 清單..."; statusSpan.style.color = "#f39c12"; try { const url = `/api/v1/cmts-mac-domain-list?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}`; const response = await fetch(url); const result = await response.json(); if (result.status === 'success' && result.data.length > 0) { mdSelect.innerHTML = ''; result.data.forEach(md => { const option = document.createElement('option'); option.value = md; option.textContent = md; mdSelect.appendChild(option); }); statusSpan.textContent = "✅ 清單抓取成功!請選擇目標並點擊讀取配置。"; statusSpan.style.color = "#27ae60"; } else { mdSelect.innerHTML = ''; statusSpan.textContent = "無法解析清單,請確認設備狀態。"; statusSpan.style.color = "#e74c3c"; } } catch (error) { mdSelect.innerHTML = ''; statusSpan.textContent = "連線失敗:" + error; statusSpan.style.color = "#e74c3c"; } } function toggleQueryInputs(mode) { const type = document.getElementById('queryType' + mode).value; const targetInput = document.getElementById('queryTarget' + mode); const globalTypes = ['partial_mode', 'hop', 'flap_list', 'flap_sum']; if (globalTypes.includes(type)) { targetInput.disabled = true; targetInput.style.backgroundColor = '#e8ecef'; targetInput.placeholder = "此查詢為全域指令,不需輸入目標"; targetInput.value = ""; } else { targetInput.disabled = false; targetInput.style.backgroundColor = '#f8f9fa'; targetInput.placeholder = mode === 'Cm' ? "例: 6467.7240.4076 (留白則查詢全體)" : "例: 13:0 (留白則查詢全體)"; } } function resetAllManagerData() { currentMacDomainData = null; const configArea = document.getElementById('macDomainConfigArea'); if (configArea) configArea.style.display = 'none'; const previewEl = document.getElementById('cliPreviewContainer'); if (previewEl) { previewEl.textContent = ''; previewEl.style.display = 'none'; } const deployBtn = document.getElementById('btnDeployConfig'); if (deployBtn) { deployBtn.style.display = 'none'; deployBtn.disabled = true; } const mdSelect = document.getElementById('cfgMacDomain'); if (mdSelect) mdSelect.innerHTML = ''; const fetchStatus = document.getElementById('fetchStatus'); if (fetchStatus) { fetchStatus.textContent = "請先讀取設備狀態..."; fetchStatus.style.color = "#7f8c8d"; } document.getElementById('queryTargetCm').value = ''; document.getElementById('queryTargetRpd').value = ''; } // ========================================== // 3. 終端機 (Terminal) 與 WebSocket 邏輯 // ========================================== function colorizeTerminalStream(text) { if (text.includes('\x1b[')) return text; let res = text; res = res.replace(/\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/g, '\x1b[1;34m$1\x1b[0m'); res = res.replace(/\b([0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}|[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})\b/g, '\x1b[1;35m$1\x1b[0m'); res = res.replace(/\b(online|w-online|p-online|init\([^)]+\)|up|active|success|yes)\b/gi, '\x1b[1;32m$1\x1b[0m'); res = res.replace(/\b(offline|down|admin-down|error|fail|failed|shutdown|reject|invalid|no)\b/gi, '\x1b[1;31m$1\x1b[0m'); res = res.replace(/\b(Cable|GigabitEthernet|TenGigabitEthernet|Upstream|Downstream|Mac-domain|bonding-group|rpd)\b/gi, '\x1b[33m$1\x1b[0m'); return res; } function initTerminal() { term = new Terminal({ cursorBlink: true, theme: { background: '#1e1e1e', foreground: '#e0e0e0' }, fontFamily: 'Courier New, monospace', fontSize: 15, scrollback: 100000 }); fitAddon = new FitAddon.FitAddon(); term.loadAddon(fitAddon); term.open(document.getElementById('terminal-container')); fitAddon.fit(); term.writeln('Welcome to Harmonic cOS Web Terminal.'); term.writeln('Please confirm the target device above and click "連線至 CMTS"... \r\n'); term.onData((data) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(data); }); } function getGlobalConnectionInfo() { const host = document.getElementById('cmtsHost').value.trim(); const user = document.getElementById('cmtsUser').value.trim(); const pass = document.getElementById('cmtsPass').value.trim(); if (!host || !user || !pass) { alert("請在畫面上方完整輸入目標設備的 IP、帳號與密碼!"); return null; } return { host, user, pass }; } function connectWebSocket() { if (ws && ws.readyState === WebSocket.OPEN) return; const connInfo = getGlobalConnectionInfo(); if (!connInfo) return; resetAllManagerData(); term.writeln(`\x1b[33mConnecting to ${connInfo.host} via WebSocket...\x1b[0m`); const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/ws/terminal?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}`; ws = new WebSocket(wsUrl); ws.onopen = () => { isConnected = true; const btnLoadTask = document.getElementById('btn-load-task'); if (btnLoadTask) { btnLoadTask.disabled = false; btnLoadTask.style.backgroundColor = '#c0392b'; btnLoadTask.style.cursor = 'pointer'; } document.getElementById('wsStatus').textContent = `狀態:已連線`; document.getElementById('wsStatus').style.color = '#27ae60'; document.getElementById('btnConnect').style.display = 'none'; document.getElementById('btnDisconnect').style.display = 'inline-block'; document.getElementById('cmtsHost').disabled = true; document.getElementById('cmtsUser').disabled = true; document.getElementById('cmtsPass').disabled = true; term.focus(); }; ws.onmessage = (event) => { term.write(colorizeTerminalStream(event.data)); }; ws.onclose = () => { isConnected = false; const btnLoadTask = document.getElementById('btn-load-task'); if (btnLoadTask) { btnLoadTask.disabled = true; btnLoadTask.style.backgroundColor = '#95a5a6'; btnLoadTask.style.cursor = 'not-allowed'; } document.getElementById('wsStatus').textContent = '狀態:已斷線'; document.getElementById('wsStatus').style.color = '#c0392b'; document.getElementById('btnConnect').style.display = 'inline-block'; document.getElementById('btnDisconnect').style.display = 'none'; document.getElementById('cmtsHost').disabled = false; document.getElementById('cmtsUser').disabled = false; document.getElementById('cmtsPass').disabled = false; term.writeln('\r\n\x1b[31m--- Connection Closed ---\x1b[0m\r\n'); resetAllManagerData(); }; } function disconnectWebSocket() { if (ws) ws.close(); } function injectCommand() { if (!ws || ws.readyState !== WebSocket.OPEN) return alert("請先點擊「連線至 CMTS」!"); const cmd = document.getElementById('fixedCmd').value; ws.send(cmd + '\r'); term.focus(); } function getTerminalText() { if (!term) return ''; try { if (term.hasSelection()) return term.getSelection(); const buffer = term.buffer.active; let text = ''; for (let i = 0; i < buffer.length; i++) { const line = buffer.getLine(i); if (line) text += line.translateToString(true) + '\n'; } return text; } catch (err) { console.error("讀取終端機文字失敗:", err); return ''; } } function downloadTerminal() { const text = getTerminalText(); if (!text.trim()) return alert("沒有內容可下載!"); try { const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19); a.download = `cmts_terminal_log_${timestamp}.txt`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (err) { console.error("下載失敗:", err); } } // ========================================== // 4. 綜合查詢 API 呼叫 // ========================================== async function executeQuery(mode) { if (!ws || ws.readyState !== WebSocket.OPEN) { alert("請先點擊畫面上方的「連線至 CMTS」按鈕!"); return; } const connInfo = getGlobalConnectionInfo(); if (!connInfo) return; const queryType = document.getElementById('queryType' + mode).value; const target = document.getElementById('queryTarget' + mode).value.trim(); const isDebug = document.getElementById('debugQuery' + mode).checked; openModal(connInfo.host); const outputEl = document.getElementById('modalOutput'); outputEl.innerHTML = `正在向 ${connInfo.host} 查詢中,請稍候...`; try { const url = `/api/v1/cmts-query?query_type=${queryType}&target=${encodeURIComponent(target)}&host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}`; const response = await fetch(url); const result = await response.json(); if (result.status === 'success') { outputEl.style.color = "#e0e0e0"; if (isDebug) { outputEl.textContent = `[DEBUG] 實際發送指令: ${result.command}\n==================================================\n${result.data}`; } else { outputEl.textContent = result.data; } } else { outputEl.style.color = "#e74c3c"; outputEl.textContent = `查詢失敗:\n${result.detail || result.message}`; } } catch (error) { outputEl.style.color = "#e74c3c"; outputEl.textContent = `連線失敗: ${error}`; } } // ========================================== // 5. MAC Domain 配置精靈邏輯 // ========================================== async function fetchMacDomainConfig() { const target = document.getElementById('cfgMacDomain').value.trim(); const connInfo = getGlobalConnectionInfo(); if (!connInfo || !target) return alert("請確認設備連線資訊與 MAC Domain!"); document.getElementById('fetchStatus').textContent = "⏳ 正在讀取並解析設定中..."; document.getElementById('fetchStatus').style.color = "#f39c12"; try { const url = `/api/v1/cmts-mac-domain-config?target=${encodeURIComponent(target)}&host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}`; const response = await fetch(url); const result = await response.json(); if (result.status === 'success') { currentMacDomainData = result.data; populateFormFromData(); document.getElementById('macDomainConfigArea').style.display = 'block'; document.getElementById('fetchStatus').textContent = "✅ 讀取成功!"; document.getElementById('fetchStatus').style.color = "#27ae60"; } else { alert("讀取失敗:" + result.detail); document.getElementById('fetchStatus').textContent = "❌ 讀取失敗"; } } catch (error) { alert("連線錯誤:" + error); } } function populateFormFromData() { const d = currentMacDomainData; document.getElementById('g_ip_prov').value = d.common_settings['ip-provisioning-mode'] || 'dual-stack'; document.getElementById('g_diplexer').value = d.common_settings['diplexer-band-edge'] || 'enabled'; document.getElementById('g_bat31').value = d.common_settings['cm-battery-mode-31-support'] || 'disabled'; document.getElementById('g_bat30').value = d.common_settings['cm-battery-mode-30-support'] || 'disabled'; document.getElementById('g_docsis40').value = d.common_settings['docsis40'] || 'disabled'; document.getElementById('g_ds_dyn').value = d.common_settings['ds-dynamic-bonding-group'] || 'disabled'; document.getElementById('g_us_dyn').value = d.common_settings['us-dynamic-bonding-group'] || 'disabled'; document.getElementById('b_admin').value = d.basic_channel_sets['admin-state'] || 'down'; document.getElementById('b_ds_pri').value = d.basic_channel_sets['ds-primary-set'] || ''; document.getElementById('b_ds_non_pri').value = d.basic_channel_sets['ds-non-primary-set'] || ''; document.getElementById('b_us_phy').value = d.basic_channel_sets['us-phy-channel-set'] || ''; document.getElementById('b_ds_ofdm').value = d.basic_channel_sets['ds-ofdm-set'] || ''; document.getElementById('b_us_ofdma').value = d.basic_channel_sets['us-ofdma-set'] || ''; const dsSelect = document.getElementById('select_ds_group'); dsSelect.innerHTML = ''; Object.keys(d.static_ds_bonding_groups).forEach(grp => dsSelect.innerHTML += ``); if (Object.keys(d.static_ds_bonding_groups).length > 0) dsSelect.value = Object.keys(d.static_ds_bonding_groups)[0]; const usSelect = document.getElementById('select_us_group'); usSelect.innerHTML = ''; Object.keys(d.static_us_bonding_groups).forEach(grp => usSelect.innerHTML += ``); if (Object.keys(d.static_us_bonding_groups).length > 0) usSelect.value = Object.keys(d.static_us_bonding_groups)[0]; toggleGroupVisibility(); loadDsGroupData(); loadUsGroupData(); } function revertFormChanges() { if (!currentMacDomainData) return; if (!confirm("確定要放棄目前的修改,還原回設備原始的設定值嗎?")) return; populateFormFromData(); const previewEl = document.getElementById('cliPreviewContainer'); if (previewEl) { previewEl.textContent = ''; previewEl.style.display = 'none'; } const deployBtn = document.getElementById('btnDeployConfig'); if (deployBtn) { deployBtn.style.display = 'none'; deployBtn.disabled = true; } const fetchStatus = document.getElementById('fetchStatus'); fetchStatus.textContent = "🔄 已還原為原始設定!"; fetchStatus.style.color = "#2980b9"; setTimeout(() => { fetchStatus.textContent = "✅ 讀取成功!"; fetchStatus.style.color = "#27ae60"; }, 3000); } function toggleGroupVisibility() { const dsDyn = document.getElementById('g_ds_dyn').value; const usDyn = document.getElementById('g_us_dyn').value; document.getElementById('section_group_ds').style.display = (dsDyn === 'disabled') ? 'block' : 'none'; document.getElementById('section_group_us').style.display = (usDyn === 'disabled') ? 'block' : 'none'; } function loadDsGroupData() { const grp = document.getElementById('select_ds_group').value; const newGrpInput = document.getElementById('input_new_ds_group'); if (grp === '_new_') { newGrpInput.style.display = 'inline-block'; document.getElementById('ds_g_admin').value = 'down'; document.getElementById('ds_g_down').value = ''; document.getElementById('ds_g_ofdm').value = ''; document.getElementById('ds_g_fdx').value = ''; } else { newGrpInput.style.display = 'none'; const data = currentMacDomainData.static_ds_bonding_groups[grp]; document.getElementById('ds_g_admin').value = data['admin-state'] || 'down'; document.getElementById('ds_g_down').value = data['down-channel-set'] || ''; document.getElementById('ds_g_ofdm').value = data['ofdm-channel-set'] || ''; document.getElementById('ds_g_fdx').value = data['fdx-ofdm-channel-set'] || ''; } } function loadUsGroupData() { const grp = document.getElementById('select_us_group').value; const newGrpInput = document.getElementById('input_new_us_group'); if (grp === '_new_') { newGrpInput.style.display = 'inline-block'; document.getElementById('us_g_admin').value = 'down'; document.getElementById('us_g_us').value = ''; document.getElementById('us_g_ofdma').value = ''; document.getElementById('us_g_fdx').value = ''; } else { newGrpInput.style.display = 'none'; const data = currentMacDomainData.static_us_bonding_groups[grp]; document.getElementById('us_g_admin').value = data['admin-state'] || 'down'; document.getElementById('us_g_us').value = data['us-channel-set'] || ''; document.getElementById('us_g_ofdma').value = data['ofdma-channel-set'] || ''; document.getElementById('us_g_fdx').value = data['fdx-ofdma-channel-set'] || ''; } } function generateMacDomainCLI() { const md = document.getElementById('cfgMacDomain').value.trim(); let cli = `! --- Harmonic MAC Domain Configuration SOP ---\n`; cli += `! 1. [區塊] MAC Domain 全域與 Base 設定\n`; cli += `cable mac-domain ${md} admin-state down\ncommit\n`; const baseCmds = [ `ip-provisioning-mode ${document.getElementById('g_ip_prov').value}`, `diplexer-band-edge control ${document.getElementById('g_diplexer').value}`, `cm-battery-mode-31-support ${document.getElementById('g_bat31').value}`, `cm-battery-mode-30-support ${document.getElementById('g_bat30').value}`, `docsis40 ${document.getElementById('g_docsis40').value}`, `ds-dynamic-bonding-group ${document.getElementById('g_ds_dyn').value}`, `us-dynamic-bonding-group ${document.getElementById('g_us_dyn').value}`, `ds-primary-set ${document.getElementById('b_ds_pri').value}`, `ds-non-primary-set ${document.getElementById('b_ds_non_pri').value}`, `us-phy-channel-set ${document.getElementById('b_us_phy').value}`, `ds-ofdm-set ${document.getElementById('b_ds_ofdm').value}`, `us-ofdma-set ${document.getElementById('b_us_ofdma').value}` ]; baseCmds.forEach(cmd => { if (!cmd.endsWith(" ")) cli += `cable mac-domain ${md} ${cmd}\n`; }); cli += `cable mac-domain ${md} admin-state ${document.getElementById('b_admin').value}\ncommit\n`; if (document.getElementById('g_ds_dyn').value === 'disabled') { const isNewDs = document.getElementById('select_ds_group').value === '_new_'; let dsName = isNewDs ? document.getElementById('input_new_ds_group').value.trim() : document.getElementById('select_ds_group').value; if (dsName) { cli += `\n! 2. [區塊] DS Bonding Group [${dsName}] 設定\n`; if (!isNewDs) cli += `cable mac-domain ${md} ds-bonding-group ${dsName} admin-state down\ncommit\n`; if (document.getElementById('ds_g_down').value) cli += `cable mac-domain ${md} ds-bonding-group ${dsName} down-channel-set ${document.getElementById('ds_g_down').value}\n`; if (document.getElementById('ds_g_ofdm').value) cli += `cable mac-domain ${md} ds-bonding-group ${dsName} ofdm-channel-set ${document.getElementById('ds_g_ofdm').value}\n`; if (document.getElementById('ds_g_fdx').value) cli += `cable mac-domain ${md} ds-bonding-group ${dsName} fdx-ofdm-channel-set ${document.getElementById('ds_g_fdx').value}\n`; cli += `cable mac-domain ${md} ds-bonding-group ${dsName} admin-state ${document.getElementById('ds_g_admin').value}\ncommit\n`; } } if (document.getElementById('g_us_dyn').value === 'disabled') { const isNewUs = document.getElementById('select_us_group').value === '_new_'; let usName = isNewUs ? document.getElementById('input_new_us_group').value.trim() : document.getElementById('select_us_group').value; if (usName) { cli += `\n! 3. [區塊] US Bonding Group [${usName}] 設定\n`; if (!isNewUs) cli += `cable mac-domain ${md} us-bonding-group ${usName} admin-state down\ncommit\n`; if (document.getElementById('us_g_us').value) cli += `cable mac-domain ${md} us-bonding-group ${usName} us-channel-set ${document.getElementById('us_g_us').value}\n`; if (document.getElementById('us_g_ofdma').value) cli += `cable mac-domain ${md} us-bonding-group ${usName} ofdma-channel-set ${document.getElementById('us_g_ofdma').value}\n`; if (document.getElementById('us_g_fdx').value) cli += `cable mac-domain ${md} us-bonding-group ${usName} fdx-ofdma-channel-set ${document.getElementById('us_g_fdx').value}\n`; cli += `cable mac-domain ${md} us-bonding-group ${usName} admin-state ${document.getElementById('us_g_admin').value}\ncommit\n`; } } const previewEl = document.getElementById('cliPreviewContainer'); previewEl.textContent = cli; previewEl.style.display = 'block'; const deployBtn = document.getElementById('btnDeployConfig'); deployBtn.style.display = 'inline-block'; deployBtn.disabled = false; } async function executeBondingConfig() { if (!confirm("⚠️ 警告:此操作將導致 MAC Domain 重新啟動,影響用戶服務。確定要執行嗎?")) return; const cliScript = document.getElementById('cliPreviewContainer').textContent; const connInfo = getGlobalConnectionInfo(); if (!connInfo) return; openModal(connInfo.host); const outputEl = document.getElementById('modalOutput'); outputEl.innerHTML = `🚀 正在排隊並執行配置腳本,這可能需要幾秒鐘,請勿關閉視窗...\n\n${cliScript}`; try { const response = await fetch('/api/v1/cmts-config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ script: cliScript, host: connInfo.host, username: connInfo.user, password: connInfo.pass }) }); const result = await response.json(); if (result.status === 'success') { outputEl.style.color = "#e0e0e0"; outputEl.textContent = `✅ 設定執行完成!\n==================================================\n${result.data}`; } else { outputEl.style.color = "#e74c3c"; outputEl.textContent = `❌ 設定執行失敗:\n${result.message}`; } } catch (error) { outputEl.style.color = "#e74c3c"; outputEl.textContent = `❌ 連線或伺服器錯誤: ${error}`; } } // ========================================== // 💡 6. 完整設備配置樹狀圖邏輯 (支援資料夾層級鎖定) // ========================================== function buildTree(data, parentPath = "") { if (typeof data !== 'object' || data === null) { return `${data}`; } let html = '
'; for (const key in data) { const value = data[key]; const currentPath = parentPath ? `${parentPath}::${key}` : key; if (typeof value === 'object' && value !== null && Object.keys(value).length > 0) { // 📁 渲染資料夾 (加入編輯與操作按鈕) const elementId = `folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`; html += `
${key}
✏️
${buildTree(value, currentPath)}
`; } else { // 📄 渲染單一設定項目 (加入獨立編輯按鈕) const safeValue = (value === null ? '' : value).toString().replace(/"/g, """); const elementId = `leaf-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`; html += `
📄 ${key}: ${value === null ? '' : value}
✏️
`; } } html += '
'; return html; } async function fetchFullConfig() { const loadingMsg = document.getElementById('loading-message'); const treeContainer = document.getElementById('tree-container'); const connInfo = getGlobalConnectionInfo(); if (!connInfo) return; // 💡 1. 顯示標題旁邊的載入提示 (改為 inline-block 讓它排在同一行) if (loadingMsg) loadingMsg.style.display = 'inline-block'; // 💡 2. 清空樹狀圖區塊,保持畫面乾淨 if (treeContainer) { treeContainer.innerHTML = ''; } try { const url = `/api/v1/cmts-full-config?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}`; const response = await fetch(url); const result = await response.json(); if (result.status === 'success') { if (treeContainer) { treeContainer.innerHTML = buildTree(result.data); enableGlobalScanButton(); // 🌟 新增:樹狀圖成功渲染後,喚醒掃描按鈕 } } else { if (treeContainer) treeContainer.innerHTML = `
❌ 錯誤: ${result.message}
`; } } catch (error) { if (treeContainer) treeContainer.innerHTML = `
❌ 連線或解析失敗: ${error.message}
`; } finally { // 💡 3. 完成後隱藏載入提示 if (loadingMsg) loadingMsg.style.display = 'none'; } } // ========================================== // 7. 系統設定 (System Settings) 邏輯 // ========================================== let currentHiddenKeys = []; async function openSystemSettings() { // 切換到此頁籤時,先默默讀取目前的隱藏名單 try { const response = await fetch('/api/v1/settings/tree-filters'); const result = await response.json(); if (result.status === 'success') { currentHiddenKeys = result.data; } } catch (error) { console.error("讀取設定失敗:", error); } } async function loadRealConfigForFilters() { const connInfo = getGlobalConnectionInfo(); if (!connInfo) { alert("請先在畫面上方輸入設備連線資訊!"); return; } const loadingMsg = document.getElementById('filter-loading-msg'); const container = document.getElementById('filter-checkboxes'); loadingMsg.style.display = 'inline-block'; container.style.display = 'none'; try { // 💡 加上 skip_filter=true,確保能抓到被隱藏的節點以便取消勾選 const url = `/api/v1/cmts-full-config?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&skip_filter=true`; const response = await fetch(url); const result = await response.json(); if (result.status === 'success') { container.style.display = 'block'; container.innerHTML = buildRealFilterTree(result.data, "", currentHiddenKeys); } else { container.style.display = 'block'; container.innerHTML = `
❌ 錯誤: ${result.message}
`; } } catch (error) { container.style.display = 'block'; container.innerHTML = `
❌ 連線失敗: ${error.message}
`; } finally { loadingMsg.style.display = 'none'; } } function buildRealFilterTree(data, parentPath, hiddenKeys) { if (typeof data !== 'object' || data === null) return ''; let html = `
`; for (const key in data) { const value = data[key]; const currentPath = parentPath ? `${parentPath}::${key}` : key; const isChecked = hiddenKeys.includes(currentPath) ? "checked" : ""; if (typeof value === 'object' && value !== null && Object.keys(value).length > 0) { html += `
${key}
${buildRealFilterTree(value, currentPath, hiddenKeys)}
`; } else { const safeValue = (value === null ? '' : value).toString().replace(/"/g, """); html += `
📄 ${key}: ${safeValue}
`; } } html += '
'; return html; } // 🔽 當點擊「父節點」時:同步勾選/取消所有子節點 function toggleChildCheckboxes(parentCheckbox) { const details = parentCheckbox.closest('details'); if (details) { // 找到該資料夾內所有的子 Checkbox const childCheckboxes = details.querySelectorAll('.filter-children-container .filter-checkbox'); childCheckboxes.forEach(cb => { cb.checked = parentCheckbox.checked; }); } // 往上檢查是否需要更新更上層的父節點 updateParentCheckboxState(parentCheckbox); } // 🔼 當點擊「子節點」時:往上檢查父節點是否該被勾選 function updateParentCheckboxState(element) { // 找到包住這個元素的上一層 details const parentDetails = element.closest('.filter-children-container')?.closest('details'); if (!parentDetails) return; const parentCheckbox = parentDetails.querySelector('summary .filter-checkbox'); const childCheckboxes = parentDetails.querySelectorAll('.filter-children-container .filter-checkbox'); if (parentCheckbox && childCheckboxes.length > 0) { // 如果所有的子節點都被勾選了,父節點就自動打勾;只要有一個沒勾,父節點就取消打勾 const allChecked = Array.from(childCheckboxes).every(cb => cb.checked); parentCheckbox.checked = allChecked; // 遞迴往上檢查,確保多層級的資料夾也能正確連動 updateParentCheckboxState(parentCheckbox); } } async function saveSystemSettings() { const checkboxes = document.querySelectorAll('.filter-checkbox:checked'); const selectedHiddenKeys = Array.from(checkboxes).map(cb => cb.value); try { const response = await fetch('/api/v1/settings/tree-filters', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hidden_keys: selectedHiddenKeys }) }); const result = await response.json(); if (result.status === 'success') { const statusText = document.getElementById('settings-status'); statusText.style.display = 'inline-block'; currentHiddenKeys = selectedHiddenKeys; // 更新本地狀態 setTimeout(() => { statusText.style.display = 'none'; }, 3000); } } catch (error) { console.error("儲存設定失敗:", error); alert("儲存設定時發生錯誤!"); } } // ========================================== // 8. Modal 彈出視窗控制 // ========================================== function openModal(targetInfo) { document.getElementById('outputModal').classList.add('active'); document.getElementById('modalTargetInfo').textContent = targetInfo ? `(${targetInfo})` : ''; document.body.style.overflow = 'hidden'; } function closeModal(event) { if (event && event.target !== event.currentTarget && event.target.className !== 'modal-close') return; document.getElementById('outputModal').classList.remove('active'); document.body.style.overflow = ''; } // ========================================== // 💡 掃描按鈕狀態管理 (支援跨重新整理鎖定) // ========================================== // 1. 網頁載入時,檢查是否還有背景任務在跑 document.addEventListener("DOMContentLoaded", () => { checkScanButtonState(); }); function checkScanButtonState() { const btn = document.getElementById('global-scan-btn'); if (!btn) return; const scanLockUntil = localStorage.getItem('scanLockUntil'); const now = Date.now(); if (scanLockUntil && now < parseInt(scanLockUntil)) { // 如果還在鎖定期間內,恢復鎖定狀態 lockScanButton(parseInt(scanLockUntil) - now); } } // 2. 鎖定按鈕的 UI 變化 function lockScanButton(durationMs) { const btn = document.getElementById('global-scan-btn'); if (!btn) return; btn.disabled = true; btn.style.backgroundColor = "#e67e22"; // 變成醒目的橘色 btn.style.cursor = "not-allowed"; btn.innerText = "⏳ 背景掃描執行中..."; // 設定計時器,時間到自動解鎖 setTimeout(() => { // 只有當樹狀圖已經載入時,才恢復成可點擊的紫色 const treeContainer = document.getElementById('tree-container'); if (treeContainer && treeContainer.innerHTML.includes('leaf-container')) { enableGlobalScanButton(); } else { // 樹狀圖沒載入,退回初始灰階狀態 btn.disabled = true; btn.style.backgroundColor = "#bdc3c7"; btn.innerText = "⏳ 請先載入樹狀圖"; } localStorage.removeItem('scanLockUntil'); }, durationMs); } // 3. 提供給「樹狀圖載入完成」時呼叫的解鎖函數 function enableGlobalScanButton() { const btn = document.getElementById('global-scan-btn'); if (!btn) return; const scanLockUntil = localStorage.getItem('scanLockUntil'); // 如果目前沒有被鎖定,才把它變成可點擊的紫色 if (!scanLockUntil || Date.now() >= parseInt(scanLockUntil)) { btn.disabled = false; btn.style.backgroundColor = "#8e44ad"; // 恢復紫色 btn.style.cursor = "pointer"; btn.innerText = "🔍 掃描畫面缺失選項"; } } // ========================================== // 🌟 新增:清除畫面選項快取功能 (精準可見度判定版) // ========================================== async function clearVisibleCache() { const elements = document.querySelectorAll('.leaf-container'); const pathsToClear = []; elements.forEach(el => { // 檢查這個元素是否被包在「未展開」的資料夾裡面 const isHiddenByFolder = el.closest('details:not([open])') !== null; if (!isHiddenByFolder) { const path = el.getAttribute('data-path'); if (path) { const cacheKey = path.replace(/::/g, ' '); if (!pathsToClear.includes(cacheKey)) { pathsToClear.push(cacheKey); } } } }); if (pathsToClear.length === 0) { alert("畫面上沒有展開的選項可以清除!\n請先展開您想重抓的資料夾。"); return; } if (!confirm(`確定要清除畫面上 ${pathsToClear.length} 個選項的快取嗎?\n(清除後請重新點擊「一鍵掃描」)`)) return; try { const response = await fetch('/api/v1/clear_cache', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(pathsToClear) }); if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`); const result = await response.json(); alert(`✅ 成功清除 ${result.cleared_count} 筆快取!\n現在請點擊旁邊的「一鍵掃描缺失選項」來重新擷取。`); } catch (error) { console.error("清除快取失敗:", error); alert("❌ 清除快取失敗,請檢查網路連線或後端 API 是否正確啟動。"); } }