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,
|
2026-05-28 02:24:17 +00:00
|
|
|
|
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)
|
|
|
|
|
|
// ==========================================
|
2026-05-19 10:25:10 +00:00
|
|
|
|
export const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9);
|
2026-05-13 07:05:39 +00:00
|
|
|
|
const ACTIVE_HEARTBEATS = {};
|
|
|
|
|
|
|
2026-06-02 09:35:33 +00:00
|
|
|
|
// 🌟 新增:用來防禦「秒按取消」導致的非同步渲染競爭危害
|
|
|
|
|
|
const activeEditSessions = new Set();
|
|
|
|
|
|
|
2026-05-13 07:05:39 +00:00
|
|
|
|
export let currentEditPath = null;
|
|
|
|
|
|
export let currentEditElementId = null;
|
|
|
|
|
|
export let currentEditIsSuccess = false;
|
|
|
|
|
|
export let currentEditType = null;
|
2026-05-28 02:24:17 +00:00
|
|
|
|
export let currentEditDiffs = [];
|
2026-05-13 07:05:39 +00:00
|
|
|
|
|
|
|
|
|
|
export async function releaseAllLocks() {
|
2026-05-19 10:25:10 +00:00
|
|
|
|
const keysToRelease = Object.keys(ACTIVE_HEARTBEATS);
|
2026-06-08 09:01:55 +00:00
|
|
|
|
|
|
|
|
|
|
// 🌟 1. 檢查當前是否處於 God Mode 狀態
|
|
|
|
|
|
const wasGodModeUnlocked = sessionStorage.getItem('godModeUnlocked') === 'true';
|
|
|
|
|
|
|
|
|
|
|
|
// 🌟 2. 只要觸發閒置,立刻無條件清除 God Mode 權限
|
|
|
|
|
|
sessionStorage.removeItem('godModeUnlocked');
|
|
|
|
|
|
|
|
|
|
|
|
if (keysToRelease.length === 0) {
|
|
|
|
|
|
// 如果沒有正在編輯的節點,但剛剛是 God Mode,依然要登出並重整
|
|
|
|
|
|
if (wasGodModeUnlocked) {
|
|
|
|
|
|
alert("您已閒置超過 5 分鐘,系統已自動為您登出進階維護者模式。");
|
|
|
|
|
|
location.reload();
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-05-19 10:25:10 +00:00
|
|
|
|
|
|
|
|
|
|
const releasePromises = keysToRelease.map(key => {
|
|
|
|
|
|
const [host, path] = key.split('@@');
|
|
|
|
|
|
clearInterval(ACTIVE_HEARTBEATS[key]);
|
|
|
|
|
|
delete ACTIVE_HEARTBEATS[key];
|
|
|
|
|
|
return apiReleaseLock(path, SESSION_USER_ID, host)
|
2026-05-13 07:05:39 +00:00
|
|
|
|
.catch(err => console.error(`釋放鎖定 ${path} 失敗:`, err));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await Promise.all(releasePromises);
|
2026-06-08 09:01:55 +00:00
|
|
|
|
alert("您已閒置超過 5 分鐘,系統已自動釋放編輯鎖定並登出進階模式。");
|
|
|
|
|
|
location.reload(); // 重整網頁,確保所有 UI 恢復未解鎖狀態
|
2026-05-13 07:05:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 10:25:10 +00:00
|
|
|
|
async function sendHeartbeat(path, host, intervalId, lockKey) {
|
2026-05-13 07:05:39 +00:00
|
|
|
|
try {
|
2026-05-19 10:25:10 +00:00
|
|
|
|
const response = await apiSendHeartbeat(path, SESSION_USER_ID, host);
|
2026-05-13 07:05:39 +00:00
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
console.warn(`心跳發送失敗 (${path}): 伺服器回傳 ${response.status}`);
|
|
|
|
|
|
if (response.status === 404) {
|
2026-05-19 10:25:10 +00:00
|
|
|
|
clearInterval(intervalId);
|
|
|
|
|
|
if (ACTIVE_HEARTBEATS[lockKey] === intervalId) {
|
|
|
|
|
|
delete ACTIVE_HEARTBEATS[lockKey];
|
|
|
|
|
|
}
|
2026-05-13 07:05:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(`心跳請求發生錯誤 (${path}):`, error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
// 2. 編輯模式啟動與取消 (Folder & Leaf)
|
|
|
|
|
|
// ==========================================
|
2026-05-19 10:25:10 +00:00
|
|
|
|
|
2026-05-13 07:05:39 +00:00
|
|
|
|
export async function startEditFolder(path, elementId) {
|
2026-06-02 09:35:33 +00:00
|
|
|
|
if (activeEditSessions.has(elementId)) return;
|
2026-06-10 06:37:16 +00:00
|
|
|
|
activeEditSessions.add(elementId);
|
2026-06-02 09:35:33 +00:00
|
|
|
|
|
2026-05-13 07:05:39 +00:00
|
|
|
|
const connInfo = getGlobalConnectionInfo();
|
|
|
|
|
|
const username = connInfo ? connInfo.user : 'admin';
|
2026-05-28 02:24:17 +00:00
|
|
|
|
const host = connInfo ? connInfo.host : '';
|
2026-05-19 10:25:10 +00:00
|
|
|
|
const lockKey = `${host}@@${path}`;
|
|
|
|
|
|
|
2026-06-02 09:35:33 +00:00
|
|
|
|
const editBtn = document.getElementById(`edit-btn-${elementId}`);
|
|
|
|
|
|
if (editBtn) {
|
|
|
|
|
|
editBtn.style.pointerEvents = 'none';
|
|
|
|
|
|
editBtn.innerText = "⏳";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 07:05:39 +00:00
|
|
|
|
try {
|
2026-05-19 10:25:10 +00:00
|
|
|
|
const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
|
2026-06-02 09:35:33 +00:00
|
|
|
|
|
|
|
|
|
|
if (!activeEditSessions.has(elementId)) {
|
|
|
|
|
|
if (result.status === 200 || (result.data && result.data.status === 'success')) {
|
|
|
|
|
|
if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
|
|
|
|
|
|
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
|
2026-06-10 06:37:16 +00:00
|
|
|
|
apiReleaseLock(path, SESSION_USER_ID, host);
|
2026-06-02 09:35:33 +00:00
|
|
|
|
}
|
|
|
|
|
|
if (editBtn) {
|
|
|
|
|
|
editBtn.style.pointerEvents = 'auto';
|
|
|
|
|
|
editBtn.style.opacity = '0.3';
|
|
|
|
|
|
editBtn.title = "鎖定並編輯此項目";
|
|
|
|
|
|
editBtn.innerText = "✏️";
|
|
|
|
|
|
}
|
2026-06-10 06:37:16 +00:00
|
|
|
|
return;
|
2026-06-02 09:35:33 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (result.status === 409) {
|
|
|
|
|
|
activeEditSessions.delete(elementId);
|
|
|
|
|
|
if (editBtn) {
|
|
|
|
|
|
editBtn.style.pointerEvents = 'auto';
|
|
|
|
|
|
editBtn.innerText = "✏️";
|
|
|
|
|
|
}
|
|
|
|
|
|
return alert(`⚠️ ${result.data.detail}`);
|
|
|
|
|
|
}
|
2026-05-13 07:05:39 +00:00
|
|
|
|
|
|
|
|
|
|
if (result.data.status === 'success') {
|
2026-05-19 10:25:10 +00:00
|
|
|
|
if (ACTIVE_HEARTBEATS[lockKey]) clearInterval(ACTIVE_HEARTBEATS[lockKey]);
|
|
|
|
|
|
let intervalId;
|
|
|
|
|
|
intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000);
|
|
|
|
|
|
ACTIVE_HEARTBEATS[lockKey] = intervalId;
|
2026-05-13 07:05:39 +00:00
|
|
|
|
|
2026-06-01 08:20:34 +00:00
|
|
|
|
if (!window.globalActiveLocks) window.globalActiveLocks = {};
|
|
|
|
|
|
if (!window.globalActiveLocks[host]) window.globalActiveLocks[host] = {};
|
|
|
|
|
|
window.globalActiveLocks[host][path] = { user_id: SESSION_USER_ID, username: username };
|
|
|
|
|
|
|
|
|
|
|
|
if (window.recentlyReleasedLocks) {
|
|
|
|
|
|
delete window.recentlyReleasedLocks[`${host}@@${path}`];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
2026-06-02 09:35:33 +00:00
|
|
|
|
document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`).forEach(btn => {
|
2026-06-01 08:20:34 +00:00
|
|
|
|
if (btn.id !== `edit-btn-${elementId}`) {
|
|
|
|
|
|
btn.style.pointerEvents = 'none';
|
|
|
|
|
|
btn.style.opacity = '0.2';
|
2026-06-02 09:35:33 +00:00
|
|
|
|
|
|
|
|
|
|
const btnPath = btn.getAttribute('data-path');
|
|
|
|
|
|
if (btnPath === path) {
|
|
|
|
|
|
btn.title = `🔒 您正在另一個視圖編輯此項目`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
btn.title = `🔒 父層級已被鎖定,無法編輯此項目`;
|
|
|
|
|
|
}
|
2026-06-01 08:20:34 +00:00
|
|
|
|
btn.innerText = "🔒";
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-19 10:25:10 +00:00
|
|
|
|
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;
|
2026-05-13 07:05:39 +00:00
|
|
|
|
|
2026-06-10 06:37:16 +00:00
|
|
|
|
// ==========================================
|
|
|
|
|
|
// 🌟 完美修復:在抓取葉子節點前,強制將該資料夾底下的所有 HTML 預先渲染出來
|
|
|
|
|
|
// 這樣 querySelectorAll 就能一次抓到所有深層的欄位!
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
if (typeof window.forceRenderFolderHTML === 'function') {
|
|
|
|
|
|
window.forceRenderFolderHTML(elementId);
|
2026-05-19 10:25:10 +00:00
|
|
|
|
}
|
2026-06-10 06:37:16 +00:00
|
|
|
|
|
|
|
|
|
|
const contentDiv = document.getElementById(`content-${elementId}`);
|
|
|
|
|
|
if (!contentDiv) return;
|
|
|
|
|
|
|
2026-05-13 07:05:39 +00:00
|
|
|
|
const leafContainers = contentDiv.querySelectorAll('.leaf-container');
|
|
|
|
|
|
|
|
|
|
|
|
let optData = {};
|
2026-06-10 06:37:16 +00:00
|
|
|
|
try { optData = await apiGetLeafOptions(host); } catch (e) {}
|
2026-05-13 07:05:39 +00:00
|
|
|
|
|
2026-06-02 09:35:33 +00:00
|
|
|
|
if (!activeEditSessions.has(elementId)) return;
|
2026-05-13 07:05:39 +00:00
|
|
|
|
|
2026-06-02 09:35:33 +00:00
|
|
|
|
const pathsToSync = [];
|
2026-05-19 10:25:10 +00:00
|
|
|
|
const containersArray = Array.from(leafContainers);
|
2026-05-13 07:05:39 +00:00
|
|
|
|
|
2026-06-10 06:37:16 +00:00
|
|
|
|
// 🌟 第一階段:盤點缺少的快取
|
|
|
|
|
|
containersArray.forEach(container => {
|
|
|
|
|
|
const rawPath = container.getAttribute('data-path');
|
|
|
|
|
|
const origVal = container.getAttribute('data-original');
|
|
|
|
|
|
let cacheKey = rawPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
|
|
|
|
|
if (rawPath.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
|
|
|
|
|
|
|
|
|
|
|
|
if (!optData[cacheKey]) pathsToSync.push(cacheKey);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 🌟 第二階段:如果有缺,觸發同步並「等待」
|
|
|
|
|
|
if (pathsToSync.length > 0) {
|
|
|
|
|
|
// 先讓所有欄位顯示載入中
|
|
|
|
|
|
containersArray.forEach(c => c.innerHTML = `<span style="font-size: 12px; color: #f39c12; font-weight: bold;">⏳ 載入選項中...</span>`);
|
|
|
|
|
|
|
|
|
|
|
|
apiSyncLeafOptions(host, pathsToSync);
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
|
|
|
|
if (!activeEditSessions.has(elementId)) return;
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
|
|
|
|
optData = await apiGetLeafOptions(host);
|
|
|
|
|
|
|
|
|
|
|
|
// 檢查是否至少抓到一部分了 (只要有 hint 或 options 就當作抓到了)
|
|
|
|
|
|
const isMakingProgress = pathsToSync.some(p => optData[p] && (optData[p].hint || (optData[p].options && optData[p].options.length > 0)));
|
|
|
|
|
|
if (isMakingProgress && i > 2) break; // 給它至少 3 秒,有進度就放行,避免死等
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!activeEditSessions.has(elementId)) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 🌟 第三階段:正式渲染輸入框與 ❓
|
|
|
|
|
|
const CHUNK_SIZE = 50;
|
2026-05-19 10:25:10 +00:00
|
|
|
|
for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) {
|
2026-06-02 09:35:33 +00:00
|
|
|
|
if (!activeEditSessions.has(elementId)) return;
|
2026-05-19 10:25:10 +00:00
|
|
|
|
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);
|
2026-05-13 07:05:39 +00:00
|
|
|
|
|
2026-05-19 10:25:10 +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-13 07:05:39 +00:00
|
|
|
|
|
2026-05-19 10:25:10 +00:00
|
|
|
|
let inputHtml = "";
|
|
|
|
|
|
let hintAttr = "";
|
|
|
|
|
|
let hintIcon = "";
|
2026-05-13 07:05:39 +00:00
|
|
|
|
|
2026-05-19 10:25:10 +00:00
|
|
|
|
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>`;
|
|
|
|
|
|
}
|
2026-05-13 07:05:39 +00:00
|
|
|
|
|
2026-05-19 10:25:10 +00:00
|
|
|
|
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;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
2026-05-13 07:05:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
2026-06-02 09:35:33 +00:00
|
|
|
|
activeEditSessions.delete(elementId);
|
|
|
|
|
|
if (editBtn) {
|
|
|
|
|
|
editBtn.style.pointerEvents = 'auto';
|
|
|
|
|
|
editBtn.innerText = "✏️";
|
|
|
|
|
|
}
|
2026-05-13 07:05:39 +00:00
|
|
|
|
alert("❌ 無法連線到鎖定伺服器:" + error.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function startEditLeaf(path, elementId) {
|
2026-06-02 09:35:33 +00:00
|
|
|
|
if (activeEditSessions.has(elementId)) return;
|
|
|
|
|
|
activeEditSessions.add(elementId);
|
|
|
|
|
|
|
2026-05-13 07:05:39 +00:00
|
|
|
|
const connInfo = getGlobalConnectionInfo();
|
|
|
|
|
|
const username = connInfo ? connInfo.user : 'admin';
|
2026-05-19 10:25:10 +00:00
|
|
|
|
const host = connInfo ? connInfo.host : '';
|
|
|
|
|
|
const lockKey = `${host}@@${path}`;
|
|
|
|
|
|
|
2026-06-02 09:35:33 +00:00
|
|
|
|
const editBtn = document.getElementById(`edit-btn-${elementId}`);
|
|
|
|
|
|
if (editBtn) {
|
|
|
|
|
|
editBtn.style.pointerEvents = 'none';
|
|
|
|
|
|
editBtn.innerText = "⏳";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 07:05:39 +00:00
|
|
|
|
try {
|
2026-05-19 10:25:10 +00:00
|
|
|
|
const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
|
2026-06-02 09:35:33 +00:00
|
|
|
|
|
|
|
|
|
|
if (!activeEditSessions.has(elementId)) {
|
|
|
|
|
|
if (result.status === 200 || (result.data && result.data.status === 'success')) {
|
|
|
|
|
|
if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
|
|
|
|
|
|
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
|
|
|
|
|
|
apiReleaseLock(path, SESSION_USER_ID, host);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (editBtn) {
|
|
|
|
|
|
editBtn.style.pointerEvents = 'auto';
|
|
|
|
|
|
editBtn.style.opacity = '0.3';
|
|
|
|
|
|
editBtn.title = "鎖定並編輯此項目";
|
|
|
|
|
|
editBtn.innerText = "✏️";
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (result.status === 409) {
|
|
|
|
|
|
activeEditSessions.delete(elementId);
|
|
|
|
|
|
if (editBtn) {
|
|
|
|
|
|
editBtn.style.pointerEvents = 'auto';
|
|
|
|
|
|
editBtn.innerText = "✏️";
|
|
|
|
|
|
}
|
|
|
|
|
|
return alert(`⚠️ ${result.data.detail}`);
|
|
|
|
|
|
}
|
2026-05-13 07:05:39 +00:00
|
|
|
|
|
|
|
|
|
|
if (result.data.status === 'success') {
|
2026-05-19 10:25:10 +00:00
|
|
|
|
if (ACTIVE_HEARTBEATS[lockKey]) clearInterval(ACTIVE_HEARTBEATS[lockKey]);
|
|
|
|
|
|
let intervalId;
|
|
|
|
|
|
intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000);
|
|
|
|
|
|
ACTIVE_HEARTBEATS[lockKey] = intervalId;
|
2026-05-13 07:05:39 +00:00
|
|
|
|
|
2026-06-01 08:20:34 +00:00
|
|
|
|
if (!window.globalActiveLocks) window.globalActiveLocks = {};
|
|
|
|
|
|
if (!window.globalActiveLocks[host]) window.globalActiveLocks[host] = {};
|
|
|
|
|
|
window.globalActiveLocks[host][path] = { user_id: SESSION_USER_ID, username: username };
|
|
|
|
|
|
|
|
|
|
|
|
if (window.recentlyReleasedLocks) {
|
|
|
|
|
|
delete window.recentlyReleasedLocks[`${host}@@${path}`];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
|
|
|
|
document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`).forEach(btn => {
|
|
|
|
|
|
if (btn.id !== `edit-btn-${elementId}`) {
|
|
|
|
|
|
btn.style.pointerEvents = 'none';
|
|
|
|
|
|
btn.style.opacity = '0.2';
|
|
|
|
|
|
btn.title = `🔒 您正在另一個視圖編輯此項目`;
|
|
|
|
|
|
btn.innerText = "🔒";
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-02 09:35:33 +00:00
|
|
|
|
if (editBtn) editBtn.style.display = 'none';
|
2026-05-13 07:05:39 +00:00
|
|
|
|
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
|
|
|
|
const safeOrigVal = escapeHTML(origVal);
|
|
|
|
|
|
|
2026-06-03 09:24:08 +00:00
|
|
|
|
container.innerHTML = `<span style="font-size: 12px; color: #f39c12; margin-left: 5px; font-weight: bold;">⏳ 設備連線與載入選項中...</span>`;
|
2026-05-13 07:05:39 +00:00
|
|
|
|
|
|
|
|
|
|
try {
|
2026-06-10 06:37:16 +00:00
|
|
|
|
let optData = await apiGetLeafOptions(host);
|
2026-05-13 07:05:39 +00:00
|
|
|
|
let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
2026-05-19 10:25:10 +00:00
|
|
|
|
if (path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
|
2026-05-13 07:05:39 +00:00
|
|
|
|
let leafCache = optData[cacheKey];
|
|
|
|
|
|
|
2026-05-15 07:17:28 +00:00
|
|
|
|
if (!leafCache) {
|
2026-06-10 06:37:16 +00:00
|
|
|
|
apiSyncLeafOptions(host, [cacheKey]);
|
2026-05-13 07:05:39 +00:00
|
|
|
|
let found = false;
|
|
|
|
|
|
for (let i = 0; i < 10; i++) {
|
2026-06-10 06:37:16 +00:00
|
|
|
|
if (!activeEditSessions.has(elementId)) return;
|
2026-05-13 07:05:39 +00:00
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
2026-06-10 06:37:16 +00:00
|
|
|
|
optData = await apiGetLeafOptions(host);
|
2026-05-13 07:05:39 +00:00
|
|
|
|
leafCache = optData[cacheKey];
|
2026-06-08 09:01:55 +00:00
|
|
|
|
|
|
|
|
|
|
if (leafCache && (leafCache.hint || (leafCache.options && leafCache.options.length > 0))) {
|
2026-05-13 07:05:39 +00:00
|
|
|
|
found = true; break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-10 06:37:16 +00:00
|
|
|
|
if (!activeEditSessions.has(elementId)) return;
|
2026-06-02 09:35:33 +00:00
|
|
|
|
|
2026-05-13 07:05:39 +00:00
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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) {
|
2026-06-10 06:37:16 +00:00
|
|
|
|
if (!activeEditSessions.has(elementId)) return;
|
2026-05-13 07:43:42 +00:00
|
|
|
|
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) {
|
2026-06-02 09:35:33 +00:00
|
|
|
|
activeEditSessions.delete(elementId);
|
|
|
|
|
|
if (editBtn) {
|
|
|
|
|
|
editBtn.style.pointerEvents = 'auto';
|
|
|
|
|
|
editBtn.innerText = "✏️";
|
|
|
|
|
|
}
|
2026-05-13 07:05:39 +00:00
|
|
|
|
alert("❌ 無法連線到鎖定伺服器:" + error.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function cancelEditFolder(path, elementId) {
|
2026-06-02 09:35:33 +00:00
|
|
|
|
activeEditSessions.delete(elementId); // 🚨 防護:註銷編輯狀態,強制中斷背景渲染迴圈
|
|
|
|
|
|
|
2026-05-19 10:25:10 +00:00
|
|
|
|
const connInfo = getGlobalConnectionInfo();
|
|
|
|
|
|
const host = connInfo ? connInfo.host : '';
|
|
|
|
|
|
const lockKey = `${host}@@${path}`;
|
|
|
|
|
|
|
|
|
|
|
|
if (ACTIVE_HEARTBEATS[lockKey]) {
|
|
|
|
|
|
clearInterval(ACTIVE_HEARTBEATS[lockKey]);
|
|
|
|
|
|
delete ACTIVE_HEARTBEATS[lockKey];
|
2026-05-13 07:05:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-02 09:35:33 +00:00
|
|
|
|
// 🌟 關鍵修復:寫入防閃爍冷卻表必須在 await 之前!防止 API 延遲期間被輪詢重新上鎖
|
2026-06-01 08:20:34 +00:00
|
|
|
|
if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
|
|
|
|
|
|
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
|
|
|
|
|
|
|
2026-06-02 09:35:33 +00:00
|
|
|
|
try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {}
|
|
|
|
|
|
|
2026-06-01 08:20:34 +00:00
|
|
|
|
// 🌟 立即解除全域鎖定狀態 (加入 Host 隔離)
|
|
|
|
|
|
if (window.globalActiveLocks && window.globalActiveLocks[host] && window.globalActiveLocks[host][path]) {
|
|
|
|
|
|
delete window.globalActiveLocks[host][path];
|
|
|
|
|
|
}
|
|
|
|
|
|
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
2026-06-02 09:35:33 +00:00
|
|
|
|
// 🌟 擴大選擇器:連同所有子節點一起秒級解除鎖定
|
|
|
|
|
|
document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`).forEach(btn => {
|
2026-06-01 08:20:34 +00:00
|
|
|
|
if (btn.id !== `edit-btn-${elementId}`) {
|
2026-06-02 09:35:33 +00:00
|
|
|
|
const btnPath = btn.getAttribute('data-path');
|
|
|
|
|
|
let stillLockedByOther = false;
|
|
|
|
|
|
|
|
|
|
|
|
// 確保解除鎖定時,不會誤解開被其他父節點鎖定的子節點
|
|
|
|
|
|
if (window.globalActiveLocks && window.globalActiveLocks[host]) {
|
|
|
|
|
|
const hostLocks = window.globalActiveLocks[host];
|
|
|
|
|
|
if (hostLocks[btnPath]) {
|
|
|
|
|
|
stillLockedByOther = true;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
for (const lockedPath of Object.keys(hostLocks)) {
|
|
|
|
|
|
if (btnPath.startsWith(lockedPath + "::")) {
|
|
|
|
|
|
stillLockedByOther = true;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!stillLockedByOther) {
|
|
|
|
|
|
btn.style.pointerEvents = 'auto';
|
|
|
|
|
|
btn.style.opacity = '0.3';
|
|
|
|
|
|
btn.title = "鎖定並編輯此項目";
|
|
|
|
|
|
btn.innerText = "✏️";
|
|
|
|
|
|
}
|
2026-06-01 08:20:34 +00:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-02 09:35:33 +00:00
|
|
|
|
const editBtn = document.getElementById(`edit-btn-${elementId}`);
|
|
|
|
|
|
if (editBtn) {
|
|
|
|
|
|
editBtn.style.display = 'inline-block';
|
|
|
|
|
|
editBtn.style.pointerEvents = 'auto';
|
|
|
|
|
|
editBtn.style.opacity = '0.3';
|
|
|
|
|
|
editBtn.title = "鎖定並編輯此項目";
|
|
|
|
|
|
editBtn.innerText = "✏️"; // 🌟 確保沙漏被清掉
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const actionBtns = document.getElementById(`actions-${elementId}`);
|
|
|
|
|
|
if (actionBtns) actionBtns.style.display = 'none';
|
2026-05-13 07:05:39 +00:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
});
|
2026-06-01 03:39:58 +00:00
|
|
|
|
|
|
|
|
|
|
// 🌟 聯動防呆機制:如果右側預覽視窗正在顯示這個節點的指令,連帶關閉它,防止無鎖寫入
|
|
|
|
|
|
if (currentEditElementId === elementId) {
|
|
|
|
|
|
document.querySelectorAll('.tree-view-instance').forEach(pane => {
|
|
|
|
|
|
pane.style.flex = '1';
|
|
|
|
|
|
pane.style.width = '100%';
|
|
|
|
|
|
pane.style.maxWidth = 'none';
|
|
|
|
|
|
});
|
|
|
|
|
|
const rightPane = document.getElementById('side-cli-preview');
|
|
|
|
|
|
if (rightPane) {
|
|
|
|
|
|
rightPane.style.flex = '1';
|
|
|
|
|
|
rightPane.style.maxWidth = '50%';
|
|
|
|
|
|
rightPane.style.width = '';
|
|
|
|
|
|
rightPane.style.display = 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
document.getElementById('drag-resizer').style.display = 'none';
|
|
|
|
|
|
|
|
|
|
|
|
currentEditPath = null;
|
|
|
|
|
|
currentEditElementId = null;
|
|
|
|
|
|
currentEditIsSuccess = false;
|
|
|
|
|
|
currentEditType = null;
|
|
|
|
|
|
}
|
2026-05-13 07:05:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function cancelEditLeaf(path, elementId) {
|
2026-06-02 09:35:33 +00:00
|
|
|
|
activeEditSessions.delete(elementId); // 🚨 防護:註銷編輯狀態,強制中斷背景渲染迴圈
|
|
|
|
|
|
|
2026-05-19 10:25:10 +00:00
|
|
|
|
const connInfo = getGlobalConnectionInfo();
|
|
|
|
|
|
const host = connInfo ? connInfo.host : '';
|
|
|
|
|
|
const lockKey = `${host}@@${path}`;
|
|
|
|
|
|
|
|
|
|
|
|
if (ACTIVE_HEARTBEATS[lockKey]) {
|
|
|
|
|
|
clearInterval(ACTIVE_HEARTBEATS[lockKey]);
|
|
|
|
|
|
delete ACTIVE_HEARTBEATS[lockKey];
|
2026-05-13 07:05:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-02 09:35:33 +00:00
|
|
|
|
// 🌟 關鍵修復:寫入防閃爍冷卻表必須在 await 之前!防止 API 延遲期間被輪詢重新上鎖
|
2026-06-01 08:20:34 +00:00
|
|
|
|
if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
|
|
|
|
|
|
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
|
|
|
|
|
|
|
2026-06-02 09:35:33 +00:00
|
|
|
|
try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {}
|
|
|
|
|
|
|
2026-06-01 08:20:34 +00:00
|
|
|
|
// 🌟 立即解除全域鎖定狀態 (加入 Host 隔離)
|
|
|
|
|
|
if (window.globalActiveLocks && window.globalActiveLocks[host] && window.globalActiveLocks[host][path]) {
|
|
|
|
|
|
delete window.globalActiveLocks[host][path];
|
|
|
|
|
|
}
|
|
|
|
|
|
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
|
|
|
|
document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`).forEach(btn => {
|
|
|
|
|
|
if (btn.id !== `edit-btn-${elementId}`) {
|
|
|
|
|
|
btn.style.pointerEvents = 'auto';
|
|
|
|
|
|
btn.style.opacity = '0.3';
|
|
|
|
|
|
btn.title = "鎖定並編輯此項目";
|
|
|
|
|
|
btn.innerText = "✏️";
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-02 09:35:33 +00:00
|
|
|
|
const editBtn = document.getElementById(`edit-btn-${elementId}`);
|
|
|
|
|
|
if (editBtn) {
|
|
|
|
|
|
editBtn.style.display = 'inline-block';
|
|
|
|
|
|
editBtn.style.pointerEvents = 'auto';
|
|
|
|
|
|
editBtn.style.opacity = '0.3';
|
|
|
|
|
|
editBtn.title = "鎖定並編輯此項目";
|
|
|
|
|
|
editBtn.innerText = "✏️"; // 🌟 確保沙漏被清掉
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const actionBtns = document.getElementById(`actions-${elementId}`);
|
|
|
|
|
|
if (actionBtns) actionBtns.style.display = 'none';
|
2026-05-13 07:05:39 +00:00
|
|
|
|
|
|
|
|
|
|
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-06-01 03:39:58 +00:00
|
|
|
|
|
|
|
|
|
|
// 🌟 聯動防呆機制:如果右側預覽視窗正在顯示這個節點的指令,連帶關閉它,防止無鎖寫入
|
|
|
|
|
|
if (currentEditElementId === elementId) {
|
|
|
|
|
|
document.querySelectorAll('.tree-view-instance').forEach(pane => {
|
|
|
|
|
|
pane.style.flex = '1';
|
|
|
|
|
|
pane.style.width = '100%';
|
|
|
|
|
|
pane.style.maxWidth = 'none';
|
|
|
|
|
|
});
|
|
|
|
|
|
const rightPane = document.getElementById('side-cli-preview');
|
|
|
|
|
|
if (rightPane) {
|
|
|
|
|
|
rightPane.style.flex = '1';
|
|
|
|
|
|
rightPane.style.maxWidth = '50%';
|
|
|
|
|
|
rightPane.style.width = '';
|
|
|
|
|
|
rightPane.style.display = 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
document.getElementById('drag-resizer').style.display = 'none';
|
|
|
|
|
|
|
|
|
|
|
|
currentEditPath = null;
|
|
|
|
|
|
currentEditElementId = null;
|
|
|
|
|
|
currentEditIsSuccess = false;
|
|
|
|
|
|
currentEditType = null;
|
|
|
|
|
|
}
|
2026-05-13 07:05:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
// 3. CLI 生成與預覽 (CLI Generation)
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
function getInterfacesWithAdminState() {
|
2026-05-19 10:25:10 +00:00
|
|
|
|
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');
|
2026-05-13 07:05:39 +00:00
|
|
|
|
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-28 02:24:17 +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-28 02:24:17 +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) {
|
2026-05-19 10:25:10 +00:00
|
|
|
|
const leftPanes = document.querySelectorAll('.tree-view-instance');
|
2026-05-13 07:05:39 +00:00
|
|
|
|
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';
|
2026-05-22 10:34:19 +00:00
|
|
|
|
document.getElementById('btn-side-close').textContent = '關閉';
|
2026-05-13 07:05:39 +00:00
|
|
|
|
document.getElementById('side-cli-textarea').style.display = 'block';
|
|
|
|
|
|
document.getElementById('side-execution-result').style.display = 'none';
|
|
|
|
|
|
|
2026-05-22 10:34:19 +00:00
|
|
|
|
const textarea = document.getElementById('side-cli-textarea');
|
|
|
|
|
|
textarea.value = cliScript;
|
|
|
|
|
|
|
|
|
|
|
|
textarea.readOnly = false;
|
2026-05-28 02:24:17 +00:00
|
|
|
|
textarea.style.backgroundColor = '#1e1e1e';
|
|
|
|
|
|
textarea.style.color = '#ecf0f1';
|
2026-05-13 07:05:39 +00:00
|
|
|
|
|
2026-06-01 03:39:58 +00:00
|
|
|
|
// 🛡️ 安全修改:解除 Flexbox 均分限制,讓 JS 拖拉的 width 生效
|
|
|
|
|
|
leftPanes.forEach(pane => {
|
|
|
|
|
|
pane.style.flex = 'none';
|
|
|
|
|
|
pane.style.maxWidth = 'none';
|
|
|
|
|
|
pane.style.width = 'calc(50% - 11px)';
|
|
|
|
|
|
});
|
|
|
|
|
|
rightPane.style.flex = 'none';
|
|
|
|
|
|
rightPane.style.maxWidth = 'none';
|
2026-05-13 07:05:39 +00:00
|
|
|
|
rightPane.style.width = 'calc(50% - 11px)';
|
2026-06-01 03:39:58 +00:00
|
|
|
|
|
2026-05-13 07:05:39 +00:00
|
|
|
|
resizer.style.display = 'flex';
|
|
|
|
|
|
rightPane.style.display = 'block';
|
|
|
|
|
|
|
|
|
|
|
|
if (!window.isResizerInitialized) {
|
|
|
|
|
|
initResizer();
|
|
|
|
|
|
window.isResizerInitialized = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
// 4. 側邊欄與執行邏輯
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
export async function hideSideCLI() {
|
2026-06-01 03:39:58 +00:00
|
|
|
|
// 🌟 完美修復:明確恢復 Flexbox 伸展能力與 100% 寬度
|
|
|
|
|
|
document.querySelectorAll('.tree-view-instance').forEach(pane => {
|
|
|
|
|
|
pane.style.flex = '1'; // 恢復 HTML 原本賦予的彈性伸展能力
|
|
|
|
|
|
pane.style.width = '100%'; // 強制撐滿整個父容器
|
|
|
|
|
|
pane.style.maxWidth = 'none';
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const rightPane = document.getElementById('side-cli-preview');
|
|
|
|
|
|
if (rightPane) {
|
|
|
|
|
|
rightPane.style.flex = '1';
|
|
|
|
|
|
rightPane.style.maxWidth = '50%'; // 恢復 HTML 原本的 50% 限制
|
|
|
|
|
|
rightPane.style.width = '';
|
|
|
|
|
|
rightPane.style.display = 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 07:05:39 +00:00
|
|
|
|
document.getElementById('drag-resizer').style.display = 'none';
|
|
|
|
|
|
|
2026-06-01 03:39:58 +00:00
|
|
|
|
// 以下為原本的取消編輯與還原邏輯,完全保持不變,確保功能安全
|
2026-05-13 07:05:39 +00:00
|
|
|
|
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 = '⏳ 正在寫入設備...';
|
2026-06-03 09:24:08 +00:00
|
|
|
|
document.getElementById('side-pane-title').style.color = '#f39c12'; // 🌟 統一載入中狀態為橘色 (原為 #3498db)
|
2026-05-13 07:05:39 +00:00
|
|
|
|
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');
|
2026-05-29 08:21:43 +00:00
|
|
|
|
|
2026-05-29 16:42:13 +00:00
|
|
|
|
// 1. 建立即時 Log 視窗 UI (無邊界滿版融合風格)
|
2026-05-29 08:21:43 +00:00
|
|
|
|
outputEl.innerHTML = `
|
2026-05-29 16:42:13 +00:00
|
|
|
|
<div id="edit-log-container" style="width: 100%; height: 100%; background: transparent; border: none; padding: 0; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; color: #ecf0f1; font-size: 14px; line-height: 1.5; text-align: left; margin: 0; box-sizing: border-box;">
|
|
|
|
|
|
<div style="color: #f39c12; font-weight: bold; margin-bottom: 8px;">[系統] ⏳ 正在透過 SSH 寫入指令,請勿關閉視窗...</div>
|
2026-05-29 08:21:43 +00:00
|
|
|
|
<div style="color: #7f8c8d;">[系統] 準備連線至設備 ${connInfo.host}...</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
const logContainer = document.getElementById('edit-log-container');
|
|
|
|
|
|
|
|
|
|
|
|
// 🌟 修正 1: 捲動目標必須是真正有捲軸的容器
|
|
|
|
|
|
const scrollTarget = document.getElementById('side-execution-result');
|
2026-05-13 07:05:39 +00:00
|
|
|
|
|
|
|
|
|
|
try {
|
2026-05-29 08:21:43 +00:00
|
|
|
|
const response = await fetch('/api/v1/cmts-config', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
script: script,
|
|
|
|
|
|
host: connInfo.host,
|
|
|
|
|
|
username: connInfo.user,
|
|
|
|
|
|
password: connInfo.pass
|
|
|
|
|
|
})
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const reader = response.body.getReader();
|
|
|
|
|
|
const decoder = new TextDecoder("utf-8");
|
|
|
|
|
|
let buffer = "";
|
|
|
|
|
|
let isSuccess = false;
|
|
|
|
|
|
|
|
|
|
|
|
while (true) {
|
|
|
|
|
|
const { done, value } = await reader.read();
|
|
|
|
|
|
if (done) break;
|
|
|
|
|
|
|
|
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
|
|
let lines = buffer.split('\n');
|
|
|
|
|
|
buffer = lines.pop();
|
|
|
|
|
|
|
|
|
|
|
|
for (let line of lines) {
|
|
|
|
|
|
if (!line.trim()) continue;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = JSON.parse(line);
|
|
|
|
|
|
const timeStr = new Date().toLocaleTimeString('en-US', { hour12: false });
|
|
|
|
|
|
|
|
|
|
|
|
if (data.status === 'progress') {
|
|
|
|
|
|
logContainer.innerHTML += `<div style="margin-top: 4px;"><span style="color: #3498db;">[${timeStr}]</span> ${data.message}</div>`;
|
|
|
|
|
|
} else if (data.status === 'success') {
|
|
|
|
|
|
isSuccess = true;
|
|
|
|
|
|
logContainer.innerHTML += `
|
|
|
|
|
|
<div style="margin-top: 15px; color: #2ecc71; font-weight: bold; font-size: 14px;">[${timeStr}] ${data.message}</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
document.getElementById('side-pane-title').innerHTML = '✅ 寫入成功!正在同步快取...';
|
|
|
|
|
|
document.getElementById('side-pane-title').style.color = '#f39c12';
|
|
|
|
|
|
} else if (data.status === 'error') {
|
|
|
|
|
|
logContainer.innerHTML += `<div style="margin-top: 10px; color: #e74c3c; font-weight: bold;">[${timeStr}] ${data.message}</div>`;
|
|
|
|
|
|
if (data.output) {
|
|
|
|
|
|
logContainer.innerHTML += `<div style="color: #c0392b; margin-left: 10px; border-left: 2px solid #c0392b; padding-left: 8px;">設備回傳: ${data.output}</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
document.getElementById('btn-side-close').style.display = 'inline-block';
|
|
|
|
|
|
document.getElementById('side-pane-title').innerHTML = '❌ 執行失敗';
|
|
|
|
|
|
document.getElementById('side-pane-title').style.color = '#ff6b6b';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 自動捲動到最底部
|
|
|
|
|
|
if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.warn("解析串流 JSON 失敗:", line);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 4. 串流結束後,執行快取同步
|
|
|
|
|
|
if (isSuccess) {
|
2026-05-13 07:05:39 +00:00
|
|
|
|
currentEditIsSuccess = true;
|
2026-05-14 08:30:10 +00:00
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-05-19 10:25:10 +00:00
|
|
|
|
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
|
2026-05-29 08:21:43 +00:00
|
|
|
|
let isSyncDone = false;
|
2026-05-28 02:24:17 +00:00
|
|
|
|
|
2026-05-29 08:21:43 +00:00
|
|
|
|
// 🌟 修正 2: 先啟動 SSE 監聽 (避免錯過 done 事件)
|
|
|
|
|
|
const streamPromise = apiSyncLeafOptionsStream(connInfo.host, uniquePaths, (data) => {
|
|
|
|
|
|
const titleEl = document.getElementById('side-pane-title');
|
2026-05-28 02:24:17 +00:00
|
|
|
|
|
2026-05-29 08:21:43 +00:00
|
|
|
|
if (data.event === 'done' || data.status === 'done') {
|
|
|
|
|
|
isSyncDone = true;
|
|
|
|
|
|
if (titleEl) {
|
|
|
|
|
|
titleEl.innerHTML = '✅ 寫入成功!快取同步完成';
|
|
|
|
|
|
titleEl.style.color = '#2ecc71';
|
|
|
|
|
|
}
|
2026-05-19 10:25:10 +00:00
|
|
|
|
document.getElementById('btn-side-close').style.display = 'inline-block';
|
2026-05-29 08:21:43 +00:00
|
|
|
|
logContainer.innerHTML += `<br><span style="color: #2ecc71; font-weight: bold;">[系統] 快取同步完成!您可以關閉此面板。</span>`;
|
|
|
|
|
|
if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight;
|
|
|
|
|
|
|
|
|
|
|
|
} else if (data.event === 'error' || data.status === 'error') {
|
|
|
|
|
|
isSyncDone = true;
|
|
|
|
|
|
if (titleEl) {
|
|
|
|
|
|
titleEl.innerHTML = '⚠️ 寫入成功 (但同步快取失敗)';
|
|
|
|
|
|
titleEl.style.color = '#e74c3c';
|
|
|
|
|
|
}
|
2026-05-19 10:25:10 +00:00
|
|
|
|
document.getElementById('btn-side-close').style.display = 'inline-block';
|
2026-05-29 08:21:43 +00:00
|
|
|
|
logContainer.innerHTML += `<br><span style="color: #e74c3c; font-weight: bold;">[系統] 同步失敗: ${data.message || '未知錯誤'}</span>`;
|
|
|
|
|
|
if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight;
|
2026-05-14 08:30:10 +00:00
|
|
|
|
}
|
2026-06-10 06:37:16 +00:00
|
|
|
|
});
|
2026-05-28 02:24:17 +00:00
|
|
|
|
|
2026-05-29 08:21:43 +00:00
|
|
|
|
// 🌟 修正 3: 再觸發後端同步任務
|
|
|
|
|
|
apiSyncLeafOptions(connInfo.host, uniquePaths, currentMode, connInfo.user, connInfo.pass);
|
|
|
|
|
|
|
|
|
|
|
|
// 🌟 修正 4: 加入 8 秒防呆機制,如果後端默默做完沒通知,自動放行
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
if (!isSyncDone) {
|
|
|
|
|
|
const titleEl = document.getElementById('side-pane-title');
|
|
|
|
|
|
if (titleEl) {
|
|
|
|
|
|
titleEl.innerHTML = '✅ 寫入成功!(快取同步已於背景完成)';
|
|
|
|
|
|
titleEl.style.color = '#2ecc71';
|
|
|
|
|
|
}
|
|
|
|
|
|
document.getElementById('btn-side-close').style.display = 'inline-block';
|
|
|
|
|
|
logContainer.innerHTML += `<br><span style="color: #f39c12; font-weight: bold;">[系統] 快取同步已於背景執行,您可以關閉此面板。</span>`;
|
|
|
|
|
|
if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight;
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 8000);
|
|
|
|
|
|
|
|
|
|
|
|
await streamPromise;
|
|
|
|
|
|
|
2026-05-14 08:30:10 +00:00
|
|
|
|
} catch (syncErr) {
|
2026-05-28 02:24:17 +00:00
|
|
|
|
console.error("同步設定失敗:", syncErr);
|
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
|
|
|
|
} else {
|
|
|
|
|
|
currentEditIsSuccess = false;
|
|
|
|
|
|
}
|
2026-05-29 08:21:43 +00:00
|
|
|
|
|
2026-05-13 07:05:39 +00:00
|
|
|
|
} 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';
|
2026-05-29 08:21:43 +00:00
|
|
|
|
logContainer.innerHTML += `<div style="color: #ff6b6b; font-weight: bold; margin-top: 10px;">連線或伺服器錯誤: ${error}</div>`;
|
2026-05-13 07:05:39 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
// 5. 輔助 UI 函數
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
function initResizer() {
|
|
|
|
|
|
const resizer = document.getElementById('drag-resizer');
|
2026-05-19 10:25:10 +00:00
|
|
|
|
const leftPanes = document.querySelectorAll('.tree-view-instance');
|
2026-05-13 07:05:39 +00:00
|
|
|
|
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;
|
2026-05-19 10:25:10 +00:00
|
|
|
|
leftPanes.forEach(pane => pane.style.width = `calc(${leftPercentage}% - 11px)`);
|
2026-05-13 07:05:39 +00:00
|
|
|
|
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 模式)
|
|
|
|
|
|
// ============================================================================
|
2026-05-22 10:34:19 +00:00
|
|
|
|
export async function previewNodeCLI(btnElement, rootPath) {
|
2026-05-15 09:25:09 +00:00
|
|
|
|
let container = btnElement.closest('details');
|
2026-05-22 10:34:19 +00:00
|
|
|
|
if (container) {
|
|
|
|
|
|
const elementId = container.id.replace('details-', '');
|
|
|
|
|
|
if (container.dataset.loaded !== 'true') {
|
|
|
|
|
|
await window.lazyLoadFolder(elementId, false);
|
|
|
|
|
|
}
|
2026-05-15 09:25:09 +00:00
|
|
|
|
}
|
2026-05-22 10:34:19 +00:00
|
|
|
|
const targetNode = container || btnElement.closest('.tree-leaf-node');
|
|
|
|
|
|
if (!targetNode) return;
|
2026-05-15 09:25:09 +00:00
|
|
|
|
|
2026-05-22 10:34:19 +00:00
|
|
|
|
const leafNodes = targetNode.querySelectorAll('.leaf-container');
|
|
|
|
|
|
const interfaceGroups = {};
|
2026-05-15 09:25:09 +00:00
|
|
|
|
|
2026-05-22 10:34:19 +00:00
|
|
|
|
leafNodes.forEach(node => {
|
|
|
|
|
|
const path = node.getAttribute('data-path') || "";
|
|
|
|
|
|
const val = node.getAttribute('data-original');
|
|
|
|
|
|
if (!path) return;
|
|
|
|
|
|
|
|
|
|
|
|
const parts = path.split('::');
|
|
|
|
|
|
const paramName = parts.pop();
|
|
|
|
|
|
let interfacePath = parts.join(' ').replace(/\s*\[\d+\]/g, '');
|
|
|
|
|
|
|
|
|
|
|
|
if (!interfaceGroups[interfacePath]) {
|
|
|
|
|
|
interfaceGroups[interfacePath] = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
interfaceGroups[interfacePath].push({ param: paramName, val: val });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (Object.keys(interfaceGroups).length === 0 && targetNode.classList.contains('tree-leaf-node')) {
|
|
|
|
|
|
const singleNode = targetNode.querySelector('.leaf-container');
|
|
|
|
|
|
if (singleNode) {
|
|
|
|
|
|
const path = singleNode.getAttribute('data-path');
|
|
|
|
|
|
const val = singleNode.getAttribute('data-original');
|
|
|
|
|
|
const parts = path.split('::');
|
|
|
|
|
|
const paramName = parts.pop();
|
|
|
|
|
|
let interfacePath = parts.join(' ').replace(/\s*\[\d+\]/g, '');
|
|
|
|
|
|
interfaceGroups[interfacePath] = [{ param: paramName, val: val }];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (Object.keys(interfaceGroups).length === 0) {
|
|
|
|
|
|
return alert("此節點下沒有任何數值可供預覽!");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let cliLines = [];
|
|
|
|
|
|
cliLines.push("! --- Read-Only Configuration Preview ---");
|
|
|
|
|
|
for (const [interfacePath, params] of Object.entries(interfaceGroups)) {
|
|
|
|
|
|
cliLines.push(`! Node: ${interfacePath || 'Global'}`);
|
|
|
|
|
|
params.forEach(({param, val}) => {
|
|
|
|
|
|
if (/^\[\d+\]$/.test(param)) {
|
|
|
|
|
|
if (val) {
|
|
|
|
|
|
cliLines.push(interfacePath ? `${interfacePath} ${val}` : val);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (param === 'no') {
|
|
|
|
|
|
cliLines.push(interfacePath ? `${interfacePath} no ${val}` : `no ${val}`);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
let line = interfacePath ? `${interfacePath} ${param}` : param;
|
|
|
|
|
|
if (val) line += ` ${val}`;
|
|
|
|
|
|
cliLines.push(line);
|
2026-05-15 09:25:09 +00:00
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-05-22 10:34:19 +00:00
|
|
|
|
cliLines.push("!");
|
2026-05-15 09:25:09 +00:00
|
|
|
|
}
|
2026-05-22 10:34:19 +00:00
|
|
|
|
const finalScript = cliLines.join('\n');
|
|
|
|
|
|
|
|
|
|
|
|
showReadOnlyCliPreviewModal(finalScript, rootPath);
|
|
|
|
|
|
}
|
2026-05-15 09:25:09 +00:00
|
|
|
|
|
2026-05-22 10:34:19 +00:00
|
|
|
|
function showReadOnlyCliPreviewModal(cliScript, rootPath) {
|
2026-05-19 10:25:10 +00:00
|
|
|
|
const leftPanes = document.querySelectorAll('.tree-view-instance');
|
2026-05-15 09:25:09 +00:00
|
|
|
|
const resizer = document.getElementById('drag-resizer');
|
|
|
|
|
|
const rightPane = document.getElementById('side-cli-preview');
|
|
|
|
|
|
|
2026-05-22 10:34:19 +00:00
|
|
|
|
document.getElementById('side-pane-title').innerHTML = `🔍 [唯讀預覽] ${rootPath}`;
|
2026-05-15 09:25:09 +00:00
|
|
|
|
document.getElementById('side-pane-title').style.color = '#3498db';
|
2026-05-22 10:34:19 +00:00
|
|
|
|
|
2026-05-15 09:25:09 +00:00
|
|
|
|
document.getElementById('btn-side-confirm').style.display = 'none';
|
2026-05-22 10:34:19 +00:00
|
|
|
|
document.getElementById('btn-side-cancel').style.display = 'none';
|
|
|
|
|
|
const closeBtn = document.getElementById('btn-side-close');
|
|
|
|
|
|
closeBtn.style.display = 'inline-block';
|
|
|
|
|
|
closeBtn.textContent = '關閉預覽';
|
|
|
|
|
|
|
2026-05-15 09:25:09 +00:00
|
|
|
|
const textarea = document.getElementById('side-cli-textarea');
|
|
|
|
|
|
textarea.style.display = 'block';
|
2026-05-22 10:34:19 +00:00
|
|
|
|
textarea.value = cliScript;
|
2026-05-28 02:24:17 +00:00
|
|
|
|
textarea.readOnly = true;
|
|
|
|
|
|
textarea.style.backgroundColor = '#2c3e50';
|
2026-05-22 10:34:19 +00:00
|
|
|
|
|
2026-05-15 09:25:09 +00:00
|
|
|
|
document.getElementById('side-execution-result').style.display = 'none';
|
2026-05-22 10:34:19 +00:00
|
|
|
|
|
2026-06-01 03:39:58 +00:00
|
|
|
|
// 🛡️ 安全修改:解除 Flexbox 均分限制,讓 JS 拖拉的 width 生效
|
|
|
|
|
|
leftPanes.forEach(pane => {
|
|
|
|
|
|
pane.style.flex = 'none';
|
|
|
|
|
|
pane.style.maxWidth = 'none';
|
|
|
|
|
|
pane.style.width = 'calc(50% - 11px)';
|
|
|
|
|
|
});
|
|
|
|
|
|
if (rightPane) {
|
|
|
|
|
|
rightPane.style.flex = 'none';
|
|
|
|
|
|
rightPane.style.maxWidth = 'none';
|
|
|
|
|
|
rightPane.style.width = 'calc(50% - 11px)';
|
|
|
|
|
|
rightPane.style.display = 'block';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 09:25:09 +00:00
|
|
|
|
if (resizer) resizer.style.display = 'flex';
|
|
|
|
|
|
|
|
|
|
|
|
if (!window.isResizerInitialized && typeof initResizer === 'function') {
|
|
|
|
|
|
initResizer();
|
|
|
|
|
|
window.isResizerInitialized = true;
|
|
|
|
|
|
}
|
2026-05-19 10:25:10 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- EOF (檔案結束) ---
|