From 77821fc620a885e0bbd643ddca70f5b4829ad9a3 Mon Sep 17 00:00:00 2001 From: swpa Date: Thu, 28 May 2026 10:24:17 +0800 Subject: [PATCH] feat: Smart Diff Recovery & Preview --- routers/backup.py | 191 ++++++++++++++++++++++++++++++++++++++++++-- static/app.js | 78 +++++++++++++++++- static/edit-mode.js | 68 +++++----------- 3 files changed, 282 insertions(+), 55 deletions(-) diff --git a/routers/backup.py b/routers/backup.py index e43e346..0bf22b7 100644 --- a/routers/backup.py +++ b/routers/backup.py @@ -1,7 +1,10 @@ import logging +import asyncssh +import asyncio +import re from fastapi import APIRouter, HTTPException from pydantic import BaseModel -from typing import Optional +from typing import Optional, List # 引入 DB 函數 from database import ( @@ -11,8 +14,8 @@ from database import ( delete_config_backup ) -# ✅ 修正:引入我們剛剛在 cmts_scraper.py 新增的函數 -from cmts_scraper import fetch_raw_config, parse_config_to_tree +from cmts_scraper import fetch_raw_config +from shared import parse_cli_to_tree # <== 引入真正的解析器 logger = logging.getLogger(__name__) @@ -21,6 +24,9 @@ router = APIRouter( tags=["Backups"] ) +# ========================================== +# 📦 Pydantic Models +# ========================================== class SnapshotRequest(BaseModel): host: str username: str @@ -28,6 +34,91 @@ class SnapshotRequest(BaseModel): snapshot_name: str config_type: str = "running" +class RestoreRequest(BaseModel): + host: str + username: str + password: str + +class ExecuteRestoreRequest(BaseModel): + host: str + username: str + password: str + commands: List[str] # 接收前端確認後的差異指令清單 + +# ========================================== +# 🛠️ 核心演算法:深度差異比對 (Deep Diff) +# ========================================== +def build_add_commands(node: dict, current_path: str) -> list: + """遞迴展開所有需要新增的絕對路徑指令""" + commands = [] + if not node: + commands.append(current_path) + return commands + + for key, val in node.items(): + # 🌟 新增這行:忽略系統中介資料 + if key.startswith('_'): + continue + + path = f"{current_path} {key}".strip() + if isinstance(val, dict): + commands.extend(build_add_commands(val, path)) + else: + val_str = f" {val}" if val else "" + commands.append(f"{path}{val_str}") + return commands + +def generate_diff_commands(current_node: dict, snapshot_node: dict, current_path: str = "") -> list: + """比對兩棵樹,產生帶有 no 與絕對路徑的差異指令清單""" + commands = [] + + # 1. 找出需要刪除或覆蓋的 (存在於 current) + for key, curr_val in current_node.items(): + # 🌟 新增這行:忽略系統中介資料 + if key.startswith('_'): + continue + + path = f"{current_path} {key}".strip() + + if key not in snapshot_node: + # 快照裡沒有,代表這是後來新增的殘留設定 -> 刪除 + commands.append(f"no {path}") + else: + snap_val = snapshot_node[key] + if isinstance(curr_val, dict) and isinstance(snap_val, dict): + commands.extend(generate_diff_commands(curr_val, snap_val, path)) + elif isinstance(curr_val, dict) or isinstance(snap_val, dict): + # 型態不一致,先刪除舊的再新增新的 + commands.append(f"no {path}") + if isinstance(snap_val, dict): + commands.extend(build_add_commands(snap_val, path)) + else: + val_str = f" {snap_val}" if snap_val else "" + commands.append(f"{path}{val_str}") + elif curr_val != snap_val: + # 兩邊都是最終值,但內容不同 -> 直接覆寫 + val_str = f" {snap_val}" if snap_val else "" + commands.append(f"{path}{val_str}") + + # 2. 找出需要新增的 (存在於 snapshot,但 current 沒有) + for key, snap_val in snapshot_node.items(): + # 🌟 新增這行:忽略系統中介資料 + if key.startswith('_'): + continue + + path = f"{current_path} {key}".strip() + if key not in current_node: + if isinstance(snap_val, dict): + commands.extend(build_add_commands(snap_val, path)) + else: + val_str = f" {snap_val}" if snap_val else "" + commands.append(f"{path}{val_str}") + + return commands + +# ========================================== +# 🚀 API 路由 +# ========================================== @router.get("/", summary="取得歷史快照列表") async def list_backups(host: str, config_type: str = "running"): records = await get_config_backup_list(host, config_type) @@ -52,11 +143,9 @@ async def delete_backup(backup_id: str): @router.post("/snapshot", summary="手動建立設備快照") async def create_snapshot(req: SnapshotRequest): try: - # ✅ 修正:使用函數式呼叫來抓取與解析設定檔 raw_cli = await fetch_raw_config(req.host, req.username, req.password, req.config_type) - parsed_tree = parse_config_to_tree(raw_cli) + parsed_tree = parse_cli_to_tree(raw_cli) - # 寫入資料庫 backup_id = await insert_config_backup( host=req.host, config_type=req.config_type, @@ -80,3 +169,93 @@ async def create_snapshot(req: SnapshotRequest): logger.error(f"❌ 建立快照失敗: {e}") return {"status": "error", "message": f"設備連線或解析失敗: {str(e)}"} +@router.post("/{backup_id}/diff", summary="分析設備當前配置與快照的差異") +async def analyze_backup_diff(backup_id: str, req: RestoreRequest): + try: + backup_record = await get_config_backup_detail(backup_id) + if not backup_record: + return {"status": "error", "message": "找不到指定的備份紀錄"} + + snapshot_tree = backup_record.get("parsed_tree") + if not snapshot_tree: + return {"status": "error", "message": "此備份紀錄缺乏樹狀結構資料,無法進行智慧比對"} + + raw_current = await fetch_raw_config(req.host, req.username, req.password, "running") + if not raw_current: + return {"status": "error", "message": "無法從設備取得當前配置,請檢查連線狀態"} + + current_tree = parse_cli_to_tree(raw_current) + diff_commands = generate_diff_commands(current_tree, snapshot_tree) + + return { + "status": "success", + "data": { + "commands": diff_commands + } + } + + except Exception as e: + logger.error(f"差異分析失敗: {str(e)}") + return {"status": "error", "message": f"分析過程發生例外錯誤: {str(e)}"} + +@router.post("/{backup_id}/restore", summary="執行差異還原 (寫入指定的指令清單)") +async def execute_restore(backup_id: str, req: ExecuteRestoreRequest): + commands = req.commands + + if not commands: + return {"status": "success", "message": "沒有需要執行的指令,設備狀態已同步。"} + + output_log = [] + try: + conn = await asyncssh.connect(req.host, username=req.username, password=req.password, known_hosts=None) + process = await conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') + + async def read_until_quiet(timeout=1.0): + output = "" + while True: + try: + chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout) + if not chunk: break + output += chunk + if "--More--" in chunk or "More" in chunk: + process.stdin.write(" ") + await process.stdin.drain() + except asyncio.TimeoutError: + break + return output + + # 進入設定模式 + process.stdin.write("config\n") + await process.stdin.drain() + await read_until_quiet(timeout=1.5) + + # 逐行寫入精準的差異指令 + for cmd in commands: + process.stdin.write(f"{cmd}\n") + await process.stdin.drain() + out = await read_until_quiet(timeout=0.2) + output_log.append(out) + + # 提交變更 + process.stdin.write("commit\n") + await process.stdin.drain() + commit_out = await read_until_quiet(timeout=2.0) + output_log.append(commit_out) + + # 退出並關閉連線 + process.stdin.write("exit\n") + await process.stdin.drain() + conn.close() + + clean_log = re.sub(r'\x1b\[[0-9;]*[mGK]', '', "".join(output_log)) + + return { + "status": "success", + "message": "差異還原指令已成功送出並 Commit", + "output": clean_log + } + + except Exception as e: + logger.error(f"還原執行失敗: {str(e)}") + return {"status": "error", "message": f"SSH 執行失敗: {str(e)}"} + diff --git a/static/app.js b/static/app.js index 658d68e..2bb5785 100644 --- a/static/app.js +++ b/static/app.js @@ -864,8 +864,12 @@ async function loadBackupHistory() { ${item.snapshot_name} ${item.config_type} - - + + + + + + `).join(''); @@ -924,6 +928,75 @@ async function deleteBackup(backupId) { } } +// 5. 請求設備差異分析 (安全還原第一步) +async function restoreBackup(snapshotId, snapshotName) { + const connInfo = getGlobalConnectionInfo(); + if (!connInfo || !connInfo.host) { + return alert("請先連線至 CMTS 設備!"); + } + + // 開啟 Modal 顯示分析進度 + const modal = document.getElementById('outputModal'); + const modalOutput = document.getElementById('modalOutput'); + const modalTargetInfo = document.getElementById('modalTargetInfo'); + + modalTargetInfo.innerText = `正在分析快照差異: ${snapshotName}`; + modalOutput.innerHTML = `🔍 正在抓取設備當前配置,並與快照進行深度比對,請稍候...`; + modal.classList.add('active'); + + try { + // 呼叫後端全新的 Diff API (我們接下來要實作的) + const response = await fetch(`/api/v1/backups/${snapshotId}/diff`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + host: connInfo.host, + username: connInfo.user, + password: connInfo.pass + }) + }); + + const result = await response.json(); + + if (result.status === 'success') { + // 渲染差異預覽畫面 + let diffHtml = `
以下是將設備還原至 ${snapshotName} 所需執行的指令清單:
`; + + if (result.data.commands.length === 0) { + diffHtml += `
✅ 設備當前狀態與快照完全一致,無需進行任何還原動作!
`; + } else { + diffHtml += `
`;
+                result.data.commands.forEach(cmd => {
+                    if (cmd.startsWith('no ')) {
+                        diffHtml += `- ${cmd}\n`; // 刪除用紅色
+                    } else {
+                        diffHtml += `+ ${cmd}\n`; // 新增/修改用綠色
+                    }
+                });
+                diffHtml += `
`; + + // 加入最終確認按鈕 + diffHtml += ` +
+ +
+ `; + } + modalOutput.innerHTML = diffHtml; + } else { + modalOutput.innerHTML = `❌ 差異分析失敗:\n${result.message}`; + } + } catch (error) { + modalOutput.innerHTML = `❌ 系統錯誤:無法連線至後端伺服器。\n${error.message}`; + } +} + +// 6. 真正執行差異還原 (掛載到 window 供按鈕呼叫) +window.executeSmartRestore = async function(snapshotId, snapshotName) { + // 這裡將會呼叫我們原本設計好的寫入 API + alert("即將實作:將上方預覽的指令寫入設備!"); +}; + // ============================================================================ // 🌟 6. 共用 UI 元件與狀態管理 (Modals & Button States) // ============================================================================ @@ -1095,6 +1168,7 @@ window.createSnapshot = createSnapshot; window.loadBackupHistory = loadBackupHistory; window.viewBackup = viewBackup; window.deleteBackup = deleteBackup; +window.restoreBackup = restoreBackup; // 🌟 新增綁定還原函數 // 將備份還原相關函數綁定到全域,供 HTML onclick 使用 window.loadBackupHistory = loadBackupHistory; diff --git a/static/edit-mode.js b/static/edit-mode.js index 9966c3f..2e5a10d 100644 --- a/static/edit-mode.js +++ b/static/edit-mode.js @@ -3,7 +3,7 @@ import { apiAcquireLock, apiReleaseLock, apiSendHeartbeat, apiGetLeafOptions, apiSyncLeafOptions, apiGenerateCli, apiExecuteConfig, - apiSyncLeafOptionsStream // 🌟 新增這一個 + apiSyncLeafOptionsStream } from './api.js'; import { getGlobalConnectionInfo } from './utils.js'; @@ -24,19 +24,17 @@ function escapeHTML(str) { export const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9); const ACTIVE_HEARTBEATS = {}; -// 💡 追蹤當前編輯狀態 export let currentEditPath = null; export let currentEditElementId = null; export let currentEditIsSuccess = false; export let currentEditType = null; -export let currentEditDiffs = []; // 🌟 新增:用來記錄這次修改了哪些路徑 +export let currentEditDiffs = []; export async function releaseAllLocks() { const keysToRelease = Object.keys(ACTIVE_HEARTBEATS); if (keysToRelease.length === 0) return; const releasePromises = keysToRelease.map(key => { - // 🌟 解析出 host 與 path const [host, path] = key.split('@@'); clearInterval(ACTIVE_HEARTBEATS[key]); delete ACTIVE_HEARTBEATS[key]; @@ -49,7 +47,6 @@ export async function releaseAllLocks() { location.reload(); } -// 🌟 加入 host 與 lockKey 參數 async function sendHeartbeat(path, host, intervalId, lockKey) { try { const response = await apiSendHeartbeat(path, SESSION_USER_ID, host); @@ -74,7 +71,7 @@ async function sendHeartbeat(path, host, intervalId, lockKey) { export async function startEditFolder(path, elementId) { const connInfo = getGlobalConnectionInfo(); const username = connInfo ? connInfo.user : 'admin'; - const host = connInfo ? connInfo.host : ''; // 🌟 取得設備 IP + const host = connInfo ? connInfo.host : ''; const lockKey = `${host}@@${path}`; const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running'; @@ -89,7 +86,6 @@ export async function startEditFolder(path, elementId) { intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000); ACTIVE_HEARTBEATS[lockKey] = intervalId; - // 🌟 修正:加入安全檢查,避免找不到元素時發生 null 錯誤 const editBtn = document.getElementById(`edit-btn-${elementId}`); if (editBtn) editBtn.style.display = 'none'; @@ -102,21 +98,17 @@ export async function startEditFolder(path, elementId) { const contentDiv = document.getElementById(`content-${elementId}`); if (!contentDiv) { console.warn(`[防呆警告] 找不到 content-${elementId} 的容器,請略過此資料夾的編輯。`); - return; // 找不到內容容器就提早結束,避免後續 querySelectorAll 報錯 + return; } const leafContainers = contentDiv.querySelectorAll('.leaf-container'); let optData = {}; - // 🌟 修改 1:傳入 host 給 apiGetLeafOptions try { optData = await apiGetLeafOptions(host, currentMode); } catch (e) {} const pathsToSync = []; - // ========================================== - // 🌟 效能急救:分批渲染 (Chunking) 避免卡死主執行緒 - // ========================================== const containersArray = Array.from(leafContainers); - const CHUNK_SIZE = 50; // 每次處理 50 個,確保畫面不結凍 + const CHUNK_SIZE = 50; for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) { const chunk = containersArray.slice(i, i + CHUNK_SIZE); @@ -159,12 +151,10 @@ export async function startEditFolder(path, elementId) { container.innerHTML = inputHtml; }); - // 🌟 關鍵魔法:每處理完 50 個,就暫停 0 毫秒,讓瀏覽器有空檔去畫畫面或回應滑鼠 await new Promise(resolve => setTimeout(resolve, 0)); } - // 🌟 傳入 host 給 apiSyncLeafOptions - if (pathsToSync.length > 0) apiSyncLeafOptions(host, pathsToSync, currentMode); + if (pathsToSync.length > 0) apiSyncLeafOptions(host, pathsToSync, currentMode, username, connInfo.pass); } } catch (error) { alert("❌ 無法連線到鎖定伺服器:" + error.message); @@ -199,19 +189,16 @@ export async function startEditLeaf(path, elementId) { container.innerHTML = `⏳ 設備連線與載入選項中...`; try { - // 🌟 修改 1:傳入 host 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) { - // 🌟 修改 2:傳入 host - apiSyncLeafOptions(host, [cacheKey], currentMode); + apiSyncLeafOptions(host, [cacheKey], currentMode, username, connInfo.pass); let found = false; for (let i = 0; i < 10; i++) { await new Promise(resolve => setTimeout(resolve, 1000)); - // 🌟 修改 3:傳入 host optData = await apiGetLeafOptions(host, currentMode); leafCache = optData[cacheKey]; if (leafCache && leafCache.options && leafCache.options.length > 0) { @@ -301,7 +288,6 @@ export async function cancelEditLeaf(path, elementId) { // 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 []; @@ -343,7 +329,7 @@ export async function previewFolderCLI(path, elementId) { }); if (diffs.length === 0) return alert("沒有偵測到任何修改,無需生成指令!"); - currentEditDiffs = diffs; // 🌟 新增這行:記錄修改差異 + currentEditDiffs = diffs; try { const result = await apiGenerateCli(diffs, getInterfacesWithAdminState()); @@ -375,7 +361,7 @@ export async function previewLeafCLI(path, elementId) { if (originalVal === newVal) return alert("沒有偵測到任何修改,無需生成指令!"); const diffs = [{ path: path, old_val: originalVal, new_val: newVal }]; - currentEditDiffs = diffs; // 🌟 新增這行:記錄修改差異 + currentEditDiffs = diffs; try { const result = await apiGenerateCli(diffs, getInterfacesWithAdminState()); @@ -390,7 +376,6 @@ export async function previewLeafCLI(path, elementId) { } function showCliPreviewModal(cliScript) { - // 🌟 修改:改用 class 統一控制所有樹狀圖容器 const leftPanes = document.querySelectorAll('.tree-view-instance'); const resizer = document.getElementById('drag-resizer'); const rightPane = document.getElementById('side-cli-preview'); @@ -407,12 +392,10 @@ function showCliPreviewModal(cliScript) { const textarea = document.getElementById('side-cli-textarea'); textarea.value = cliScript; - // 👇 確保正常模式可以編輯,並明確恢復深色主題 textarea.readOnly = false; - textarea.style.backgroundColor = '#1e1e1e'; // 恢復成標準的深色背景 (您可以依喜好微調顏色碼) - textarea.style.color = '#ecf0f1'; // 確保字體是清晰的淺灰色 + textarea.style.backgroundColor = '#1e1e1e'; + textarea.style.color = '#ecf0f1'; - // 🌟 修改:遍歷所有樹狀圖容器進行縮放 leftPanes.forEach(pane => pane.style.width = 'calc(50% - 11px)'); rightPane.style.width = 'calc(50% - 11px)'; resizer.style.display = 'flex'; @@ -428,7 +411,6 @@ function showCliPreviewModal(cliScript) { // 4. 側邊欄與執行邏輯 // ========================================== export async function hideSideCLI() { - // 🌟 修改:改用 class 統一控制所有樹狀圖容器 document.querySelectorAll('.tree-view-instance').forEach(pane => pane.style.width = '100%'); document.getElementById('drag-resizer').style.display = 'none'; document.getElementById('side-cli-preview').style.display = 'none'; @@ -517,23 +499,29 @@ async function executeGeneratedCLI(script) { const uniquePaths = [...new Set(pathsToSync)]; try { - // 🌟 修改:取得當前模式,並將 connInfo.host 與 currentMode 傳給 apiSyncLeafOptionsStream const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running'; + apiSyncLeafOptions(connInfo.host, uniquePaths, currentMode, connInfo.user, connInfo.pass); + await apiSyncLeafOptionsStream(connInfo.host, uniquePaths, (data) => { + if (data.event === 'done') { document.getElementById('side-pane-title').innerHTML = '✅ 執行與快取同步完美達成'; document.getElementById('side-pane-title').style.color = '#2ecc71'; document.getElementById('btn-side-close').style.display = 'inline-block'; outputEl.innerHTML += `

[系統] 快取同步完成!您可以關閉此面板。`; + } else if (data.event === 'error') { document.getElementById('side-pane-title').innerHTML = '✅ 寫入成功 (但同步快取失敗)'; document.getElementById('side-pane-title').style.color = '#e74c3c'; document.getElementById('btn-side-close').style.display = 'inline-block'; - outputEl.innerHTML += `

[系統] 同步失敗: ${data.message}`; + outputEl.innerHTML += `

[系統] 同步失敗: ${data.message || '未知錯誤'}`; } + }, currentMode); + } catch (syncErr) { + console.error("同步設定失敗:", syncErr); document.getElementById('btn-side-close').style.display = 'inline-block'; } @@ -558,7 +546,6 @@ async function executeGeneratedCLI(script) { // ========================================== function initResizer() { const resizer = document.getElementById('drag-resizer'); - // 🌟 修改:改用 class 統一控制所有樹狀圖容器 const leftPanes = document.querySelectorAll('.tree-view-instance'); const rightPane = document.getElementById('side-cli-preview'); const container = document.getElementById('split-container'); @@ -581,7 +568,6 @@ function initResizer() { 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)`; }); @@ -639,11 +625,9 @@ function transformNoToActive(elementId, newCommand) { // 💻 開發者工具:預覽節點完整指令 (唯讀 Debug 模式) // ============================================================================ export async function previewNodeCLI(btnElement, rootPath) { - // 1. 定位當前點擊的容器 let container = btnElement.closest('details'); if (container) { const elementId = container.id.replace('details-', ''); - // 確保延遲渲染的內容已經載入 (Lazy Load) if (container.dataset.loaded !== 'true') { await window.lazyLoadFolder(elementId, false); } @@ -651,7 +635,6 @@ export async function previewNodeCLI(btnElement, rootPath) { const targetNode = container || btnElement.closest('.tree-leaf-node'); if (!targetNode) return; - // 2. 收集所有子節點資料 const leafNodes = targetNode.querySelectorAll('.leaf-container'); const interfaceGroups = {}; @@ -662,7 +645,6 @@ export async function previewNodeCLI(btnElement, rootPath) { const parts = path.split('::'); const paramName = parts.pop(); - // 移除虛擬陣列索引 [0], [1] 等,還原真實介面路徑 let interfacePath = parts.join(' ').replace(/\s*\[\d+\]/g, ''); if (!interfaceGroups[interfacePath]) { @@ -671,7 +653,6 @@ export async function previewNodeCLI(btnElement, rootPath) { 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) { @@ -688,22 +669,18 @@ export async function previewNodeCLI(btnElement, rootPath) { return alert("此節點下沒有任何數值可供預覽!"); } - // 3. 在前端組裝平坦化 CLI (模擬真實設備指令) 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') { - // 處理 no 指令 cliLines.push(interfacePath ? `${interfacePath} no ${val}` : `no ${val}`); } else { - // 一般鍵值對 (若 val 為空,代表是單純的 flag 指令) let line = interfacePath ? `${interfacePath} ${param}` : param; if (val) line += ` ${val}`; cliLines.push(line); @@ -713,11 +690,9 @@ export async function previewNodeCLI(btnElement, rootPath) { } const finalScript = cliLines.join('\n'); - // 4. 顯示唯讀預覽面板 showReadOnlyCliPreviewModal(finalScript, rootPath); } -// 專為 God-Mode 設計的唯讀面板 UI function showReadOnlyCliPreviewModal(cliScript, rootPath) { const leftPanes = document.querySelectorAll('.tree-view-instance'); const resizer = document.getElementById('drag-resizer'); @@ -726,7 +701,6 @@ function showReadOnlyCliPreviewModal(cliScript, rootPath) { 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'); @@ -736,8 +710,8 @@ function showReadOnlyCliPreviewModal(cliScript, rootPath) { const textarea = document.getElementById('side-cli-textarea'); textarea.style.display = 'block'; textarea.value = cliScript; - textarea.readOnly = true; // 強制唯讀 - textarea.style.backgroundColor = '#2c3e50'; // 改變背景色提示唯讀狀態 + textarea.readOnly = true; + textarea.style.backgroundColor = '#2c3e50'; document.getElementById('side-execution-result').style.display = 'none';