feat: 完成 Tree 基礎編輯邏輯設計 & 父子層級 Reservation 防護機制
This commit is contained in:
parent
562625abb3
commit
c9a98d26c2
27
index.html
27
index.html
|
|
@ -250,23 +250,22 @@
|
||||||
|
|
||||||
<!-- 表單 4:完整設備配置樹狀圖 -->
|
<!-- 表單 4:完整設備配置樹狀圖 -->
|
||||||
<div id="form-full-config" class="task-form" style="display: none;">
|
<div id="form-full-config" class="task-form" style="display: none;">
|
||||||
<h3 style="margin-top: 0; font-size: 18px; color: #27ae60; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">🌳 完整設備配置樹狀圖</h3>
|
<!-- 💡 將標題與載入提示放在同一行 (使用 flexbox) -->
|
||||||
|
<h3 style="display: flex; align-items: center; margin-bottom: 15px;">
|
||||||
<div class="control-group" style="background: #eafaf1; padding: 15px; border-radius: 6px; border: 1px solid #a9dfbf;">
|
🌳 完整設備配置樹狀圖
|
||||||
<label style="font-weight: bold; color: #1e8449;">點擊下方按鈕,將抓取整台設備的完整配置並轉為樹狀結構:</label>
|
<!-- 💡 隱藏的載入提示,放在標題右邊 -->
|
||||||
<br>
|
<span id="loading-message" style="display: none; color: #f39c12; font-size: 14px; margin-left: 15px; font-weight: normal;">
|
||||||
<button onclick="fetchFullConfig()" style="background-color: #27ae60; color: white; margin-top: 10px; padding: 8px 16px;">📥 載入完整配置</button>
|
|
||||||
|
|
||||||
<div id="loading-message" style="display: none; color: #d35400; margin-top: 15px; font-weight: bold;">
|
|
||||||
⏳ 正在從設備抓取完整配置,可能需要 30~60 秒,請耐心稍候...
|
⏳ 正在從設備抓取完整配置,可能需要 30~60 秒,請耐心稍候...
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</h3>
|
||||||
|
|
||||||
<div id="tree-container" style="margin-top: 20px; border: 1px solid #bdc3c7; padding: 15px; border-radius: 5px; background-color: #ffffff; overflow-x: auto; max-height: 600px; overflow-y: auto;">
|
<!-- 💡 已經刪除了原本的綠色按鈕與說明文字 -->
|
||||||
<span style="color: #7f8c8d;">尚未載入資料。請點擊上方按鈕開始。</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<!-- 樹狀圖渲染區塊 -->
|
||||||
|
<div id="tree-container" style="background-color: #ffffff; padding: 15px; border-radius: 5px; border: 1px solid #e0e0e0; min-height: 100px;">
|
||||||
|
<span style="color: #7f8c8d;">尚未載入資料。請點擊上方「載入任務」按鈕開始。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
3
main.py
3
main.py
|
|
@ -5,7 +5,7 @@ from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
# 引入我們剛剛拆分出來的路由模組
|
# 引入我們剛剛拆分出來的路由模組
|
||||||
from routers import query, config, terminal
|
from routers import query, config, terminal, lock
|
||||||
|
|
||||||
app = FastAPI(title="Harmonic CMTS Manager", version="2.0")
|
app = FastAPI(title="Harmonic CMTS Manager", version="2.0")
|
||||||
|
|
||||||
|
|
@ -16,6 +16,7 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
app.include_router(query.router, prefix="/api/v1", tags=["Query"])
|
app.include_router(query.router, prefix="/api/v1", tags=["Query"])
|
||||||
app.include_router(config.router, prefix="/api/v1", tags=["Config"])
|
app.include_router(config.router, prefix="/api/v1", tags=["Config"])
|
||||||
app.include_router(terminal.router, tags=["Terminal"])
|
app.include_router(terminal.router, tags=["Terminal"])
|
||||||
|
app.include_router(lock.router, prefix="/api/v1/locks")
|
||||||
|
|
||||||
# 根目錄路由:回傳前端 UI
|
# 根目錄路由:回傳前端 UI
|
||||||
@app.get("/", response_class=HTMLResponse, tags=["UI"])
|
@app.get("/", response_class=HTMLResponse, tags=["UI"])
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
# --- routers/lock.py ---
|
||||||
|
import time
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 💡 全域鎖定表 (In-Memory Lock Table)
|
||||||
|
# 結構: { "path": {"user_id": "...", "username": "...", "expires_at": 1234567890.123} }
|
||||||
|
# ==========================================
|
||||||
|
ACTIVE_LOCKS: Dict[str, dict] = {}
|
||||||
|
|
||||||
|
# 鎖定超時時間 (秒):前端需在此時間內發送 Heartbeat,否則自動釋放
|
||||||
|
LOCK_TIMEOUT = 30
|
||||||
|
|
||||||
|
class LockAcquireReq(BaseModel):
|
||||||
|
path: str
|
||||||
|
user_id: str # 前端產生的 UUID (Session ID)
|
||||||
|
username: str # 登入的帳號 (用於 UI 提示,例如 "admin")
|
||||||
|
|
||||||
|
class LockActionReq(BaseModel):
|
||||||
|
path: str
|
||||||
|
user_id: str
|
||||||
|
|
||||||
|
def clean_expired_locks():
|
||||||
|
"""清除已經超時的鎖 (被動式清理)"""
|
||||||
|
current_time = time.time()
|
||||||
|
expired_paths = [path for path, lock in ACTIVE_LOCKS.items() if lock["expires_at"] < current_time]
|
||||||
|
for path in expired_paths:
|
||||||
|
del ACTIVE_LOCKS[path]
|
||||||
|
|
||||||
|
def is_path_locked_by_others(target_path: str, user_id: str) -> tuple[Optional[dict], str]:
|
||||||
|
"""
|
||||||
|
檢查路徑是否被其他人鎖定,並回傳 (衝突的鎖定資訊, 具體錯誤訊息)
|
||||||
|
"""
|
||||||
|
clean_expired_locks()
|
||||||
|
|
||||||
|
for locked_path, lock_info in ACTIVE_LOCKS.items():
|
||||||
|
if lock_info["user_id"] == user_id:
|
||||||
|
continue # 自己鎖定的不算衝突
|
||||||
|
|
||||||
|
# 💡 拆分判斷,回傳更精準的錯誤訊息
|
||||||
|
if target_path == locked_path:
|
||||||
|
return lock_info, f"此區塊正由 [{lock_info['username']}] 編輯中"
|
||||||
|
|
||||||
|
if target_path.startswith(locked_path + "."):
|
||||||
|
return lock_info, f"父層級 [{locked_path}] 已被 [{lock_info['username']}] 鎖定,無法編輯子區塊"
|
||||||
|
|
||||||
|
if locked_path.startswith(target_path + "."):
|
||||||
|
return lock_info, f"子層級 [{locked_path}] 已被 [{lock_info['username']}] 鎖定,無法鎖定整個父區塊"
|
||||||
|
|
||||||
|
return None, ""
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 💡 API Endpoints
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
@router.post("/acquire")
|
||||||
|
async def acquire_lock(req: LockAcquireReq):
|
||||||
|
"""請求鎖定特定路徑"""
|
||||||
|
# 接收回傳的鎖定資訊與具體訊息
|
||||||
|
conflict_lock, error_msg = is_path_locked_by_others(req.path, req.user_id)
|
||||||
|
|
||||||
|
if conflict_lock:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=error_msg # 💡 將精準的錯誤訊息傳給前端
|
||||||
|
)
|
||||||
|
|
||||||
|
# 寫入或更新鎖定狀態
|
||||||
|
ACTIVE_LOCKS[req.path] = {
|
||||||
|
"user_id": req.user_id,
|
||||||
|
"username": req.username,
|
||||||
|
"expires_at": time.time() + LOCK_TIMEOUT
|
||||||
|
}
|
||||||
|
return {"status": "success", "message": f"成功鎖定路徑: {req.path}"}
|
||||||
|
|
||||||
|
@router.post("/heartbeat")
|
||||||
|
async def heartbeat_lock(req: LockActionReq):
|
||||||
|
"""延長鎖定時間 (心跳偵測)"""
|
||||||
|
clean_expired_locks()
|
||||||
|
|
||||||
|
if req.path not in ACTIVE_LOCKS:
|
||||||
|
raise HTTPException(status_code=404, detail="鎖定已失效,請重新獲取")
|
||||||
|
|
||||||
|
if ACTIVE_LOCKS[req.path]["user_id"] != req.user_id:
|
||||||
|
raise HTTPException(status_code=403, detail="無權更新他人的鎖定")
|
||||||
|
|
||||||
|
# 延長鎖定時間
|
||||||
|
ACTIVE_LOCKS[req.path]["expires_at"] = time.time() + LOCK_TIMEOUT
|
||||||
|
return {"status": "success", "message": "心跳更新成功"}
|
||||||
|
|
||||||
|
@router.post("/release")
|
||||||
|
async def release_lock(req: LockActionReq):
|
||||||
|
"""主動釋放鎖定"""
|
||||||
|
if req.path in ACTIVE_LOCKS and ACTIVE_LOCKS[req.path]["user_id"] == req.user_id:
|
||||||
|
del ACTIVE_LOCKS[req.path]
|
||||||
|
return {"status": "success", "message": "鎖定已釋放"}
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def get_lock_status():
|
||||||
|
"""獲取目前所有被鎖定的路徑 (供前端 UI 標示 '編輯中' 狀態)"""
|
||||||
|
clean_expired_locks()
|
||||||
|
return {"status": "success", "data": ACTIVE_LOCKS}
|
||||||
228
static/app.js
228
static/app.js
|
|
@ -10,6 +10,170 @@ let isConnected = false;
|
||||||
window.onload = initTerminal;
|
window.onload = initTerminal;
|
||||||
window.onresize = () => { if (fitAddon) fitAddon.fit(); };
|
window.onresize = () => { if (fitAddon) fitAddon.fit(); };
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 💡 全域狀態與鎖定管理 (Lock Management)
|
||||||
|
// ==========================================
|
||||||
|
const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9);
|
||||||
|
const ACTIVE_HEARTBEATS = {}; // 記錄正在打心跳的計時器: { "path": intervalId }
|
||||||
|
|
||||||
|
async function sendHeartbeat(path) {
|
||||||
|
try {
|
||||||
|
await fetch('/api/v1/locks/heartbeat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path: path, user_id: SESSION_USER_ID })
|
||||||
|
});
|
||||||
|
} catch (error) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 💡 資料夾層級的編輯模式邏輯 (Node-Level Lock)
|
||||||
|
// ==========================================
|
||||||
|
async function startEditFolder(path, elementId) {
|
||||||
|
const connInfo = getGlobalConnectionInfo();
|
||||||
|
const username = connInfo ? connInfo.user : 'admin';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/locks/acquire', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path: path, user_id: SESSION_USER_ID, username: username })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.status === 409) {
|
||||||
|
alert(`⚠️ ${result.detail}`); // 後端擋下衝突時顯示警告
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === 'success') {
|
||||||
|
// 啟動心跳
|
||||||
|
ACTIVE_HEARTBEATS[path] = setInterval(() => sendHeartbeat(path), 15000);
|
||||||
|
|
||||||
|
// UI 切換
|
||||||
|
document.getElementById(`edit-btn-${elementId}`).style.display = 'none';
|
||||||
|
document.getElementById(`actions-${elementId}`).style.display = 'inline-block';
|
||||||
|
document.getElementById(`details-${elementId}`).open = true;
|
||||||
|
|
||||||
|
// 轉為輸入框
|
||||||
|
const contentDiv = document.getElementById(`content-${elementId}`);
|
||||||
|
const leafContainers = contentDiv.querySelectorAll('.leaf-container');
|
||||||
|
|
||||||
|
leafContainers.forEach(container => {
|
||||||
|
const origVal = container.getAttribute('data-original');
|
||||||
|
const nodePath = container.getAttribute('data-path');
|
||||||
|
container.innerHTML = `
|
||||||
|
<input type="text" class="edit-input" data-path="${nodePath}" value="${origVal}"
|
||||||
|
style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; outline: none; margin-left: 5px;">
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert("❌ 無法連線到鎖定伺服器:" + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelEditFolder(path, elementId) {
|
||||||
|
if (ACTIVE_HEARTBEATS[path]) {
|
||||||
|
clearInterval(ACTIVE_HEARTBEATS[path]);
|
||||||
|
delete ACTIVE_HEARTBEATS[path];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch('/api/v1/locks/release', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path: path, user_id: SESSION_USER_ID })
|
||||||
|
});
|
||||||
|
} catch (error) {}
|
||||||
|
|
||||||
|
document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block';
|
||||||
|
document.getElementById(`actions-${elementId}`).style.display = 'none';
|
||||||
|
|
||||||
|
const contentDiv = document.getElementById(`content-${elementId}`);
|
||||||
|
const leafContainers = contentDiv.querySelectorAll('.leaf-container');
|
||||||
|
|
||||||
|
leafContainers.forEach(container => {
|
||||||
|
const origVal = container.getAttribute('data-original');
|
||||||
|
container.innerHTML = `
|
||||||
|
<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${origVal}</span>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewFolderCLI(path, elementId) {
|
||||||
|
const contentDiv = document.getElementById(`content-${elementId}`);
|
||||||
|
const inputs = contentDiv.querySelectorAll('.edit-input');
|
||||||
|
|
||||||
|
let changes = [];
|
||||||
|
|
||||||
|
// 掃描所有輸入框,找出被修改過的值
|
||||||
|
inputs.forEach(input => {
|
||||||
|
const nodePath = input.getAttribute('data-path');
|
||||||
|
const newVal = input.value;
|
||||||
|
const origVal = input.parentElement.getAttribute('data-original');
|
||||||
|
|
||||||
|
if (newVal !== origVal) {
|
||||||
|
changes.push(`- ${nodePath}:\n [舊] ${origVal} ➡️ [新] ${newVal}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (changes.length === 0) {
|
||||||
|
alert("您沒有修改任何數值!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
alert(`【Phase 3 預覽】\n鎖定區塊: ${path}\n\n偵測到的修改:\n${changes.join('\n\n')}\n\n即將實作轉譯為 CMTS CLI 的功能!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 💡 編輯模式 UI 切換邏輯
|
||||||
|
// ==========================================
|
||||||
|
function renderEditModeUI(path, currentValue, elementId) {
|
||||||
|
const container = document.getElementById(elementId);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// 將原本的文字替換為 Input 與操作按鈕
|
||||||
|
// 注意:這裡將單引號跳脫,避免 HTML 屬性解析錯誤
|
||||||
|
const safeValue = currentValue.replace(/'/g, "'");
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<input type="text" id="input-${elementId}" value="${safeValue}"
|
||||||
|
style="padding: 4px 8px; border: 2px solid #3498db; border-radius: 4px; font-family: Courier New, monospace; font-size: 14px; width: 250px; outline: none;">
|
||||||
|
|
||||||
|
<button onclick="previewCLI('${path}', '${elementId}')"
|
||||||
|
style="margin-left: 8px; background-color: #27ae60; color: white; border: none; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 13px;">
|
||||||
|
✅ 預覽指令
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onclick="cancelEdit('${path}', '${safeValue}', '${elementId}')"
|
||||||
|
style="margin-left: 4px; background-color: #e74c3c; color: white; border: none; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 13px;">
|
||||||
|
❌ 取消
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreViewModeUI(path, originalValue, elementId) {
|
||||||
|
const container = document.getElementById(elementId);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// 恢復成原本的文字與編輯按鈕
|
||||||
|
const safeValue = originalValue.replace(/'/g, "'");
|
||||||
|
container.innerHTML = `
|
||||||
|
<span style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${originalValue}</span>
|
||||||
|
<span onclick="startEdit('${path}', '${safeValue}', '${elementId}')"
|
||||||
|
style="margin-left: 10px; cursor: pointer; font-size: 14px; opacity: 0.6;"
|
||||||
|
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.6" title="編輯此項目">✏️</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3 的預留函數:預覽 CLI 指令
|
||||||
|
function previewCLI(path, elementId) {
|
||||||
|
const newValue = document.getElementById(`input-${elementId}`).value;
|
||||||
|
alert(`【Phase 3 預覽】\n路徑: ${path}\n新值: ${newValue}\n\n即將實作轉譯為 CMTS CLI 的功能!`);
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 2. UI 互動與頁籤切換
|
// 2. UI 互動與頁籤切換
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
@ -584,9 +748,9 @@ async function executeBondingConfig() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 💡 6. 完整設備配置樹狀圖邏輯 (從 HTML 移出)
|
// 💡 6. 完整設備配置樹狀圖邏輯 (支援資料夾層級鎖定)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
function buildTree(data) {
|
function buildTree(data, parentPath = "") {
|
||||||
if (typeof data !== 'object' || data === null) {
|
if (typeof data !== 'object' || data === null) {
|
||||||
return `<span>${data}</span>`;
|
return `<span>${data}</span>`;
|
||||||
}
|
}
|
||||||
|
|
@ -594,19 +758,48 @@ function buildTree(data) {
|
||||||
let html = '<div style="margin-left: 20px;">';
|
let html = '<div style="margin-left: 20px;">';
|
||||||
for (const key in data) {
|
for (const key in data) {
|
||||||
const value = data[key];
|
const value = data[key];
|
||||||
|
const currentPath = parentPath ? `${parentPath}.${key}` : key;
|
||||||
|
|
||||||
if (typeof value === 'object' && value !== null && Object.keys(value).length > 0) {
|
if (typeof value === 'object' && value !== null && Object.keys(value).length > 0) {
|
||||||
|
// 📁 渲染資料夾 (加入編輯與操作按鈕)
|
||||||
|
const elementId = `folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<details>
|
<details id="details-${elementId}">
|
||||||
<summary style="cursor: pointer; font-weight: bold; color: #2c3e50; margin-top: 5px; padding: 2px 0;">
|
<summary style="cursor: pointer; font-weight: bold; color: #2c3e50; margin-top: 5px; padding: 2px 0; display: flex; align-items: center;">
|
||||||
📁 ${key}
|
<span style="margin-right: 5px;">📁</span> ${key}
|
||||||
|
|
||||||
|
<!-- ✏️ 資料夾專屬編輯按鈕 -->
|
||||||
|
<span id="edit-btn-${elementId}" onclick="event.stopPropagation(); event.preventDefault(); startEditFolder('${currentPath}', '${elementId}')"
|
||||||
|
style="margin-left: 10px; cursor: pointer; font-size: 14px; opacity: 0.3; transition: opacity 0.2s;"
|
||||||
|
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3" title="鎖定並編輯此區塊">
|
||||||
|
✏️
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- 儲存與取消按鈕 (預設隱藏) -->
|
||||||
|
<span id="actions-${elementId}" style="display:none; margin-left: 10px;">
|
||||||
|
<button onclick="event.stopPropagation(); event.preventDefault(); previewFolderCLI('${currentPath}', '${elementId}')" style="background-color: #27ae60; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">✅ 預覽指令</button>
|
||||||
|
<button onclick="event.stopPropagation(); event.preventDefault(); cancelEditFolder('${currentPath}', '${elementId}')" style="background-color: #e74c3c; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px; margin-left: 5px;">❌ 取消</button>
|
||||||
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
${buildTree(value)}
|
|
||||||
|
<!-- 💡 包裝子內容,方便後續用 JS 抓取裡面的葉節點 -->
|
||||||
|
<div id="content-${elementId}">
|
||||||
|
${buildTree(value, currentPath)}
|
||||||
|
</div>
|
||||||
</details>
|
</details>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
|
// 📄 渲染檔案 (移除獨立按鈕,改為埋入 data 屬性供父節點控制)
|
||||||
|
const safeValue = (value === null ? '' : value).toString().replace(/"/g, """);
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div style="padding: 2px 0; color: #34495e; margin-left: 20px; border-left: 1px solid #ecf0f1; padding-left: 10px;">
|
<div style="padding: 4px 0; color: #34495e; margin-left: 20px; border-left: 1px solid #ecf0f1; padding-left: 10px; display: flex; align-items: center;">
|
||||||
📄 <b>${key}</b>: <span style="color: #16a085;">${value === null ? '' : value}</span>
|
<span style="margin-right: 5px;">📄</span> <b>${key}</b>:
|
||||||
|
|
||||||
|
<span class="leaf-container" data-path="${currentPath}" data-original="${safeValue}" style="display: inline-flex; align-items: center; min-height: 24px;">
|
||||||
|
<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${value === null ? '' : value}</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
@ -622,22 +815,29 @@ async function fetchFullConfig() {
|
||||||
const connInfo = getGlobalConnectionInfo();
|
const connInfo = getGlobalConnectionInfo();
|
||||||
if (!connInfo) return;
|
if (!connInfo) return;
|
||||||
|
|
||||||
loadingMsg.style.display = 'block';
|
// 💡 1. 顯示標題旁邊的載入提示 (改為 inline-block 讓它排在同一行)
|
||||||
|
if (loadingMsg) loadingMsg.style.display = 'inline-block';
|
||||||
|
|
||||||
|
// 💡 2. 清空樹狀圖區塊,保持畫面乾淨
|
||||||
|
if (treeContainer) {
|
||||||
treeContainer.innerHTML = '';
|
treeContainer.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/cmts-full-config?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}`);
|
const url = `/api/v1/cmts-full-config?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}`;
|
||||||
|
const response = await fetch(url);
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (result.status === 'success') {
|
if (result.status === 'success') {
|
||||||
treeContainer.innerHTML = buildTree(result.data);
|
if (treeContainer) treeContainer.innerHTML = buildTree(result.data);
|
||||||
} else {
|
} else {
|
||||||
treeContainer.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 錯誤: ${result.message}</div>`;
|
if (treeContainer) treeContainer.innerHTML = `<div style="color: #c0392b; font-weight: bold; margin: 20px;">❌ 錯誤: ${result.message}</div>`;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
treeContainer.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 連線失敗: ${error.message}</div>`;
|
if (treeContainer) treeContainer.innerHTML = `<div style="color: #c0392b; font-weight: bold; margin: 20px;">❌ 連線或解析失敗: ${error.message}</div>`;
|
||||||
} finally {
|
} finally {
|
||||||
loadingMsg.style.display = 'none';
|
// 💡 3. 完成後隱藏載入提示
|
||||||
|
if (loadingMsg) loadingMsg.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue