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