scm-harmonic-cmts-admin/static/app.js

1250 lines
55 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* --- static/app.js --- */
// ==========================================
// 1. 全域變數與初始化
// ==========================================
let term, fitAddon, ws;
let currentMacDomainData = null;
let isConnected = false;
// 💡 新增:追蹤當前編輯狀態
let currentEditPath = null;
let currentEditElementId = null;
let currentEditIsSuccess = false;
window.onload = initTerminal;
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 sendHeartbeat(path) {
try {
await fetch('/api/v1/locks/heartbeat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, user_id: SESSION_USER_ID })
});
} catch (error) {}
}
// 🎨 輔助函數:解析終端機輸出,僅將錯誤/警告行標紅
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');
}
// ==========================================
// 💡 資料夾層級的編輯模式邏輯 (Node-Level Lock)
// ==========================================
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');
leafContainers.forEach(container => {
const origVal = container.getAttribute('data-original');
const nodePath = container.getAttribute('data-path');
container.innerHTML = `
<input type="text" class="edit-input" data-path="${nodePath}" 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>
`;
});
}
// 🚀 Phase 3: 收集修改並呼叫後端轉譯 API
async function previewFolderCLI(path, elementId) {
// 💡 記錄當前正在編輯的節點資訊
currentEditPath = path;
currentEditElementId = elementId;
currentEditIsSuccess = false; // 預設為尚未成功
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 })
});
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");
}
}
// 🚀 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) {
applyEditFolder(currentEditPath, currentEditElementId);
}
// 重置狀態,等待下一次編輯
currentEditPath = null;
currentEditElementId = null;
currentEditIsSuccess = false;
}
// 💡 新增:套用修改並解除鎖定
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);
}
// 🚀 執行側邊區塊的指令 (切換為「執行模式」)
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>`;
}
}
// ==========================================
// 💡 編輯模式 UI 切換邏輯 (保留舊版單一節點編輯用)
// ==========================================
function renderEditModeUI(path, currentValue, elementId) {
const container = document.getElementById(elementId);
if (!container) return;
const safeValue = currentValue.replace(/'/g, "&#39;");
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, "&#39;");
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(data, parentPath = "") {
if (typeof data !== 'object' || data === null) {
return `<span>${data}</span>`;
}
let html = '<div style="margin-left: 20px;">';
for (const key in data) {
const value = data[key];
const currentPath = parentPath ? `${parentPath}::${key}` : key;
if (typeof value === 'object' && value !== null && Object.keys(value).length > 0) {
// 📁 渲染資料夾 (加入編輯與操作按鈕)
const elementId = `folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
html += `
<!-- 💡 加上 ontoggle 事件,當展開/收合時自動切換 is-open class -->
<details id="details-${elementId}" ontoggle="this.querySelector('summary').classList.toggle('is-open', this.open)">
<!-- 💡 套用 tree-node-header 樣式,並加入 list-style: none 隱藏原生黑色小三角形 -->
<summary class="tree-node-header" style="font-weight: bold; color: #2c3e50; margin-top: 5px; list-style: none;">
<!-- 💡 替換為純 CSS 的旋轉箭頭與動態資料夾圖示 -->
<span class="tree-chevron"></span>
<span class="tree-folder-icon"></span>
<!-- 確保資料夾名稱佔據剩餘空間,讓後面的按鈕靠右或保持間距 -->
<span style="flex-grow: 1;">${key}</span>
<!-- ✏️ 資料夾專屬編輯按鈕 -->
<span id="edit-btn-${elementId}" onclick="event.stopPropagation(); event.preventDefault(); startEditFolder('${currentPath}', '${elementId}')"
style="margin-left: 10px; 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>
</summary>
<!-- 💡 包裝子內容,方便後續用 JS 抓取裡面的葉節點 -->
<div id="content-${elementId}">
${buildTree(value, currentPath)}
</div>
</details>
`;
} else {
// 📄 渲染檔案 (移除獨立按鈕,改為埋入 data 屬性供父節點控制)
const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;");
html += `
<div style="padding: 4px 0; color: #34495e; margin-left: 20px; border-left: 1px solid #ecf0f1; padding-left: 10px; display: flex; align-items: center;">
<span style="margin-right: 5px;">📄</span> <b>${key}</b>:
<span class="leaf-container" 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;">${value === null ? '' : value}</span>
</span>
</div>
`;
}
}
html += '</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);
} 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) {
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;">
<!-- 💡 加上 onclick 阻擋事件冒泡,避免點擊 Checkbox 時觸發資料夾展開/收合 -->
<input type="checkbox" value="${currentPath}" class="filter-checkbox" ${isChecked}
onclick="event.stopPropagation();" onchange="toggleChildCheckboxes(this)">
<span class="tree-chevron"></span>
<span class="tree-folder-icon"></span>
<span style="flex-grow: 1;">${key}</span>
</summary>
<!-- 💡 加入特定的 class 方便後續 JS 抓取子節點 -->
<div class="filter-children-container">
${buildRealFilterTree(value, currentPath, hiddenKeys)}
</div>
</details>
`;
} else {
const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;");
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)">
<span style="margin-right: 5px;">📄</span> <b>${key}</b>:
<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 = '';
}