refactor: 完成前端 ES6 模組化全面拆分與 app.js 瘦身
This commit is contained in:
parent
7592643e3e
commit
b7395a1763
|
|
@ -99094,5 +99094,94 @@
|
||||||
"type": "string_or_number",
|
"type": "string_or_number",
|
||||||
"current_value": "10",
|
"current_value": "10",
|
||||||
"updated_at": 1778582997
|
"updated_at": 1778582997
|
||||||
|
},
|
||||||
|
"logging evt 1024 description": {
|
||||||
|
"hint": "Description: Enter a meaningful description\nPossible completions:\n<string, min: 0 chars, max: 128 chars>[Cold Start]",
|
||||||
|
"options": [],
|
||||||
|
"type": "string_or_number",
|
||||||
|
"current_value": "Cold Start",
|
||||||
|
"updated_at": 1778643770
|
||||||
|
},
|
||||||
|
"logging evt 1024 name": {
|
||||||
|
"hint": "Description: Short Description\nPossible completions:\n<string, min: 0 chars, max: 64 chars>[cold-start]",
|
||||||
|
"options": [],
|
||||||
|
"type": "string_or_number",
|
||||||
|
"current_value": "cold-start",
|
||||||
|
"updated_at": 1778644476
|
||||||
|
},
|
||||||
|
"logging evt 1024 priority": {
|
||||||
|
"hint": "Description: Event Priority\nPossible completions:\n[notice] alert critical debug emergency error information notice warning",
|
||||||
|
"options": [
|
||||||
|
"alert",
|
||||||
|
"critical",
|
||||||
|
"debug",
|
||||||
|
"emergency",
|
||||||
|
"error",
|
||||||
|
"information",
|
||||||
|
"notice",
|
||||||
|
"warning"
|
||||||
|
],
|
||||||
|
"type": "list",
|
||||||
|
"current_value": "notice",
|
||||||
|
"updated_at": 1778644476
|
||||||
|
},
|
||||||
|
"logging evt 1024 active": {
|
||||||
|
"hint": "Description: Enable/Disable the event\nPossible completions:\n[enabled] disabled enabled",
|
||||||
|
"options": [
|
||||||
|
"disabled",
|
||||||
|
"enabled"
|
||||||
|
],
|
||||||
|
"type": "list",
|
||||||
|
"current_value": "enabled",
|
||||||
|
"updated_at": 1778644476
|
||||||
|
},
|
||||||
|
"logging evt 1024 destination": {
|
||||||
|
"hint": "Description: Event destination <c - console, s - syslog>\nPossible completions:\n<c(console), s(syslog)>[]",
|
||||||
|
"options": [],
|
||||||
|
"type": "string_or_number",
|
||||||
|
"current_value": null,
|
||||||
|
"updated_at": 1778644476
|
||||||
|
},
|
||||||
|
"radius-server timeout": {
|
||||||
|
"hint": "Possible completions:\n<unsignedInt, 1 .. 60>[5]",
|
||||||
|
"options": [],
|
||||||
|
"type": "string_or_number",
|
||||||
|
"current_value": "5",
|
||||||
|
"updated_at": 1778644492
|
||||||
|
},
|
||||||
|
"radius-server retransmit": {
|
||||||
|
"hint": "Possible completions:\n<unsignedInt>[3]",
|
||||||
|
"options": [],
|
||||||
|
"type": "string_or_number",
|
||||||
|
"current_value": "3",
|
||||||
|
"updated_at": 1778644492
|
||||||
|
},
|
||||||
|
"snmp-server view all": {
|
||||||
|
"hint": "Possible completions:\n<oid:string> 1 <cr>",
|
||||||
|
"options": [],
|
||||||
|
"type": "string_or_number",
|
||||||
|
"current_value": null,
|
||||||
|
"updated_at": 1778654743
|
||||||
|
},
|
||||||
|
"snmp-server group": {
|
||||||
|
"hint": "Possible completions:\n<name:string> private",
|
||||||
|
"options": [],
|
||||||
|
"type": "string_or_number",
|
||||||
|
"current_value": null,
|
||||||
|
"updated_at": 1778654796
|
||||||
|
},
|
||||||
|
"aaa": {
|
||||||
|
"hint": "Possible completions:\naccounting\nauthentication\nauthorization\nnew-model Set/Unset to enable/disable AAA",
|
||||||
|
"options": [],
|
||||||
|
"type": "string_or_number",
|
||||||
|
"current_value": null,
|
||||||
|
"updated_at": 1778654851
|
||||||
|
},
|
||||||
|
"cli prompt1": {
|
||||||
|
"hint": "Description: Prompt for operational mode\nPossible completions:\n<string, min: 0 chars, max: 256 chars>[\\u@SERCOMM-COS-02> ]",
|
||||||
|
"options": [],
|
||||||
|
"type": "string_or_number",
|
||||||
|
"current_value": "\\u@SERCOMM-COS-02>",
|
||||||
|
"updated_at": 1778654885
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
// --- static/api.js ---
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 1. 鎖定管理 (Lock Management)
|
||||||
|
// ==========================================
|
||||||
|
export async function apiAcquireLock(path, userId, username) {
|
||||||
|
const response = await fetch('/api/v1/locks/acquire', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path, user_id: userId, username })
|
||||||
|
});
|
||||||
|
return { status: response.status, data: await response.json() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiReleaseLock(path, userId) {
|
||||||
|
return fetch('/api/v1/locks/release', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path, user_id: userId })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiSendHeartbeat(path, userId) {
|
||||||
|
return fetch('/api/v1/locks/heartbeat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path, user_id: userId })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 2. 樹狀圖與選項快取 (Tree & Options)
|
||||||
|
// ==========================================
|
||||||
|
export async function apiGetFullConfig(host, username, password, skipFilter = false) {
|
||||||
|
let url = `/api/v1/cmts-full-config?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
|
||||||
|
if (skipFilter) url += '&skip_filter=true';
|
||||||
|
const response = await fetch(url);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiGetLeafOptions() {
|
||||||
|
const response = await fetch('/api/v1/cmts-leaf-options');
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiSyncLeafOptions(paths) {
|
||||||
|
return fetch('/api/v1/cmts-leaf-options/sync', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ leaf_paths: paths })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiClearCache(paths) {
|
||||||
|
const response = await fetch('/api/v1/clear_cache', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(paths)
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 3. 指令生成與執行 (CLI Generation & Execution)
|
||||||
|
// ==========================================
|
||||||
|
export async function apiGenerateCli(diffs, interfaces) {
|
||||||
|
const response = await fetch('/api/v1/cmts-generate-cli', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ diffs, interfaces_with_admin_state: interfaces })
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiExecuteConfig(script, host, username, password) {
|
||||||
|
const response = await fetch('/api/v1/cmts-config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ script, host, username, password })
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 4. 系統設定與過濾器 (System Settings)
|
||||||
|
// ==========================================
|
||||||
|
export async function apiGetTreeFilters() {
|
||||||
|
const response = await fetch('/api/v1/settings/tree-filters');
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiSaveTreeFilters(hiddenKeys) {
|
||||||
|
const response = await fetch('/api/v1/settings/tree-filters', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ hidden_keys: hiddenKeys })
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 5. MAC Domain 與綜合查詢 (MAC Domain & Query)
|
||||||
|
// ==========================================
|
||||||
|
export async function apiGetMacDomainList(host, username, password) {
|
||||||
|
const url = `/api/v1/cmts-mac-domain-list?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiGetMacDomainConfig(target, host, username, password) {
|
||||||
|
const url = `/api/v1/cmts-mac-domain-config?target=${encodeURIComponent(target)}&host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiExecuteQuery(queryType, target, host, username, password) {
|
||||||
|
const url = `/api/v1/cmts-query?query_type=${queryType}&target=${encodeURIComponent(target)}&host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
2050
static/app.js
2050
static/app.js
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,527 @@
|
||||||
|
// --- static/edit-mode.js ---
|
||||||
|
|
||||||
|
import {
|
||||||
|
apiAcquireLock, apiReleaseLock, apiSendHeartbeat,
|
||||||
|
apiGetLeafOptions, apiSyncLeafOptions, apiGenerateCli, apiExecuteConfig
|
||||||
|
} from './api.js';
|
||||||
|
import { getGlobalConnectionInfo } from './utils.js';
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 1. 全域狀態與鎖定管理 (Lock Management)
|
||||||
|
// ==========================================
|
||||||
|
const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9);
|
||||||
|
const ACTIVE_HEARTBEATS = {};
|
||||||
|
|
||||||
|
// 💡 追蹤當前編輯狀態
|
||||||
|
export let currentEditPath = null;
|
||||||
|
export let currentEditElementId = null;
|
||||||
|
export let currentEditIsSuccess = false;
|
||||||
|
export let currentEditType = null;
|
||||||
|
|
||||||
|
export async function releaseAllLocks() {
|
||||||
|
const pathsToRelease = Object.keys(ACTIVE_HEARTBEATS);
|
||||||
|
if (pathsToRelease.length === 0) return;
|
||||||
|
|
||||||
|
const releasePromises = pathsToRelease.map(path => {
|
||||||
|
clearInterval(ACTIVE_HEARTBEATS[path]);
|
||||||
|
delete ACTIVE_HEARTBEATS[path];
|
||||||
|
return apiReleaseLock(path, SESSION_USER_ID)
|
||||||
|
.catch(err => console.error(`釋放鎖定 ${path} 失敗:`, err));
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(releasePromises);
|
||||||
|
alert("您已閒置超過 5 分鐘,系統已自動釋放編輯鎖定並重新整理頁面。");
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendHeartbeat(path) {
|
||||||
|
try {
|
||||||
|
const response = await apiSendHeartbeat(path, SESSION_USER_ID);
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn(`心跳發送失敗 (${path}): 伺服器回傳 ${response.status}`);
|
||||||
|
if (response.status === 404) {
|
||||||
|
clearInterval(ACTIVE_HEARTBEATS[path]);
|
||||||
|
delete ACTIVE_HEARTBEATS[path];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`心跳請求發生錯誤 (${path}):`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 2. 編輯模式啟動與取消 (Folder & Leaf)
|
||||||
|
// ==========================================
|
||||||
|
export async function startEditFolder(path, elementId) {
|
||||||
|
const connInfo = getGlobalConnectionInfo();
|
||||||
|
const username = connInfo ? connInfo.user : 'admin';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiAcquireLock(path, SESSION_USER_ID, username);
|
||||||
|
if (result.status === 409) return alert(`⚠️ ${result.data.detail}`);
|
||||||
|
|
||||||
|
if (result.data.status === 'success') {
|
||||||
|
ACTIVE_HEARTBEATS[path] = setInterval(() => sendHeartbeat(path), 15000);
|
||||||
|
|
||||||
|
document.getElementById(`edit-btn-${elementId}`).style.display = 'none';
|
||||||
|
document.getElementById(`actions-${elementId}`).style.display = 'inline-block';
|
||||||
|
document.getElementById(`details-${elementId}`).open = true;
|
||||||
|
|
||||||
|
const contentDiv = document.getElementById(`content-${elementId}`);
|
||||||
|
const leafContainers = contentDiv.querySelectorAll('.leaf-container');
|
||||||
|
|
||||||
|
let optData = {};
|
||||||
|
try { optData = await apiGetLeafOptions(); } catch (e) { console.warn("無法取得選項快取", e); }
|
||||||
|
|
||||||
|
const pathsToSync = [];
|
||||||
|
const currentTime = Math.floor(Date.now() / 1000);
|
||||||
|
const CACHE_TTL = 86400 * 7;
|
||||||
|
|
||||||
|
leafContainers.forEach(container => {
|
||||||
|
const origVal = container.getAttribute('data-original');
|
||||||
|
const rawPath = container.getAttribute('data-path');
|
||||||
|
let cacheKey = rawPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
||||||
|
|
||||||
|
if (rawPath.endsWith('::no')) {
|
||||||
|
cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
let leafCache = optData[cacheKey];
|
||||||
|
|
||||||
|
if (!leafCache || (currentTime - leafCache.updated_at >= CACHE_TTL)) {
|
||||||
|
pathsToSync.push(cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
let inputHtml = "";
|
||||||
|
let hintAttr = "";
|
||||||
|
let hintIcon = "";
|
||||||
|
|
||||||
|
if (leafCache && leafCache.hint) {
|
||||||
|
const safeHint = leafCache.hint.replace(/"/g, '"');
|
||||||
|
hintAttr = `title="${safeHint}"`;
|
||||||
|
hintIcon = `<span style="cursor: help; margin-left: 6px; color: #e67e22; font-size: 14px;" title="${safeHint}">❓</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leafCache && leafCache.options && leafCache.options.length > 0) {
|
||||||
|
let options = leafCache.options;
|
||||||
|
if (origVal && !options.includes(origVal)) options = [origVal, ...options];
|
||||||
|
|
||||||
|
inputHtml = `<select class="edit-input" data-path="${rawPath}" ${hintAttr} style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; height: 26px; outline: none; margin-left: 5px; background-color: white; cursor: pointer;">`;
|
||||||
|
options.forEach(opt => {
|
||||||
|
const 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;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pathsToSync.length > 0) {
|
||||||
|
console.log(`🚀 將 ${pathsToSync.length} 個欄位送入背景排程抓取...`);
|
||||||
|
apiSyncLeafOptions(pathsToSync);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert("❌ 無法連線到鎖定伺服器:" + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startEditLeaf(path, elementId) {
|
||||||
|
const connInfo = getGlobalConnectionInfo();
|
||||||
|
const username = connInfo ? connInfo.user : 'admin';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiAcquireLock(path, SESSION_USER_ID, username);
|
||||||
|
if (result.status === 409) return alert(`⚠️ ${result.data.detail}`);
|
||||||
|
|
||||||
|
if (result.data.status === 'success') {
|
||||||
|
ACTIVE_HEARTBEATS[path] = setInterval(() => sendHeartbeat(path), 15000);
|
||||||
|
|
||||||
|
document.getElementById(`edit-btn-${elementId}`).style.display = 'none';
|
||||||
|
document.getElementById(`actions-${elementId}`).style.display = 'inline-block';
|
||||||
|
|
||||||
|
const container = document.getElementById(`container-${elementId}`);
|
||||||
|
const origVal = container.getAttribute('data-original');
|
||||||
|
container.innerHTML = `<span style="font-size: 12px; color: #e67e22; margin-left: 5px;">⏳ 設備連線與載入選項中...</span>`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let optData = await apiGetLeafOptions();
|
||||||
|
let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
||||||
|
|
||||||
|
if (path.endsWith('::no')) {
|
||||||
|
cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
let leafCache = optData[cacheKey];
|
||||||
|
const currentTime = Math.floor(Date.now() / 1000);
|
||||||
|
const CACHE_TTL = 86400 * 7;
|
||||||
|
|
||||||
|
if (!leafCache || (currentTime - leafCache.updated_at >= CACHE_TTL)) {
|
||||||
|
apiSyncLeafOptions([cacheKey]);
|
||||||
|
let found = false;
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
optData = await apiGetLeafOptions();
|
||||||
|
leafCache = optData[cacheKey];
|
||||||
|
if (leafCache && leafCache.options && leafCache.options.length > 0) {
|
||||||
|
found = true; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!found) console.warn("⏳ 等待選項超時,退回純文字模式");
|
||||||
|
}
|
||||||
|
|
||||||
|
let inputHtml = "";
|
||||||
|
let hintAttr = "";
|
||||||
|
let hintIcon = "";
|
||||||
|
|
||||||
|
if (leafCache && leafCache.hint) {
|
||||||
|
const safeHint = leafCache.hint.replace(/"/g, '"');
|
||||||
|
hintAttr = `title="${safeHint}"`;
|
||||||
|
hintIcon = `<span style="cursor: help; margin-left: 6px; color: #e67e22; font-size: 14px;" title="${safeHint}">❓</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leafCache && leafCache.options && leafCache.options.length > 0) {
|
||||||
|
let options = leafCache.options;
|
||||||
|
if (origVal && !options.includes(origVal)) options = [origVal, ...options];
|
||||||
|
|
||||||
|
inputHtml = `<select class="edit-input" data-path="${path}" ${hintAttr} style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; height: 26px; outline: none; margin-left: 5px; background-color: white; cursor: pointer;">`;
|
||||||
|
options.forEach(opt => {
|
||||||
|
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="${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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelEditFolder(path, elementId) {
|
||||||
|
if (ACTIVE_HEARTBEATS[path]) {
|
||||||
|
clearInterval(ACTIVE_HEARTBEATS[path]);
|
||||||
|
delete ACTIVE_HEARTBEATS[path];
|
||||||
|
}
|
||||||
|
try { await apiReleaseLock(path, SESSION_USER_ID); } catch (error) {}
|
||||||
|
|
||||||
|
document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block';
|
||||||
|
document.getElementById(`actions-${elementId}`).style.display = 'none';
|
||||||
|
|
||||||
|
const contentDiv = document.getElementById(`content-${elementId}`);
|
||||||
|
const leafContainers = contentDiv.querySelectorAll('.leaf-container');
|
||||||
|
leafContainers.forEach(container => {
|
||||||
|
const origVal = container.getAttribute('data-original');
|
||||||
|
container.innerHTML = `<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${origVal}</span>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelEditLeaf(path, elementId) {
|
||||||
|
if (ACTIVE_HEARTBEATS[path]) {
|
||||||
|
clearInterval(ACTIVE_HEARTBEATS[path]);
|
||||||
|
delete ACTIVE_HEARTBEATS[path];
|
||||||
|
}
|
||||||
|
try { await apiReleaseLock(path, SESSION_USER_ID); } catch (error) {}
|
||||||
|
|
||||||
|
document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block';
|
||||||
|
document.getElementById(`actions-${elementId}`).style.display = 'none';
|
||||||
|
|
||||||
|
const container = document.getElementById(`container-${elementId}`);
|
||||||
|
const origVal = container.getAttribute('data-original');
|
||||||
|
container.innerHTML = `<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${origVal}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 3. CLI 生成與預覽 (CLI Generation)
|
||||||
|
// ==========================================
|
||||||
|
function getInterfacesWithAdminState() {
|
||||||
|
const nodes = document.querySelectorAll('.leaf-container');
|
||||||
|
const interfaces = new Set();
|
||||||
|
nodes.forEach(node => {
|
||||||
|
const path = node.getAttribute('data-path');
|
||||||
|
if (path && path.endsWith('::admin-state')) {
|
||||||
|
const interfacePath = path.replace('::admin-state', '').replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
||||||
|
interfaces.add(interfacePath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(interfaces);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function previewFolderCLI(path, elementId) {
|
||||||
|
currentEditPath = path;
|
||||||
|
currentEditElementId = elementId;
|
||||||
|
currentEditIsSuccess = false;
|
||||||
|
currentEditType = 'folder';
|
||||||
|
|
||||||
|
const contentDiv = document.getElementById(`content-${elementId}`);
|
||||||
|
if (!contentDiv) return;
|
||||||
|
|
||||||
|
const inputs = contentDiv.querySelectorAll('.edit-input');
|
||||||
|
const diffs = [];
|
||||||
|
|
||||||
|
inputs.forEach(input => {
|
||||||
|
const container = input.closest('.leaf-container');
|
||||||
|
if (!container) return;
|
||||||
|
const originalVal = container.getAttribute('data-original') || "";
|
||||||
|
const newVal = input.value.trim();
|
||||||
|
const nodePath = container.getAttribute('data-path');
|
||||||
|
|
||||||
|
if (originalVal !== newVal && nodePath) {
|
||||||
|
diffs.push({ path: nodePath, old_val: originalVal, new_val: newVal });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (diffs.length === 0) return alert("沒有偵測到任何修改,無需生成指令!");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiGenerateCli(diffs, getInterfacesWithAdminState());
|
||||||
|
if (result.status === 'success') {
|
||||||
|
showCliPreviewModal(result.data);
|
||||||
|
} else {
|
||||||
|
alert("CLI 生成失敗: " + result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating CLI:", error);
|
||||||
|
alert("網路連線錯誤,無法生成 CLI");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function previewLeafCLI(path, elementId) {
|
||||||
|
currentEditPath = path;
|
||||||
|
currentEditElementId = elementId;
|
||||||
|
currentEditIsSuccess = false;
|
||||||
|
currentEditType = 'leaf';
|
||||||
|
|
||||||
|
const container = document.getElementById(`container-${elementId}`);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const input = container.querySelector('.edit-input');
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
const originalVal = container.getAttribute('data-original') || "";
|
||||||
|
const newVal = input.value.trim();
|
||||||
|
if (originalVal === newVal) return alert("沒有偵測到任何修改,無需生成指令!");
|
||||||
|
|
||||||
|
const diffs = [{ path: path, old_val: originalVal, new_val: newVal }];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiGenerateCli(diffs, getInterfacesWithAdminState());
|
||||||
|
if (result.status === 'success') {
|
||||||
|
showCliPreviewModal(result.data);
|
||||||
|
} else {
|
||||||
|
alert("CLI 生成失敗: " + result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert("網路連線錯誤,無法生成 CLI");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCliPreviewModal(cliScript) {
|
||||||
|
const leftPane = document.getElementById('tree-container');
|
||||||
|
const resizer = document.getElementById('drag-resizer');
|
||||||
|
const rightPane = document.getElementById('side-cli-preview');
|
||||||
|
|
||||||
|
document.getElementById('side-pane-title').innerHTML = '⚠️ 即將寫入的指令';
|
||||||
|
document.getElementById('side-pane-title').style.color = '#f1c40f';
|
||||||
|
document.getElementById('btn-side-cancel').style.display = 'inline-block';
|
||||||
|
document.getElementById('btn-side-confirm').style.display = 'inline-block';
|
||||||
|
document.getElementById('btn-side-close').style.display = 'none';
|
||||||
|
document.getElementById('side-cli-textarea').style.display = 'block';
|
||||||
|
document.getElementById('side-execution-result').style.display = 'none';
|
||||||
|
|
||||||
|
document.getElementById('side-cli-textarea').value = cliScript;
|
||||||
|
|
||||||
|
leftPane.style.width = 'calc(50% - 11px)';
|
||||||
|
rightPane.style.width = 'calc(50% - 11px)';
|
||||||
|
resizer.style.display = 'flex';
|
||||||
|
rightPane.style.display = 'block';
|
||||||
|
|
||||||
|
if (!window.isResizerInitialized) {
|
||||||
|
initResizer();
|
||||||
|
window.isResizerInitialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 4. 側邊欄與執行邏輯
|
||||||
|
// ==========================================
|
||||||
|
export async function hideSideCLI() {
|
||||||
|
document.getElementById('tree-container').style.width = '100%';
|
||||||
|
document.getElementById('drag-resizer').style.display = 'none';
|
||||||
|
document.getElementById('side-cli-preview').style.display = 'none';
|
||||||
|
|
||||||
|
if (currentEditIsSuccess && currentEditElementId && currentEditPath) {
|
||||||
|
const wrapper = document.getElementById(`display-wrapper-${currentEditElementId}`);
|
||||||
|
if (wrapper) {
|
||||||
|
const container = document.getElementById(`container-${currentEditElementId}`);
|
||||||
|
const input = container ? container.querySelector('.edit-input') : null;
|
||||||
|
const newCommand = input ? input.value.trim() : (container ? container.getAttribute('data-original') : '');
|
||||||
|
|
||||||
|
if (currentEditType === 'folder') await cancelEditFolder(currentEditPath, currentEditElementId);
|
||||||
|
else await cancelEditLeaf(currentEditPath, currentEditElementId);
|
||||||
|
|
||||||
|
transformNoToActive(currentEditElementId, newCommand);
|
||||||
|
} else {
|
||||||
|
if (currentEditType === 'folder') await applyEditFolder(currentEditPath, currentEditElementId);
|
||||||
|
else if (currentEditType === 'leaf') await applyEditLeaf(currentEditPath, currentEditElementId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentEditPath = null;
|
||||||
|
currentEditElementId = null;
|
||||||
|
currentEditIsSuccess = false;
|
||||||
|
currentEditType = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyEditFolder(path, elementId) {
|
||||||
|
const contentDiv = document.getElementById(`content-${elementId}`);
|
||||||
|
if (contentDiv) {
|
||||||
|
const inputs = contentDiv.querySelectorAll('.edit-input');
|
||||||
|
inputs.forEach(input => {
|
||||||
|
const container = input.closest('.leaf-container');
|
||||||
|
if (container) container.setAttribute('data-original', input.value.trim());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await cancelEditFolder(path, elementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyEditLeaf(path, elementId) {
|
||||||
|
const container = document.getElementById(`container-${elementId}`);
|
||||||
|
if (container) {
|
||||||
|
const input = container.querySelector('.edit-input');
|
||||||
|
if (input) container.setAttribute('data-original', input.value.trim());
|
||||||
|
}
|
||||||
|
await cancelEditLeaf(path, elementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executeSideCLI() {
|
||||||
|
const finalScript = document.getElementById('side-cli-textarea').value;
|
||||||
|
document.getElementById('side-pane-title').innerHTML = '⏳ 正在寫入設備...';
|
||||||
|
document.getElementById('side-pane-title').style.color = '#3498db';
|
||||||
|
document.getElementById('btn-side-cancel').style.display = 'none';
|
||||||
|
document.getElementById('btn-side-confirm').style.display = 'none';
|
||||||
|
document.getElementById('btn-side-close').style.display = 'none';
|
||||||
|
document.getElementById('side-cli-textarea').style.display = 'none';
|
||||||
|
|
||||||
|
const resultBox = document.getElementById('side-execution-result');
|
||||||
|
resultBox.style.display = 'block';
|
||||||
|
resultBox.innerHTML = `<span style="color: #ecf0f1;">${finalScript}</span>`;
|
||||||
|
|
||||||
|
executeGeneratedCLI(finalScript);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeGeneratedCLI(script) {
|
||||||
|
const connInfo = getGlobalConnectionInfo();
|
||||||
|
if (!connInfo) return;
|
||||||
|
|
||||||
|
const outputEl = document.getElementById('side-execution-result');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiExecuteConfig(script, connInfo.host, connInfo.user, connInfo.pass);
|
||||||
|
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';
|
||||||
|
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>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 5. 輔助 UI 函數
|
||||||
|
// ==========================================
|
||||||
|
function initResizer() {
|
||||||
|
const resizer = document.getElementById('drag-resizer');
|
||||||
|
const leftPane = document.getElementById('tree-container');
|
||||||
|
const rightPane = document.getElementById('side-cli-preview');
|
||||||
|
const container = document.getElementById('split-container');
|
||||||
|
let isResizing = false;
|
||||||
|
|
||||||
|
resizer.addEventListener('mousedown', (e) => {
|
||||||
|
isResizing = true;
|
||||||
|
document.body.style.cursor = 'col-resize';
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', (e) => {
|
||||||
|
if (!isResizing) return;
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
let newLeftWidth = e.clientX - containerRect.left;
|
||||||
|
const minWidth = containerRect.width * 0.2;
|
||||||
|
const maxWidth = containerRect.width * 0.8;
|
||||||
|
if (newLeftWidth < minWidth) newLeftWidth = minWidth;
|
||||||
|
if (newLeftWidth > maxWidth) newLeftWidth = maxWidth;
|
||||||
|
|
||||||
|
const leftPercentage = (newLeftWidth / containerRect.width) * 100;
|
||||||
|
const rightPercentage = 100 - leftPercentage;
|
||||||
|
leftPane.style.width = `calc(${leftPercentage}% - 11px)`;
|
||||||
|
rightPane.style.width = `calc(${rightPercentage}% - 11px)`;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mouseup', () => {
|
||||||
|
if (isResizing) {
|
||||||
|
isResizing = false;
|
||||||
|
document.body.style.cursor = 'default';
|
||||||
|
document.body.style.userSelect = 'auto';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightTerminalOutput(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const lines = text.split('\n');
|
||||||
|
const formattedLines = lines.map(line => {
|
||||||
|
if (line.match(/(Aborted|Error|Warning|Failed|% Invalid|% Unknown|Bad command)/i)) {
|
||||||
|
return `<span style="color: #ff6b6b; font-weight: bold;">${line}</span>`;
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
});
|
||||||
|
return formattedLines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformNoToActive(elementId, newCommand) {
|
||||||
|
const wrapper = document.getElementById(`display-wrapper-${elementId}`);
|
||||||
|
if (!wrapper) return;
|
||||||
|
|
||||||
|
const parts = newCommand.trim().split(' ');
|
||||||
|
const firstWord = parts[0];
|
||||||
|
const restCommand = parts.slice(1).join(' ');
|
||||||
|
|
||||||
|
const svgEnabled = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#27ae60"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zM8.29 11.59L7 12.88l4 4 8-8-1.29-1.29L11 14.18z"/></svg>`;
|
||||||
|
const iconHtml = `<span style="margin-right: 6px; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px;">${svgEnabled}</span>`;
|
||||||
|
|
||||||
|
wrapper.innerHTML = `
|
||||||
|
${iconHtml}
|
||||||
|
<b style="color: #2c3e50;">${firstWord}</b>
|
||||||
|
<span class="leaf-container" style="display: inline-flex; align-items: center; min-height: 24px;">
|
||||||
|
<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);">${restCommand}</span>
|
||||||
|
<span style="font-size: 0.8em; color: #f39c12; margin-left: 10px; border: 1px solid #f39c12; padding: 2px 6px; border-radius: 4px; background-color: #fffdf7;">(已啟用,重整後歸檔)</span>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const actionBtns = document.getElementById(`actions-${elementId}`);
|
||||||
|
const editBtn = document.getElementById(`edit-btn-${elementId}`);
|
||||||
|
if (actionBtns) actionBtns.style.display = 'none';
|
||||||
|
if (editBtn) editBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,279 @@
|
||||||
|
// --- static/mac-domain.js ---
|
||||||
|
|
||||||
|
import { apiGetMacDomainList, apiGetMacDomainConfig, apiExecuteConfig } from './api.js';
|
||||||
|
import { getGlobalConnectionInfo } from './utils.js';
|
||||||
|
|
||||||
|
// 🌟 將原本在 app.js 的全域變數移到這裡封裝
|
||||||
|
let currentMacDomainData = null;
|
||||||
|
|
||||||
|
export function hasMacDomainData() {
|
||||||
|
return currentMacDomainData !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetMacDomainData() {
|
||||||
|
currentMacDomainData = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 result = await apiGetMacDomainList(connInfo.host, connInfo.user, connInfo.pass);
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 result = await apiGetMacDomainConfig(target, connInfo.host, connInfo.user, connInfo.pass);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function populateFormFromData() {
|
||||||
|
const d = currentMacDomainData;
|
||||||
|
if (!d) return;
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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';
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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'] || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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'] || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeBondingConfig() {
|
||||||
|
if (!confirm("⚠️ 警告:此操作將導致 MAC Domain 重新啟動,影響用戶服務。確定要執行嗎?")) return;
|
||||||
|
|
||||||
|
const cliScript = document.getElementById('cliPreviewContainer').textContent;
|
||||||
|
const connInfo = getGlobalConnectionInfo();
|
||||||
|
if (!connInfo) return;
|
||||||
|
|
||||||
|
// 開啟 Modal 顯示執行進度
|
||||||
|
document.getElementById('outputModal').classList.add('active');
|
||||||
|
document.getElementById('modalTargetInfo').textContent = connInfo.host ? `(${connInfo.host})` : '';
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
const outputEl = document.getElementById('modalOutput');
|
||||||
|
outputEl.innerHTML = `<span style="color: #f39c12;">🚀 正在排隊並執行配置腳本,這可能需要幾秒鐘,請勿關閉視窗...</span>\n\n${cliScript}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiExecuteConfig(cliScript, connInfo.host, connInfo.user, connInfo.pass);
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,279 @@
|
||||||
|
// --- static/tree-ui.js ---
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 🌟 輔助函數:全部展開與全部收合
|
||||||
|
// ==========================================
|
||||||
|
export 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); // 展開所有子項目
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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; // 收合自己
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 🌟 核心函數:生成完整設備配置樹狀圖
|
||||||
|
// ==========================================
|
||||||
|
export function buildTree(node, path = '', isParentCommandGroup = false) {
|
||||||
|
let html = '';
|
||||||
|
if (!node || typeof node !== 'object') return html;
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(node)) {
|
||||||
|
const currentPath = path ? `${path}::${key}` : key;
|
||||||
|
let cliCommand = currentPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
||||||
|
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
const hasNestedObject = Object.values(value).some(v => typeof v === 'object' && v !== null);
|
||||||
|
const isCommandGroup = isParentCommandGroup || !hasNestedObject;
|
||||||
|
const elementId = `folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
||||||
|
const folderSuffix = isCommandGroup ? '<span style="color: #95a5a6; font-size: 0.85em; font-weight: normal; margin-left: 5px;">(指令群組)</span>' : '';
|
||||||
|
|
||||||
|
const svgFolderClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
|
||||||
|
const svgFolderOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M19 8H8.99C8.04 8 7.19 8.59 6.81 9.46L2.81 18.55C2.62 18.98 2.94 19.5 3.41 19.5H16.99C17.94 19.5 18.79 18.91 19.17 18.04L23.17 8.95C23.36 8.52 23.04 8 22.57 8H19zM4 4c-1.1 0-2 .9-2 2v10.59l3.09-7.04C5.58 8.36 6.27 8 7.01 8H19v-2c0-1.1-.9-2-2-2h-7l-2-2H4c-1.1 0-2 .9-2 2v12z"/></svg>`;
|
||||||
|
const svgGroupClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#95a5a6"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
|
||||||
|
const svgGroupOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#3498db"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
|
||||||
|
|
||||||
|
const iconClosed = isCommandGroup ? svgGroupClosed : svgFolderClosed;
|
||||||
|
const iconOpen = isCommandGroup ? svgGroupOpen : svgFolderOpen;
|
||||||
|
|
||||||
|
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>`;
|
||||||
|
|
||||||
|
const expandCollapseBtns = `
|
||||||
|
<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>
|
||||||
|
`;
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;"
|
||||||
|
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none';">
|
||||||
|
<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: 6px; display: inline-flex; align-items: center; width: 18px; height: 18px;">
|
||||||
|
<span class="icon-closed" style="display: inline-block;">${iconClosed}</span>
|
||||||
|
<span class="icon-open" style="display: none;">${iconOpen}</span>
|
||||||
|
</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, isCommandGroup)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
const isVirtualIndex = /^\[\d+\]$/.test(key);
|
||||||
|
const isNoCommand = (key === 'no');
|
||||||
|
const safeValue = (value === null ? '' : value).toString().replace(/"/g, """);
|
||||||
|
const elementId = `leaf-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
||||||
|
|
||||||
|
if (isNoCommand) {
|
||||||
|
let baseCmd = cliCommand.replace(/(^|\s)no$/, '').trim();
|
||||||
|
cliCommand = baseCmd ? `${baseCmd} no ${safeValue}` : `no ${safeValue}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const svgLeaf = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#bdc3c7"><path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/></svg>`;
|
||||||
|
const svgDisabled = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#e74c3c"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"/></svg>`;
|
||||||
|
|
||||||
|
const iconHtml = (icon) => `<span style="margin-right: 6px; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px;">${icon}</span>`;
|
||||||
|
const valueStyle = `color: #16a085; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);`;
|
||||||
|
|
||||||
|
let displayContent = '';
|
||||||
|
|
||||||
|
if (isVirtualIndex) {
|
||||||
|
displayContent = `
|
||||||
|
${iconHtml(svgLeaf)}
|
||||||
|
<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="${valueStyle}">${safeValue}</span>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
} else if (isNoCommand) {
|
||||||
|
displayContent = `
|
||||||
|
<span id="display-wrapper-${elementId}" style="display: inline-flex; align-items: center;">
|
||||||
|
${iconHtml(svgDisabled)}
|
||||||
|
<b style="color: #e74c3c;">no:</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: #7f8c8d; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);">${safeValue}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
displayContent = `
|
||||||
|
${iconHtml(svgLeaf)} <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="${valueStyle}">${safeValue}</span>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 🌟 核心函數:生成系統設定的過濾樹狀圖
|
||||||
|
// ==========================================
|
||||||
|
export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isParentCommandGroup = false) {
|
||||||
|
if (typeof data !== 'object' || data === null) return '';
|
||||||
|
|
||||||
|
let html = `<div style="margin-left: ${parentPath ? '24px' : '0'};">`;
|
||||||
|
|
||||||
|
for (const key in data) {
|
||||||
|
const value = data[key];
|
||||||
|
const currentPath = parentPath ? `${parentPath}::${key}` : key;
|
||||||
|
const isChecked = hiddenKeys.includes(currentPath) ? "checked" : "";
|
||||||
|
|
||||||
|
let cliCommand = currentPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
||||||
|
|
||||||
|
const svgFolderClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
|
||||||
|
const svgFolderOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M19 8H8.99C8.04 8 7.19 8.59 6.81 9.46L2.81 18.55C2.62 18.98 2.94 19.5 3.41 19.5H16.99C17.94 19.5 18.79 18.91 19.17 18.04L23.17 8.95C23.36 8.52 23.04 8 22.57 8H19zM4 4c-1.1 0-2 .9-2 2v10.59l3.09-7.04C5.58 8.36 6.27 8 7.01 8H19v-2c0-1.1-.9-2-2-2h-7l-2-2H4c-1.1 0-2 .9-2 2v12z"/></svg>`;
|
||||||
|
const svgGroupClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#95a5a6"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
|
||||||
|
const svgGroupOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#3498db"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
|
||||||
|
const svgLeaf = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#bdc3c7"><path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/></svg>`;
|
||||||
|
|
||||||
|
const isFolder = typeof value === 'object' && value !== null && Object.keys(value).length > 0;
|
||||||
|
const onChangeEvent = isFolder ? 'toggleChildCheckboxes(this)' : 'updateParentCheckboxState(this)';
|
||||||
|
const checkboxHtml = `<input type="checkbox" value="${currentPath}" class="filter-checkbox" ${isChecked} onclick="event.stopPropagation();" onchange="${onChangeEvent}" style="margin-right: 8px; cursor: pointer; width: 14px; height: 14px; flex-shrink: 0;">`;
|
||||||
|
|
||||||
|
if (isFolder) {
|
||||||
|
const hasNestedObject = Object.values(value).some(v => typeof v === 'object' && v !== null);
|
||||||
|
const isCommandGroup = isParentCommandGroup || !hasNestedObject;
|
||||||
|
const elementId = `filter-folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
||||||
|
|
||||||
|
const iconClosed = isCommandGroup ? svgGroupClosed : svgFolderClosed;
|
||||||
|
const iconOpen = isCommandGroup ? svgGroupOpen : svgFolderOpen;
|
||||||
|
const folderSuffix = isCommandGroup ? `<span style="color: #95a5a6; font-size: 0.85em; font-weight: normal; margin-left: 5px;">(指令群組)</span>` : '';
|
||||||
|
|
||||||
|
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>`;
|
||||||
|
|
||||||
|
const expandCollapseBtns = `
|
||||||
|
<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>
|
||||||
|
`;
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<details id="details-${elementId}" class="tree-folder" ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none';">
|
||||||
|
<summary class="tree-node-header" title="${cliCommand}" style="font-weight: bold; color: #2c3e50; margin-top: 4px; list-style: none; display: flex; align-items: center; cursor: pointer; outline: none; padding: 4px 0;">
|
||||||
|
<style>#details-${elementId} > summary::-webkit-details-marker { display: none; }</style>
|
||||||
|
${checkboxHtml}
|
||||||
|
<span style="margin-right: 6px; display: inline-flex; align-items: center; width: 18px; height: 18px;">
|
||||||
|
<span class="icon-closed" style="display: inline-block;">${iconClosed}</span>
|
||||||
|
<span class="icon-open" style="display: none;">${iconOpen}</span>
|
||||||
|
</span>
|
||||||
|
<span>${key}${folderSuffix}</span>
|
||||||
|
${expandCollapseBtns}
|
||||||
|
</summary>
|
||||||
|
<div class="filter-children-container" style="border-left: 1px dashed #bdc3c7; margin-left: 7px; padding-left: 16px;">
|
||||||
|
${buildRealFilterTree(value, currentPath, hiddenKeys, isCommandGroup)}
|
||||||
|
</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, """);
|
||||||
|
|
||||||
|
const isNoCommand = (key === 'no');
|
||||||
|
|
||||||
|
let valueHtml = `<span style="color: #16a085; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);">${safeValue}</span>`;
|
||||||
|
let colonHtml = `:`;
|
||||||
|
let currentIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#bdc3c7"><path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/></svg>`;
|
||||||
|
|
||||||
|
if (isNoCommand) {
|
||||||
|
let baseCmd = cliCommand.replace(/(^|\s)no$/, '').trim();
|
||||||
|
cliCommand = baseCmd ? `${baseCmd} no ${safeValue}` : `no ${safeValue}`;
|
||||||
|
|
||||||
|
displayName = `<b style="color: #e74c3c;">no</b>`;
|
||||||
|
colonHtml = `:`;
|
||||||
|
valueHtml = `<span style="color: #7f8c8d; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);">${safeValue}</span>`;
|
||||||
|
currentIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#e74c3c"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"/></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="tree-leaf-node"
|
||||||
|
onmouseover="this.style.backgroundColor='#f1f2f6'"
|
||||||
|
onmouseout="this.style.backgroundColor='transparent'"
|
||||||
|
style="padding: 2px 0; color: #34495e; display: flex; align-items: center; margin-top: 2px; border-radius: 4px; transition: background-color 0.2s;"
|
||||||
|
title="${cliCommand}">
|
||||||
|
${checkboxHtml}
|
||||||
|
<span style="margin-right: 6px; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; flex-shrink: 0;">
|
||||||
|
${currentIcon}
|
||||||
|
</span>
|
||||||
|
<span style="margin-right: 5px;">${displayName}${colonHtml}</span>
|
||||||
|
${valueHtml}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
{
|
{
|
||||||
"hidden_keys": [
|
"hidden_keys": [
|
||||||
"ssh",
|
"ssh"
|
||||||
"clock"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue