756 lines
35 KiB
JavaScript
756 lines
35 KiB
JavaScript
// --- 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, """)
|
||
.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 = {};
|
||
|
||
// 💡 追蹤當前編輯狀態
|
||
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);
|
||
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];
|
||
return apiReleaseLock(path, SESSION_USER_ID, host)
|
||
.catch(err => console.error(`釋放鎖定 ${path} 失敗:`, err));
|
||
});
|
||
|
||
await Promise.all(releasePromises);
|
||
alert("您已閒置超過 5 分鐘,系統已自動釋放編輯鎖定並重新整理頁面。");
|
||
location.reload();
|
||
}
|
||
|
||
// 🌟 加入 host 與 lockKey 參數
|
||
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) {
|
||
const connInfo = getGlobalConnectionInfo();
|
||
const username = connInfo ? connInfo.user : 'admin';
|
||
const host = connInfo ? connInfo.host : ''; // 🌟 取得設備 IP
|
||
const lockKey = `${host}@@${path}`;
|
||
|
||
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
|
||
|
||
try {
|
||
const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
|
||
if (result.status === 409) 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;
|
||
|
||
// 🌟 修正:加入安全檢查,避免找不到元素時發生 null 錯誤
|
||
const editBtn = document.getElementById(`edit-btn-${elementId}`);
|
||
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; // 找不到內容容器就提早結束,避免後續 querySelectorAll 報錯
|
||
}
|
||
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 個,確保畫面不結凍
|
||
|
||
for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) {
|
||
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 = `<span style="cursor: help; margin-left: 6px; color: #e67e22; font-size: 14px;" title="${safeHint}">❓</span>`;
|
||
}
|
||
|
||
if (leafCache && leafCache.options && leafCache.options.length > 0) {
|
||
let options = leafCache.options;
|
||
if (origVal && !options.includes(origVal)) options = [origVal, ...options];
|
||
|
||
inputHtml = `<select class="edit-input" data-path="${rawPath}" ${hintAttr} style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; height: 26px; outline: none; margin-left: 5px; background-color: white; cursor: pointer;">`;
|
||
options.forEach(opt => {
|
||
const safeOpt = escapeHTML(opt);
|
||
const isSelected = (opt === origVal) ? 'selected' : '';
|
||
inputHtml += `<option value="${safeOpt}" ${isSelected}>${safeOpt}</option>`;
|
||
});
|
||
inputHtml += `</select>${hintIcon}`;
|
||
} else {
|
||
inputHtml = `<input type="text" class="edit-input" data-path="${rawPath}" value="${safeOrigVal}" ${hintAttr} style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; outline: none; margin-left: 5px;">${hintIcon}`;
|
||
}
|
||
container.innerHTML = inputHtml;
|
||
});
|
||
|
||
// 🌟 關鍵魔法:每處理完 50 個,就暫停 0 毫秒,讓瀏覽器有空檔去畫畫面或回應滑鼠
|
||
await new Promise(resolve => setTimeout(resolve, 0));
|
||
}
|
||
|
||
// 🌟 傳入 host 給 apiSyncLeafOptions
|
||
if (pathsToSync.length > 0) apiSyncLeafOptions(host, pathsToSync, currentMode);
|
||
}
|
||
} catch (error) {
|
||
alert("❌ 無法連線到鎖定伺服器:" + error.message);
|
||
}
|
||
}
|
||
|
||
export async function startEditLeaf(path, 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';
|
||
|
||
try {
|
||
const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
|
||
if (result.status === 409) 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;
|
||
|
||
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');
|
||
const safeOrigVal = escapeHTML(origVal);
|
||
|
||
container.innerHTML = `<span style="font-size: 12px; color: #e67e22; margin-left: 5px;">⏳ 設備連線與載入選項中...</span>`;
|
||
|
||
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);
|
||
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) {
|
||
found = true; break;
|
||
}
|
||
}
|
||
}
|
||
|
||
let inputHtml = "";
|
||
let hintAttr = "";
|
||
let hintIcon = "";
|
||
|
||
if (leafCache && leafCache.hint) {
|
||
const safeHint = leafCache.hint.replace(/"/g, '"');
|
||
hintAttr = `title="${safeHint}"`;
|
||
hintIcon = `<span style="cursor: help; margin-left: 6px; color: #e67e22; font-size: 14px;" title="${safeHint}">❓</span>`;
|
||
}
|
||
|
||
if (leafCache && leafCache.options && leafCache.options.length > 0) {
|
||
let options = leafCache.options;
|
||
if (origVal && !options.includes(origVal)) options = [origVal, ...options];
|
||
|
||
inputHtml = `<select class="edit-input" data-path="${path}" ${hintAttr} style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; height: 26px; outline: none; margin-left: 5px; background-color: white; cursor: pointer;">`;
|
||
options.forEach(opt => {
|
||
const safeOpt = escapeHTML(opt);
|
||
const isSelected = (opt === origVal) ? 'selected' : '';
|
||
inputHtml += `<option value="${safeOpt}" ${isSelected}>${safeOpt}</option>`;
|
||
});
|
||
inputHtml += `</select>${hintIcon}`;
|
||
} else {
|
||
inputHtml = `<input type="text" class="edit-input" data-path="${path}" value="${safeOrigVal}" ${hintAttr} style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; outline: none; margin-left: 5px;">${hintIcon}`;
|
||
}
|
||
container.innerHTML = inputHtml;
|
||
} catch (e) {
|
||
container.innerHTML = `<input type="text" class="edit-input" data-path="${path}" value="${safeOrigVal}" style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; outline: none; margin-left: 5px;">`;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
alert("❌ 無法連線到鎖定伺服器:" + error.message);
|
||
}
|
||
}
|
||
|
||
export async function cancelEditFolder(path, 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];
|
||
}
|
||
try { await apiReleaseLock(path, SESSION_USER_ID, host); } 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');
|
||
const safeOrigVal = escapeHTML(origVal);
|
||
container.innerHTML = `<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${safeOrigVal}</span>`;
|
||
});
|
||
}
|
||
|
||
export async function cancelEditLeaf(path, 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];
|
||
}
|
||
try { await apiReleaseLock(path, SESSION_USER_ID, host); } 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');
|
||
const safeOrigVal = escapeHTML(origVal);
|
||
container.innerHTML = `<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${safeOrigVal}</span>`;
|
||
}
|
||
|
||
// ==========================================
|
||
// 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) {
|
||
// 🌟 修改:改用 class 統一控制所有樹狀圖容器
|
||
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'; // 確保字體是清晰的淺灰色
|
||
|
||
// 🌟 修改:遍歷所有樹狀圖容器進行縮放
|
||
leftPanes.forEach(pane => pane.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;
|
||
}
|
||
}
|
||
|
||
// ==========================================
|
||
// 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';
|
||
|
||
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 = '#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 = `<span style="color: #ecf0f1;">${finalScript}</span>`;
|
||
|
||
executeGeneratedCLI(finalScript);
|
||
}
|
||
|
||
async function executeGeneratedCLI(script) {
|
||
const connInfo = getGlobalConnectionInfo();
|
||
if (!connInfo) return;
|
||
|
||
const outputEl = document.getElementById('side-execution-result');
|
||
|
||
try {
|
||
const result = await apiExecuteConfig(script, connInfo.host, connInfo.user, connInfo.pass);
|
||
|
||
if (result.status === 'success') {
|
||
currentEditIsSuccess = true;
|
||
|
||
document.getElementById('side-pane-title').innerHTML = '✅ 寫入成功!正在從設備同步最新狀態...';
|
||
document.getElementById('side-pane-title').style.color = '#f39c12';
|
||
outputEl.innerHTML = `<span style="color: #ecf0f1;">${result.data}\n\n[系統] 正在背景重新抓取變更欄位的最新選項,請稍候...</span>`;
|
||
|
||
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 {
|
||
// 🌟 修改:取得當前模式,並將 connInfo.host 與 currentMode 傳給 apiSyncLeafOptionsStream
|
||
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
|
||
|
||
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 += `<br><br><span style="color: #2ecc71;">[系統] 快取同步完成!您可以關閉此面板。</span>`;
|
||
} 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 += `<br><br><span style="color: #e74c3c;">[系統] 同步失敗: ${data.message}</span>`;
|
||
}
|
||
}, currentMode);
|
||
} catch (syncErr) {
|
||
document.getElementById('btn-side-close').style.display = 'inline-block';
|
||
}
|
||
|
||
} else {
|
||
currentEditIsSuccess = false;
|
||
document.getElementById('btn-side-close').style.display = 'inline-block';
|
||
document.getElementById('side-pane-title').innerHTML = '❌ 執行失敗';
|
||
document.getElementById('side-pane-title').style.color = '#ff6b6b';
|
||
const rawLog = result.detail || result.message || '';
|
||
outputEl.innerHTML = `<span style="color: #ecf0f1;">${highlightTerminalOutput(rawLog)}</span>`;
|
||
}
|
||
} 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 = `<span style="color: #ff6b6b; font-weight: bold;">連線或伺服器錯誤: ${error}</span>`;
|
||
}
|
||
}
|
||
|
||
// ==========================================
|
||
// 5. 輔助 UI 函數
|
||
// ==========================================
|
||
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');
|
||
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 `<span style="color: #ff6b6b; font-weight: bold;">${line}</span>`;
|
||
}
|
||
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 = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#27ae60"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zM8.29 11.59L7 12.88l4 4 8-8-1.29-1.29L11 14.18z"/></svg>`;
|
||
const iconHtml = `<span style="margin-right: 6px; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px;">${svgEnabled}</span>`;
|
||
|
||
wrapper.innerHTML = `
|
||
${iconHtml}
|
||
<b style="color: #2c3e50;">${firstWord}</b>
|
||
<span class="leaf-container" style="display: inline-flex; align-items: center; min-height: 24px;">
|
||
<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);">${safeRestCommand}</span>
|
||
<span style="font-size: 0.8em; color: #f39c12; margin-left: 10px; border: 1px solid #f39c12; padding: 2px 6px; border-radius: 4px; background-color: #fffdf7;">(已啟用,重整後歸檔)</span>
|
||
</span>
|
||
`;
|
||
|
||
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) {
|
||
// 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);
|
||
}
|
||
}
|
||
const targetNode = container || btnElement.closest('.tree-leaf-node');
|
||
if (!targetNode) return;
|
||
|
||
// 2. 收集所有子節點資料
|
||
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();
|
||
// 移除虛擬陣列索引 [0], [1] 等,還原真實介面路徑
|
||
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("此節點下沒有任何數值可供預覽!");
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
});
|
||
cliLines.push("!");
|
||
}
|
||
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');
|
||
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';
|
||
|
||
leftPanes.forEach(pane => pane.style.width = 'calc(50% - 11px)');
|
||
rightPane.style.width = 'calc(50% - 11px)';
|
||
if (resizer) resizer.style.display = 'flex';
|
||
if (rightPane) rightPane.style.display = 'block';
|
||
|
||
if (!window.isResizerInitialized && typeof initResizer === 'function') {
|
||
initResizer();
|
||
window.isResizerInitialized = true;
|
||
}
|
||
}
|
||
|
||
// --- EOF (檔案結束) ---
|