2026-05-13 07:05:39 +00:00
|
|
|
|
// --- static/edit-mode.js ---
|
|
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
|
apiAcquireLock, apiReleaseLock, apiSendHeartbeat,
|
2026-05-14 08:30:10 +00:00
|
|
|
|
apiGetLeafOptions, apiSyncLeafOptions, apiGenerateCli, apiExecuteConfig,
|
|
|
|
|
|
apiSyncLeafOptionsStream // 🌟 新增這一個
|
2026-05-13 07:05:39 +00:00
|
|
|
|
} from './api.js';
|
|
|
|
|
|
import { getGlobalConnectionInfo } from './utils.js';
|
|
|
|
|
|
|
2026-05-13 07:43:42 +00:00
|
|
|
|
// --- 安全跳脫 HTML 特殊字元,防止破版 ---
|
|
|
|
|
|
function escapeHTML(str) {
|
|
|
|
|
|
if (str === null || str === undefined) return "";
|
|
|
|
|
|
return String(str)
|
|
|
|
|
|
.replace(/&/g, "&")
|
|
|
|
|
|
.replace(/</g, "<")
|
|
|
|
|
|
.replace(/>/g, ">")
|
|
|
|
|
|
.replace(/"/g, """)
|
|
|
|
|
|
.replace(/'/g, "'");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 07:05:39 +00:00
|
|
|
|
// ==========================================
|
|
|
|
|
|
// 1. 全域狀態與鎖定管理 (Lock Management)
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
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;
|
2026-05-14 08:30:10 +00:00
|
|
|
|
export let currentEditDiffs = []; // 🌟 新增:用來記錄這次修改了哪些路徑
|
2026-05-13 07:05:39 +00:00
|
|
|
|
|
|
|
|
|
|
export async function releaseAllLocks() {
|
|
|
|
|
|
const pathsToRelease = Object.keys(ACTIVE_HEARTBEATS);
|
|
|
|
|
|
if (pathsToRelease.length === 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
const releasePromises = pathsToRelease.map(path => {
|
|
|
|
|
|
clearInterval(ACTIVE_HEARTBEATS[path]);
|
|
|
|
|
|
delete ACTIVE_HEARTBEATS[path];
|
|
|
|
|
|
return apiReleaseLock(path, SESSION_USER_ID)
|
|
|
|
|
|
.catch(err => console.error(`釋放鎖定 ${path} 失敗:`, err));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await Promise.all(releasePromises);
|
|
|
|
|
|
alert("您已閒置超過 5 分鐘,系統已自動釋放編輯鎖定並重新整理頁面。");
|
|
|
|
|
|
location.reload();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function sendHeartbeat(path) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await apiSendHeartbeat(path, SESSION_USER_ID);
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
console.warn(`心跳發送失敗 (${path}): 伺服器回傳 ${response.status}`);
|
|
|
|
|
|
if (response.status === 404) {
|
|
|
|
|
|
clearInterval(ACTIVE_HEARTBEATS[path]);
|
|
|
|
|
|
delete ACTIVE_HEARTBEATS[path];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(`心跳請求發生錯誤 (${path}):`, error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
// 2. 編輯模式啟動與取消 (Folder & Leaf)
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
export async function startEditFolder(path, elementId) {
|
|
|
|
|
|
const connInfo = getGlobalConnectionInfo();
|
|
|
|
|
|
const username = connInfo ? connInfo.user : 'admin';
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await apiAcquireLock(path, SESSION_USER_ID, username);
|
|
|
|
|
|
if (result.status === 409) return alert(`⚠️ ${result.data.detail}`);
|
|
|
|
|
|
|
|
|
|
|
|
if (result.data.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');
|
|
|
|
|
|
|
|
|
|
|
|
let optData = {};
|
|
|
|
|
|
try { optData = await apiGetLeafOptions(); } catch (e) { console.warn("無法取得選項快取", e); }
|
|
|
|
|
|
|
|
|
|
|
|
const pathsToSync = [];
|
|
|
|
|
|
|
|
|
|
|
|
leafContainers.forEach(container => {
|
|
|
|
|
|
const origVal = container.getAttribute('data-original');
|
|
|
|
|
|
const rawPath = container.getAttribute('data-path');
|
2026-05-13 07:43:42 +00:00
|
|
|
|
|
|
|
|
|
|
// ✅ 修改這裡:新增 safeOrigVal
|
|
|
|
|
|
const safeOrigVal = escapeHTML(origVal);
|
|
|
|
|
|
|
2026-05-13 07:05:39 +00:00
|
|
|
|
let cacheKey = rawPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
|
|
|
|
|
|
|
|
|
|
|
if (rawPath.endsWith('::no')) {
|
|
|
|
|
|
cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let leafCache = optData[cacheKey];
|
|
|
|
|
|
|
2026-05-15 07:17:28 +00:00
|
|
|
|
if (!leafCache) {
|
2026-05-13 07:05:39 +00:00
|
|
|
|
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 => {
|
2026-05-13 07:43:42 +00:00
|
|
|
|
// ✅ 修改這裡:新增 safeOpt,並替換 value 與顯示文字
|
|
|
|
|
|
const safeOpt = escapeHTML(opt);
|
2026-05-13 07:05:39 +00:00
|
|
|
|
const isSelected = (opt === origVal) ? 'selected' : '';
|
2026-05-13 07:43:42 +00:00
|
|
|
|
inputHtml += `<option value="${safeOpt}" ${isSelected}>${safeOpt}</option>`;
|
2026-05-13 07:05:39 +00:00
|
|
|
|
});
|
|
|
|
|
|
inputHtml += `</select>${hintIcon}`;
|
|
|
|
|
|
} else {
|
2026-05-13 07:43:42 +00:00
|
|
|
|
// ✅ 修改這裡:把 value="${origVal}" 改成 value="${safeOrigVal}"
|
|
|
|
|
|
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}`;
|
2026-05-13 07:05:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
container.innerHTML = inputHtml;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (pathsToSync.length > 0) {
|
|
|
|
|
|
console.log(`🚀 將 ${pathsToSync.length} 個欄位送入背景排程抓取...`);
|
|
|
|
|
|
apiSyncLeafOptions(pathsToSync);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
alert("❌ 無法連線到鎖定伺服器:" + error.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function startEditLeaf(path, elementId) {
|
|
|
|
|
|
const connInfo = getGlobalConnectionInfo();
|
|
|
|
|
|
const username = connInfo ? connInfo.user : 'admin';
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await apiAcquireLock(path, SESSION_USER_ID, username);
|
|
|
|
|
|
if (result.status === 409) return alert(`⚠️ ${result.data.detail}`);
|
|
|
|
|
|
|
|
|
|
|
|
if (result.data.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');
|
2026-05-13 07:43:42 +00:00
|
|
|
|
|
|
|
|
|
|
// ✅ 修改這裡:新增 safeOrigVal
|
|
|
|
|
|
const safeOrigVal = escapeHTML(origVal);
|
|
|
|
|
|
|
2026-05-13 07:05:39 +00:00
|
|
|
|
container.innerHTML = `<span style="font-size: 12px; color: #e67e22; margin-left: 5px;">⏳ 設備連線與載入選項中...</span>`;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
let optData = await apiGetLeafOptions();
|
|
|
|
|
|
let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
|
|
|
|
|
|
|
|
|
|
|
if (path.endsWith('::no')) {
|
|
|
|
|
|
cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let leafCache = optData[cacheKey];
|
|
|
|
|
|
|
2026-05-15 07:17:28 +00:00
|
|
|
|
if (!leafCache) {
|
2026-05-13 07:05:39 +00:00
|
|
|
|
apiSyncLeafOptions([cacheKey]);
|
|
|
|
|
|
let found = false;
|
|
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
|
|
|
|
optData = await apiGetLeafOptions();
|
|
|
|
|
|
leafCache = optData[cacheKey];
|
|
|
|
|
|
if (leafCache && leafCache.options && leafCache.options.length > 0) {
|
|
|
|
|
|
found = true; break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!found) console.warn("⏳ 等待選項超時,退回純文字模式");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 => {
|
2026-05-13 07:43:42 +00:00
|
|
|
|
// ✅ 修改這裡:新增 safeOpt
|
|
|
|
|
|
const safeOpt = escapeHTML(opt);
|
2026-05-13 07:05:39 +00:00
|
|
|
|
const isSelected = (opt === origVal) ? 'selected' : '';
|
2026-05-13 07:43:42 +00:00
|
|
|
|
inputHtml += `<option value="${safeOpt}" ${isSelected}>${safeOpt}</option>`;
|
2026-05-13 07:05:39 +00:00
|
|
|
|
});
|
|
|
|
|
|
inputHtml += `</select>${hintIcon}`;
|
|
|
|
|
|
} else {
|
2026-05-13 07:43:42 +00:00
|
|
|
|
// ✅ 修改這裡:使用 safeOrigVal
|
|
|
|
|
|
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}`;
|
2026-05-13 07:05:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
container.innerHTML = inputHtml;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.warn("選項載入失敗", e);
|
2026-05-13 07:43:42 +00:00
|
|
|
|
// ✅ 修改這裡:catch 區塊也要使用 safeOrigVal
|
|
|
|
|
|
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;">`;
|
2026-05-13 07:05:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
alert("❌ 無法連線到鎖定伺服器:" + error.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function cancelEditFolder(path, elementId) {
|
|
|
|
|
|
if (ACTIVE_HEARTBEATS[path]) {
|
|
|
|
|
|
clearInterval(ACTIVE_HEARTBEATS[path]);
|
|
|
|
|
|
delete ACTIVE_HEARTBEATS[path];
|
|
|
|
|
|
}
|
|
|
|
|
|
try { await apiReleaseLock(path, 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');
|
2026-05-13 07:43:42 +00:00
|
|
|
|
// ✅ 修改這裡:還原時也要跳脫
|
|
|
|
|
|
const safeOrigVal = escapeHTML(origVal);
|
|
|
|
|
|
container.innerHTML = `<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${safeOrigVal}</span>`;
|
2026-05-13 07:05:39 +00:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function cancelEditLeaf(path, elementId) {
|
|
|
|
|
|
if (ACTIVE_HEARTBEATS[path]) {
|
|
|
|
|
|
clearInterval(ACTIVE_HEARTBEATS[path]);
|
|
|
|
|
|
delete ACTIVE_HEARTBEATS[path];
|
|
|
|
|
|
}
|
|
|
|
|
|
try { await apiReleaseLock(path, 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');
|
2026-05-13 07:43:42 +00:00
|
|
|
|
// ✅ 修改這裡:還原時也要跳脫
|
|
|
|
|
|
const safeOrigVal = escapeHTML(origVal);
|
|
|
|
|
|
container.innerHTML = `<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${safeOrigVal}</span>`;
|
2026-05-13 07:05:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
// 3. CLI 生成與預覽 (CLI Generation)
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
function getInterfacesWithAdminState() {
|
|
|
|
|
|
const nodes = document.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("沒有偵測到任何修改,無需生成指令!");
|
2026-05-14 08:30:10 +00:00
|
|
|
|
currentEditDiffs = diffs; // 🌟 新增這行:記錄修改差異
|
2026-05-13 07:05:39 +00:00
|
|
|
|
|
|
|
|
|
|
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 }];
|
2026-05-14 08:30:10 +00:00
|
|
|
|
currentEditDiffs = diffs; // 🌟 新增這行:記錄修改差異
|
2026-05-13 07:05:39 +00:00
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await apiGenerateCli(diffs, getInterfacesWithAdminState());
|
|
|
|
|
|
if (result.status === 'success') {
|
|
|
|
|
|
showCliPreviewModal(result.data);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
alert("CLI 生成失敗: " + result.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
alert("網路連線錯誤,無法生成 CLI");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function showCliPreviewModal(cliScript) {
|
|
|
|
|
|
const leftPane = document.getElementById('tree-container');
|
|
|
|
|
|
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('side-cli-textarea').style.display = 'block';
|
|
|
|
|
|
document.getElementById('side-execution-result').style.display = 'none';
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('side-cli-textarea').value = cliScript;
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
// 4. 側邊欄與執行邏輯
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
export async 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) {
|
|
|
|
|
|
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);
|
2026-05-14 08:30:10 +00:00
|
|
|
|
|
|
|
|
|
|
// 注意:這裡移除了原本直接顯示 btn-side-close 的程式碼,改到後續判斷中顯示
|
2026-05-13 07:05:39 +00:00
|
|
|
|
|
|
|
|
|
|
if (result.status === 'success') {
|
|
|
|
|
|
currentEditIsSuccess = true;
|
2026-05-14 08:30:10 +00:00
|
|
|
|
|
|
|
|
|
|
// 🌟 1. 顯示寫入成功,並提示正在同步
|
|
|
|
|
|
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>`;
|
|
|
|
|
|
|
|
|
|
|
|
// 🌟 2. 萃取出需要重新抓取的路徑 (精準打擊)
|
|
|
|
|
|
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)];
|
|
|
|
|
|
|
|
|
|
|
|
// 🌟 3. 呼叫後端爬蟲,去 CMTS 把這幾個欄位的最新狀態拉下來更新 Cache
|
|
|
|
|
|
try {
|
|
|
|
|
|
await apiSyncLeafOptionsStream(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>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (syncErr) {
|
|
|
|
|
|
document.getElementById('btn-side-close').style.display = 'inline-block';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 07:05:39 +00:00
|
|
|
|
} else {
|
|
|
|
|
|
currentEditIsSuccess = false;
|
2026-05-14 08:30:10 +00:00
|
|
|
|
document.getElementById('btn-side-close').style.display = 'inline-block';
|
2026-05-13 07:05:39 +00:00
|
|
|
|
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');
|
|
|
|
|
|
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;
|
|
|
|
|
|
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';
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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(' ');
|
|
|
|
|
|
|
2026-05-13 07:43:42 +00:00
|
|
|
|
const safeRestCommand = escapeHTML(restCommand);
|
|
|
|
|
|
|
2026-05-13 07:05:39 +00:00
|
|
|
|
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;">
|
2026-05-13 07:43:42 +00:00
|
|
|
|
<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);">${safeRestCommand}</span>
|
2026-05-13 07:05:39 +00:00
|
|
|
|
<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';
|
|
|
|
|
|
}
|
2026-05-15 09:25:09 +00:00
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// 💻 開發者工具:預覽節點完整指令 (唯讀 Debug 模式)
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
export function previewNodeCLI(btnElement, rootPath) {
|
|
|
|
|
|
// 1. 定位當前點擊的容器
|
|
|
|
|
|
let container = btnElement.closest('details');
|
|
|
|
|
|
if (!container) {
|
|
|
|
|
|
const leafNode = btnElement.closest('.tree-leaf-node');
|
|
|
|
|
|
if (leafNode) container = leafNode.querySelector('.leaf-container');
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!container) return;
|
|
|
|
|
|
|
|
|
|
|
|
let cliOutput = `! =========================================\n`;
|
|
|
|
|
|
cliOutput += `! 🔍 [Debug] 節點預覽: ${rootPath}\n`;
|
|
|
|
|
|
cliOutput += `! =========================================\n`;
|
|
|
|
|
|
cliOutput += `${rootPath.replace(/::/g, ' ')}\n`;
|
|
|
|
|
|
|
|
|
|
|
|
// 2. 找出該節點下所有的葉節點 (包含自己)
|
|
|
|
|
|
const allLeaves = container.classList.contains('leaf-container')
|
|
|
|
|
|
? [container]
|
|
|
|
|
|
: Array.from(container.querySelectorAll('.leaf-container'));
|
|
|
|
|
|
|
|
|
|
|
|
if (allLeaves.length === 1 && allLeaves[0] === container) {
|
|
|
|
|
|
// 情況 A:它自己就是最底層的葉節點
|
|
|
|
|
|
const val = container.getAttribute('data-original');
|
|
|
|
|
|
if (val) cliOutput += ` ${val}\n`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 情況 B:它是一個資料夾,遞迴列出所有子項目的相對路徑與值
|
|
|
|
|
|
allLeaves.forEach(leaf => {
|
|
|
|
|
|
const leafPath = leaf.getAttribute('data-path');
|
|
|
|
|
|
const leafVal = leaf.getAttribute('data-original');
|
|
|
|
|
|
|
|
|
|
|
|
if (leafPath && leafVal !== null) {
|
|
|
|
|
|
const relativePath = leafPath.replace(rootPath, '').replace(/^::/, '').replace(/::/g, ' ');
|
|
|
|
|
|
cliOutput += ` ${relativePath} ${leafVal}\n`;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cliOutput += `exit\n`;
|
|
|
|
|
|
|
|
|
|
|
|
// 3. 呼叫現有的樹狀圖側邊欄 (Side Pane) 顯示
|
|
|
|
|
|
const leftPane = document.getElementById('tree-container');
|
|
|
|
|
|
const resizer = document.getElementById('drag-resizer');
|
|
|
|
|
|
const rightPane = document.getElementById('side-cli-preview');
|
|
|
|
|
|
|
|
|
|
|
|
if (!rightPane) {
|
|
|
|
|
|
console.error("找不到側邊欄元素 'side-cli-preview'");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 設定標題與樣式
|
|
|
|
|
|
document.getElementById('side-pane-title').innerHTML = '🔍 節點指令預覽 (唯讀)';
|
|
|
|
|
|
document.getElementById('side-pane-title').style.color = '#3498db';
|
|
|
|
|
|
|
|
|
|
|
|
// 隱藏「確認寫入」按鈕,只顯示「取消/關閉」按鈕
|
|
|
|
|
|
document.getElementById('btn-side-confirm').style.display = 'none';
|
|
|
|
|
|
document.getElementById('btn-side-cancel').style.display = 'inline-block';
|
|
|
|
|
|
document.getElementById('btn-side-close').style.display = 'none';
|
|
|
|
|
|
|
|
|
|
|
|
// 顯示 Textarea 並填入內容
|
|
|
|
|
|
const textarea = document.getElementById('side-cli-textarea');
|
|
|
|
|
|
textarea.style.display = 'block';
|
|
|
|
|
|
textarea.value = cliOutput;
|
|
|
|
|
|
textarea.style.backgroundColor = '#1e1e1e'; // 終端機深色背景
|
|
|
|
|
|
textarea.style.color = '#d4d4d4';
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('side-execution-result').style.display = 'none';
|
|
|
|
|
|
|
|
|
|
|
|
// 展開側邊欄
|
|
|
|
|
|
leftPane.style.width = 'calc(50% - 11px)';
|
|
|
|
|
|
rightPane.style.width = 'calc(50% - 11px)';
|
|
|
|
|
|
if (resizer) resizer.style.display = 'flex';
|
|
|
|
|
|
rightPane.style.display = 'block';
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化拖拉分隔線 (如果尚未初始化)
|
|
|
|
|
|
if (!window.isResizerInitialized && typeof initResizer === 'function') {
|
|
|
|
|
|
initResizer();
|
|
|
|
|
|
window.isResizerInitialized = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|