fix: isNoCommand讀取寫入邏輯重整

This commit is contained in:
swpa 2026-05-13 10:32:26 +08:00
parent d57a01f05b
commit 098b7a831f
4 changed files with 333 additions and 71 deletions

View File

@ -99028,5 +99028,71 @@
"type": "string_or_number", "type": "string_or_number",
"current_value": null, "current_value": null,
"updated_at": 1778489033 "updated_at": 1778489033
},
"cable mac-domain 13:0/0.0 shared-secret": {
"hint": "Description: shared-secret {0 | 7}\nPossible completions:\n<shared-secret {0 | 7}>[7] <cr>",
"options": [],
"type": "string_or_number",
"current_value": "7",
"updated_at": 1778495587
},
"network link-holdoff-time-ms": {
"hint": "Description: Link bring-up delay in milliseconds\nPossible completions:\n<unsignedInt>[0]",
"options": [],
"type": "string_or_number",
"current_value": "0",
"updated_at": 1778566706
},
"network l3-toward-access": {
"hint": "Description: Enables/disables L3 mode\nPossible completions:\n[disabled] disabled enabled",
"options": [
"disabled",
"enabled"
],
"type": "list",
"current_value": "disabled",
"updated_at": 1778566706
},
"logging buffered": {
"hint": "Possible completions:\nseverity size-mb",
"options": [],
"type": "string_or_number",
"current_value": null,
"updated_at": 1778570349
},
"cable rip": {
"hint": "Possible completions:\nallow-subnet Allowed subnets for RIP routes\nauthentication",
"options": [],
"type": "string_or_number",
"current_value": null,
"updated_at": 1778570650
},
"cable bundle 1 nsi start-ipv6-address": {
"hint": "Description: Starting address within ipv6 subnet on the CRE for the bundle nsi subnet\nPossible completions:\n<IPv6 address>[2001:50:5:11::7]",
"options": [],
"type": "string_or_number",
"current_value": "2001:50:5:11::7",
"updated_at": 1778582997
},
"cable bundle 1 nsi cre-ipv4-address": {
"hint": "Description: CRE gateway IP address for the bundle nsi subnet\nPossible completions:\n<string>[10.14.11.1/24]",
"options": [],
"type": "string_or_number",
"current_value": "10.14.11.1/24",
"updated_at": 1778582997
},
"cable bundle 1 nsi cre-ipv6-address": {
"hint": "Description: CRE gateway IPv6 address for the bundle nsi subnet\nPossible completions:\n<string>[2001:50:5:11::1/64]",
"options": [],
"type": "string_or_number",
"current_value": "2001:50:5:11::1/64",
"updated_at": 1778582997
},
"cable bundle 1 nsi pool-count": {
"hint": "Description: Number of IP addresses starting from \"start-ipv4-addr/start-ipv6-addr\" used as nsi\nPossible completions:\n<unsignedInt>[10]",
"options": [],
"type": "string_or_number",
"current_value": "10",
"updated_at": 1778582997
} }
} }

View File

@ -26,10 +26,45 @@ def parse_cli_to_tree(cli_text: str) -> dict:
""" """
CMTS CLI 配置文字轉換為完美的邏輯巢狀 JSON (兩階段解析法) CMTS CLI 配置文字轉換為完美的邏輯巢狀 JSON (兩階段解析法)
""" """
lines = [line.rstrip() for line in cli_text.splitlines() if line.strip()] # ==========================================
# 🌟 階段 0預處理 (修復終端機 200 字元自動折行問題)
# ==========================================
raw_lines = cli_text.split('\n')
fixed_lines = []
# 定義常見的 Root 指令關鍵字,避免把正常的指令誤判為斷行
root_keywords = (
'alias', 'ssh', 'hostname', 'logging', 'cable', 'ipdr',
'snmp-server', 'aaa', 'radius-server', 'privilege', 'cli', 'packetcable',
'system', 'network', 'clock', 'no'
)
for line in raw_lines:
line = line.replace('\r', '')
if not line.strip():
continue
# 💡 判斷是否為被截斷的行:
# 1. 前一行長度非常長 (大於 190 字元,根據您的截圖,極限是 199)
# 2. 當前行「沒有縮排」(終端機自動折行會把字元推到最左邊第 0 格)
# 3. 當前行不是註解 '!'
if fixed_lines and len(fixed_lines[-1]) > 190 and not line.startswith(' ') and not line.startswith('!'):
# 取得當前行的第一個單字
first_word = line.strip().split()[0] if line.strip() else ""
# 確保它不是一個正常的 Root 指令
if first_word not in root_keywords:
# ✅ 觸發黏合邏輯:將這行直接接在上一行後面
fixed_lines[-1] = fixed_lines[-1] + line
continue
fixed_lines.append(line)
# 統一去除右側多餘空白,準備進入階段 1
lines = [line.rstrip() for line in fixed_lines if line.strip()]
# ========================================== # ==========================================
# 階段 1建立實體樹 (依據縮排與 '!') # 階段 1建立實體樹 (嚴格比對縮排與 '!' 的對應關係)
# ========================================== # ==========================================
def build_raw_tree(start_idx, current_indent): def build_raw_tree(start_idx, current_indent):
nodes = [] nodes = []
@ -37,26 +72,25 @@ def parse_cli_to_tree(cli_text: str) -> dict:
while i < len(lines): while i < len(lines):
line = lines[i] line = lines[i]
stripped = line.strip() stripped = line.strip()
# 遇到 ! 的處理邏輯
if stripped == '!':
if current_indent > 0:
return nodes, i + 1 # 子區塊遇到 !,結束並消耗該行
else:
i += 1 # Root 層級的 ! 只是分隔符號,直接跳過
continue
indent = len(line) - len(line.lstrip()) indent = len(line) - len(line.lstrip())
# 縮排退回,隱含結束當前區塊 (不消耗該行) # 🌟 嚴格判斷 1如果遇到縮排退回 (包含縮排退回的 !),代表當前深度的區塊已結束
if indent < current_indent: if indent < current_indent:
return nodes, i return nodes, i
# 🌟 嚴格判斷 2處理同層級的 !
if stripped == '!':
# 能走到這裡,代表 indent >= current_indent
# 這是對應當前層級的結束符號或分隔符,我們將其消耗掉並繼續,絕對不提早 return
i += 1
continue
# 探測是否有子節點 # 探測是否有子節點
has_children = False has_children = False
if i + 1 < len(lines): if i + 1 < len(lines):
next_line = lines[i+1] next_line = lines[i+1]
next_stripped = next_line.strip() next_stripped = next_line.strip()
# 只有當下一行不是 ! 且縮排大於當前行時,才判定為有子節點
if next_stripped != '!': if next_stripped != '!':
next_indent = len(next_line) - len(next_line.lstrip()) next_indent = len(next_line) - len(next_line.lstrip())
if next_indent > indent: if next_indent > indent:

View File

@ -178,7 +178,12 @@ async function startEditFolder(path, elementId) {
const origVal = container.getAttribute('data-original'); const origVal = container.getAttribute('data-original');
const rawPath = container.getAttribute('data-path'); const rawPath = container.getAttribute('data-path');
// 🌟 轉換為空白格式後,全域拔除路徑中所有的虛擬索引 [0], [1] // 🌟 轉換為空白格式後,全域拔除路徑中所有的虛擬索引 [0], [1]
const cacheKey = rawPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); let cacheKey = rawPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
// 💡 攔截 no 指令:將查詢路徑改為實際指令
if (rawPath.endsWith('::no')) {
cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
}
let leafCache = optData[cacheKey]; let leafCache = optData[cacheKey];
@ -265,7 +270,13 @@ async function startEditLeaf(path, elementId) {
let optData = await optRes.json(); let optData = await optRes.json();
// 🌟 轉換為空白格式後,全域拔除路徑中所有的虛擬索引 [0], [1] // 🌟 轉換為空白格式後,全域拔除路徑中所有的虛擬索引 [0], [1]
const cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
// 💡 攔截 no 指令:將查詢路徑改為實際指令
if (path.endsWith('::no')) {
cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
}
let leafCache = optData[cacheKey]; let leafCache = optData[cacheKey];
const currentTime = Math.floor(Date.now() / 1000); const currentTime = Math.floor(Date.now() / 1000);
@ -545,17 +556,41 @@ function showCliPreviewModal(cliScript) {
} }
// ❌ 隱藏側邊預覽區塊 (恢復左側 100% 寬度) // ❌ 隱藏側邊預覽區塊 (恢復左側 100% 寬度)
function hideSideCLI() { async function hideSideCLI() {
document.getElementById('tree-container').style.width = '100%'; document.getElementById('tree-container').style.width = '100%';
document.getElementById('drag-resizer').style.display = 'none'; document.getElementById('drag-resizer').style.display = 'none';
document.getElementById('side-cli-preview').style.display = 'none'; document.getElementById('side-cli-preview').style.display = 'none';
// 💡 根據編輯類型,呼叫對應的套用函數 // 💡 根據編輯類型,呼叫對應的套用函數
if (currentEditIsSuccess && currentEditElementId && currentEditPath) { if (currentEditIsSuccess && currentEditElementId && currentEditPath) {
// 🔍 檢查這個節點是不是 no 指令 (透過尋找我們剛加的 display-wrapper)
const wrapper = document.getElementById(`display-wrapper-${currentEditElementId}`);
if (wrapper) {
// 🌟 這是 no 指令的變身邏輯!
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') : '');
// 1. 先正常解除後端鎖定 (這會還原原本的 HTML 結構)
if (currentEditType === 'folder') { if (currentEditType === 'folder') {
applyEditFolder(currentEditPath, currentEditElementId); await cancelEditFolder(currentEditPath, currentEditElementId);
} else {
await cancelEditLeaf(currentEditPath, currentEditElementId);
}
// 2. 觸發原地變身!(這會把整個 wrapper 換成綠色打勾的樣式)
transformNoToActive(currentEditElementId, newCommand);
} else {
// 🌟 一般正常指令的套用邏輯 (維持您原本的寫法)
if (currentEditType === 'folder') {
await applyEditFolder(currentEditPath, currentEditElementId);
} else if (currentEditType === 'leaf') { } else if (currentEditType === 'leaf') {
applyEditLeaf(currentEditPath, currentEditElementId); await applyEditLeaf(currentEditPath, currentEditElementId);
}
} }
} }
@ -1538,30 +1573,34 @@ async function executeBondingConfig() {
// ========================================== // ==========================================
// 💡 6. 完整設備配置樹狀圖邏輯 (支援資料夾層級鎖定與虛擬群組視覺優化) // 💡 6. 完整設備配置樹狀圖邏輯 (支援資料夾層級鎖定與虛擬群組視覺優化)
// ========================================== // ==========================================
function buildTree(node, path = '') { // 🌟 1. 在參數中加入 isParentCommandGroup用來實現「向下繼承」
// 🌟 加入 isParentCommandGroup 參數,實現向下繼承
function buildTree(node, path = '', isParentCommandGroup = false) {
let html = ''; let html = '';
if (!node || typeof node !== 'object') return html; if (!node || typeof node !== 'object') return html;
for (const [key, value] of Object.entries(node)) { for (const [key, value] of Object.entries(node)) {
const currentPath = path ? `${path}::${key}` : key; const currentPath = path ? `${path}::${key}` : key;
let cliCommand = currentPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
// 🌟 產生純淨的 CLI 指令路徑 (替換 :: 為空白,並移除虛擬索引)
const cliCommand = currentPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
if (typeof value === 'object' && value !== null) { if (typeof value === 'object' && value !== null) {
// 🌟 處理資料夾或指令群組
const elementId = `folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
const hasNestedObject = Object.values(value).some(v => typeof v === 'object' && v !== null); const hasNestedObject = Object.values(value).some(v => typeof v === 'object' && v !== null);
const folderIcon = hasNestedObject ? '📁' : '📑'; const isCommandGroup = isParentCommandGroup || !hasNestedObject;
const folderSuffix = hasNestedObject ? '' : '<span style="color: #95a5a6; font-size: 0.85em; font-weight: normal; margin-left: 5px;">(指令群組)</span>'; 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;
// 🌟 定義專業的 SVG 向量圖示
const expandAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 13 12 18 17 13"></polyline><polyline points="7 6 12 11 17 6"></polyline></svg>`; const 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 collapseAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 11 12 6 7 11"></polyline><polyline points="17 18 12 13 7 18"></polyline></svg>`;
// 🌟 全部展開/收合按鈕 (使用 SVG 並加入懸停變色效果) const expandCollapseBtns = `
const expandCollapseBtns = hasNestedObject ? `
<span style="margin-left: 8px; display: inline-flex; gap: 6px; align-items: center;"> <span style="margin-left: 8px; display: inline-flex; gap: 6px; align-items: center;">
<span onclick="event.stopPropagation(); event.preventDefault(); expandAll('${elementId}')" <span onclick="event.stopPropagation(); event.preventDefault(); expandAll('${elementId}')"
style="cursor: pointer; color: #95a5a6; transition: color 0.2s; display: flex; align-items: center;" style="cursor: pointer; color: #95a5a6; transition: color 0.2s; display: flex; align-items: center;"
@ -1574,17 +1613,21 @@ function buildTree(node, path = '') {
${collapseAllIcon} ${collapseAllIcon}
</span> </span>
</span> </span>
` : ''; `;
// ✅ 加入 title="${cliCommand}" 提供懸停提示
html += ` html += `
<details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;"> <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" <summary class="tree-folder-header"
title="${cliCommand}" title="${cliCommand}"
onmouseover="this.style.backgroundColor='#f1f2f6'" onmouseover="this.style.backgroundColor='#f1f2f6'"
onmouseout="this.style.backgroundColor='transparent'" 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;"> style="cursor: pointer; padding: 4px 8px; display: flex; align-items: center; outline: none; border-radius: 4px; transition: background-color 0.2s;">
<span style="margin-right: 5px;">${folderIcon}</span>
<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} <b style="color: #2c3e50;">${key}</b>${folderSuffix}
${expandCollapseBtns} ${expandCollapseBtns}
@ -1601,35 +1644,63 @@ function buildTree(node, path = '') {
</div> </div>
</summary> </summary>
<div id="content-${elementId}" class="tree-folder-content" style="margin-left: 12px; border-left: 1px dashed #bdc3c7; padding-left: 12px;"> <div id="content-${elementId}" class="tree-folder-content" style="margin-left: 12px; border-left: 1px dashed #bdc3c7; padding-left: 12px;">
${buildTree(value, currentPath)} ${buildTree(value, currentPath, isCommandGroup)}
</div> </div>
</details> </details>
`; `;
} else { } else {
// 🌟 處理單一設定項目 (Leaf) // 🌟 處理單一設定項目 (Leaf)
const isVirtualIndex = /^\[\d+\]$/.test(key); const isVirtualIndex = /^\[\d+\]$/.test(key);
const isNoCommand = (key === 'no');
const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;"); const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;");
const elementId = `leaf-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`; const elementId = `leaf-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
// 💡 終極修正:完美兼容根目錄與子目錄的 no 指令
if (isNoCommand) {
// 1. 移除結尾的 " no" (子目錄) 或是整句剛好是 "no" (根目錄)
let baseCmd = cliCommand.replace(/(^|\s)no$/, '').trim();
// 2. 如果 baseCmd 有東西,代表是子目錄;如果為空,代表是根目錄
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>`;
// 💡 關鍵修正:加上 display: inline-block; 與 transform: translateY(1.5px); 強制下移
const valueStyle = `color: #16a085; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);`;
let displayContent = ''; let displayContent = '';
if (isVirtualIndex) { if (isVirtualIndex) {
displayContent = ` displayContent = `
<span style="margin-right: 5px;">📄</span> ${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-container" id="container-${elementId}" data-path="${currentPath}" data-original="${safeValue}" style="display: inline-flex; align-items: center; min-height: 24px;">
<span class="leaf-value" style="color: #16a085; font-weight: normal; font-family: Courier New, monospace;">${safeValue}</span> <span class="leaf-value" style="${valueStyle}">${safeValue}</span>
</span>
`;
} else if (isNoCommand) {
// 為了方便後續的「原地變身」,我們加上了 id="display-wrapper-..."
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> </span>
`; `;
} else { } else {
displayContent = ` displayContent = `
<span style="margin-right: 5px;">📄</span> <b>${key}</b>: ${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-container" id="container-${elementId}" data-path="${currentPath}" data-original="${safeValue}" style="display: inline-flex; align-items: center; min-height: 24px;">
<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${safeValue}</span> <span class="leaf-value" style="${valueStyle}">${safeValue}</span>
</span> </span>
`; `;
} }
// ✅ 加入 title="${cliCommand}" 提供懸停提示
html += ` html += `
<div class="tree-leaf-node" <div class="tree-leaf-node"
title="${cliCommand}" title="${cliCommand}"
@ -1744,56 +1815,110 @@ async function loadRealConfigForFilters() {
} }
} }
function buildRealFilterTree(data, parentPath, hiddenKeys) { // 🌟 加入 isParentCommandGroup 參數,讓系統設定也能完美繼承群組狀態
function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isParentCommandGroup = false) {
if (typeof data !== 'object' || data === null) return ''; if (typeof data !== 'object' || data === null) return '';
let html = `<div style="margin-left: ${parentPath ? '20px' : '0'};">`; let html = `<div style="margin-left: ${parentPath ? '24px' : '0'};">`;
for (const key in data) { for (const key in data) {
const value = data[key]; const value = data[key];
const currentPath = parentPath ? `${parentPath}::${key}` : key; const currentPath = parentPath ? `${parentPath}::${key}` : key;
const isChecked = hiddenKeys.includes(currentPath) ? "checked" : ""; const isChecked = hiddenKeys.includes(currentPath) ? "checked" : "";
if (typeof value === 'object' && value !== null && Object.keys(value).length > 0) { // 💡 統一在這裡解析 cliCommand讓資料夾與 Leaf 都能擁有 Hint並拔除虛擬索引 [0]
// 🌟 系統設定區塊也套用相同的視覺優化 let cliCommand = currentPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
const isVirtualGroup = Object.keys(value).every(k => /^\[\d+\]$/.test(k));
const isVirtualIndex = /^\[\d+\]$/.test(key);
let displayName = key; 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>`;
let iconHtml = `<span class="tree-folder-icon"></span>`; 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>`;
if (isVirtualGroup) { const isFolder = typeof value === 'object' && value !== null && Object.keys(value).length > 0;
iconHtml = `<span style="margin-right: 5px; font-size: 16px;">📑</span>`; const onChangeEvent = isFolder ? 'toggleChildCheckboxes(this)' : 'updateParentCheckboxState(this)';
displayName = `${key} <span style="font-size: 12px; color: #7f8c8d; font-weight: normal;">(指令群組)</span>`; 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;">`;
} else if (isVirtualIndex) {
iconHtml = `<span style="margin-right: 5px; font-size: 14px;">📦</span>`; if (isFolder) {
displayName = `<span style="color: #95a5a6; font-weight: normal;">項目 ${key}</span>`; 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 += ` html += `
<details ontoggle="this.querySelector('summary').classList.toggle('is-open', this.open)"> <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" style="font-weight: bold; color: #2c3e50; margin-top: 5px; list-style: none; display: flex; align-items: center;"> <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;">
<input type="checkbox" value="${currentPath}" class="filter-checkbox" ${isChecked} <style>#details-${elementId} > summary::-webkit-details-marker { display: none; }</style>
onclick="event.stopPropagation();" onchange="toggleChildCheckboxes(this)" style="margin-right: 8px;"> ${checkboxHtml}
<span class="tree-chevron"></span> <span style="margin-right: 6px; display: inline-flex; align-items: center; width: 18px; height: 18px;">
${iconHtml} <span class="icon-closed" style="display: inline-block;">${iconClosed}</span>
<span style="flex-grow: 1;">${displayName}</span> <span class="icon-open" style="display: none;">${iconOpen}</span>
</span>
<span>${key}${folderSuffix}</span>
${expandCollapseBtns}
</summary> </summary>
<div class="filter-children-container"> <div class="filter-children-container" style="border-left: 1px dashed #bdc3c7; margin-left: 7px; padding-left: 16px;">
${buildRealFilterTree(value, currentPath, hiddenKeys)} ${buildRealFilterTree(value, currentPath, hiddenKeys, isCommandGroup)}
</div> </div>
</details> </details>
`; `;
} else { } else {
// 🌟 處理單一設定項目 (Leaf)
const isVirtualIndex = /^\[\d+\]$/.test(key); const isVirtualIndex = /^\[\d+\]$/.test(key);
let displayName = isVirtualIndex ? `<span style="color: #95a5a6; font-weight: normal;">項目 ${key}</span>` : `<b>${key}</b>`; let displayName = isVirtualIndex ? `<span style="color: #95a5a6; font-weight: normal;">項目 ${key}</span>` : `<b>${key}</b>`;
const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;"); const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;");
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>`;
// 💡 統一 UI 顯示:紅色禁止符號 + 紅色 no: + 灰色數值
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 += ` html += `
<div style="padding: 4px 0; color: #34495e; margin-left: 20px; border-left: 1px solid #ecf0f1; padding-left: 10px; display: flex; align-items: center;"> <div class="tree-leaf-node"
<input type="checkbox" value="${currentPath}" class="filter-checkbox" ${isChecked} onmouseover="this.style.backgroundColor='#f1f2f6'"
onchange="updateParentCheckboxState(this)" style="margin-right: 8px;"> onmouseout="this.style.backgroundColor='transparent'"
<span style="margin-right: 5px;">📄</span> ${displayName}: style="padding: 2px 0; color: #34495e; display: flex; align-items: center; margin-top: 2px; border-radius: 4px; transition: background-color 0.2s;"
<span style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${safeValue}</span> 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> </div>
`; `;
} }
@ -1984,3 +2109,37 @@ async function clearVisibleCache() {
} }
} }
/**
* no 指令原地變身為啟用狀態
* @param {string} elementId - 該節點的 elementId (例如 leaf-no-cable-...)
* @param {string} newCommand - 解除 no 之後的完整指令 (例如 "cable dynamic-secret tftp...")
*/
function transformNoToActive(elementId, newCommand) {
const wrapper = document.getElementById(`display-wrapper-${elementId}`);
if (!wrapper) return;
// 1. 解析指令:切出第一個字 (如 cable) 與後續指令
const parts = newCommand.trim().split(' ');
const firstWord = parts[0]; // "cable"
const restCommand = parts.slice(1).join(' '); // "dynamic-secret..."
// 💡 2. 換成視覺平衡的「空心綠圈圈 + 打勾」圖示
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>`;
// 3. 執行原地替換 (In-place Update)
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>
`;
// 4. 隱藏該行的編輯按鈕,避免使用者在歸檔前重複編輯產生邏輯錯誤
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';
}

View File

@ -1,3 +1,6 @@
{ {
"hidden_keys": [] "hidden_keys": [
"ssh",
"clock"
]
} }