Merge branch 'enhancement/ux'

This commit is contained in:
swpa 2026-05-18 15:12:20 +08:00
commit df90aa50fe
2 changed files with 164 additions and 100 deletions

View File

@ -1,29 +1,67 @@
# --- routers/leaf_options.py --- # --- routers/leaf_options.py ---
from fastapi import APIRouter, BackgroundTasks, HTTPException, Body from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Body
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
from typing import List from typing import List
import json, os, asyncio, time, re import json, os, asyncio, re
# 🌟 引入新的 asyncssh 版本爬蟲
from cmts_scraper import sync_cmts_leaves_async from cmts_scraper import sync_cmts_leaves_async
from shared import CMTS_DEVICE from shared import CMTS_DEVICE
# 🌟 移除原本硬寫的 prefix="/api/v1",只留下 tags
router = APIRouter(tags=["Options"]) router = APIRouter(tags=["Options"])
CACHE_FILE = "cmts_leaf_options_cache.json" CACHE_FILE = "cmts_leaf_options_cache.json"
# 🌟 1. 改用全域布林變數,狀態切換更即時,消滅非同步時間差 # ==========================================
# 🌟 全域廣播機制 (SSE)
# ==========================================
IS_SCANNING = False IS_SCANNING = False
active_clients = set()
async def broadcast_message(message_dict: dict):
"""將訊息推播給所有連線中的前端"""
dead_clients = set()
# 🌟 關鍵修復 1SSE 協定嚴格要求必須以 "data: " 開頭,並以 "\n\n" 結尾
message_str = f"data: {json.dumps(message_dict)}\n\n"
for client_queue in active_clients:
try:
await client_queue.put(message_str)
except Exception:
dead_clients.add(client_queue)
for dead in dead_clients:
active_clients.discard(dead)
@router.get("/cmts-leaf-options/stream")
async def sse_stream(request: Request):
"""前端一載入就會連上這個路由,持續監聽廣播"""
client_queue = asyncio.Queue()
active_clients.add(client_queue)
async def event_generator():
try:
while True:
try:
message = await asyncio.wait_for(client_queue.get(), timeout=2.0)
yield message
except asyncio.TimeoutError:
if await request.is_disconnected():
break
# 🌟 關鍵修復 2每 2 秒發送一個空註解,防止瀏覽器因閒置而自動斷線
yield ": keepalive\n\n"
finally:
active_clients.discard(client_queue)
return StreamingResponse(event_generator(), media_type="text/event-stream")
# ==========================================
# 🌟 新增:讓前端查詢目前是否有人正在掃描
# ==========================================
@router.get("/scan-status") @router.get("/scan-status")
async def get_scan_status(): async def get_scan_status():
global IS_SCANNING global IS_SCANNING
return {"is_scanning": IS_SCANNING} return {"is_scanning": IS_SCANNING}
# ==========================================
# 🌟 快取讀取與背景掃描任務
# ==========================================
class SyncOptionsRequest(BaseModel): class SyncOptionsRequest(BaseModel):
leaf_paths: List[str] leaf_paths: List[str]
@ -33,25 +71,43 @@ async def get_leaf_options():
with open(CACHE_FILE, "r", encoding="utf-8") as f: with open(CACHE_FILE, "r", encoding="utf-8") as f:
return json.load(f) return json.load(f)
async def run_scan_task(paths: List[str]):
"""在背景執行的爬蟲任務,並將進度廣播出去"""
global IS_SCANNING
try:
await broadcast_message({"event": "scan_start"})
# 呼叫爬蟲 (帶入設備連線資訊)
async for chunk in sync_cmts_leaves_async(
host=CMTS_DEVICE.get("host"),
username=CMTS_DEVICE.get("username"),
password=CMTS_DEVICE.get("password"),
leaf_paths=paths
):
if isinstance(chunk, str):
await broadcast_message(json.loads(chunk))
else:
await broadcast_message(chunk)
await broadcast_message({"event": "done"})
except Exception as e:
await broadcast_message({"event": "error", "message": str(e)})
finally:
IS_SCANNING = False
@router.post("/cmts-leaf-options/sync") @router.post("/cmts-leaf-options/sync")
async def sync_leaf_options(request: SyncOptionsRequest): async def sync_leaf_options(request: SyncOptionsRequest, background_tasks: BackgroundTasks):
global IS_SCANNING # 宣告我們要修改全域變數 """前端點擊掃描時,只負責觸發背景任務並立即回傳 JSON"""
global IS_SCANNING
if not request.leaf_paths: if not request.leaf_paths:
raise HTTPException(status_code=400) raise HTTPException(status_code=400)
# 🌟 2. 第一時間檢查並「立即」阻擋
if IS_SCANNING: if IS_SCANNING:
async def busy_stream(): return {"status": "busy", "message": "⚠️ 掃描任務正在執行中,請勿重複點擊!"}
yield json.dumps({"event": "error", "message": "⚠️ 掃描任務正在執行中,請勿重複點擊!"}) + "\n"
return StreamingResponse(busy_stream(), media_type="text/event-stream")
# 如果沒人掃描,立刻把狀態改為 True (在建立串流之前就上鎖)
IS_SCANNING = True IS_SCANNING = True
async def event_generator():
global IS_SCANNING
try:
cmts_query_paths = [] cmts_query_paths = []
for p in request.leaf_paths: for p in request.leaf_paths:
clean_p = p.replace("::", " ") clean_p = p.replace("::", " ")
@ -60,45 +116,28 @@ async def sync_leaf_options(request: SyncOptionsRequest):
cmts_query_paths = list(dict.fromkeys(cmts_query_paths)) cmts_query_paths = list(dict.fromkeys(cmts_query_paths))
# 🌟 因為爬蟲自己會存檔,我們只要專心把進度轉發給前端就好! # 將任務丟入背景執行,並立刻回傳 JSON 給前端
async for chunk in sync_cmts_leaves_async( background_tasks.add_task(run_scan_task, cmts_query_paths)
host=CMTS_DEVICE.get("host"), return {"status": "started"}
username=CMTS_DEVICE.get("username"),
password=CMTS_DEVICE.get("password"),
leaf_paths=cmts_query_paths
):
yield chunk
except Exception as e: # ==========================================
yield json.dumps({"event": "error", "message": str(e)}) + "\n" # 🌟 清除快取 API (對應 api.js 的 POST 請求)
# ==========================================
finally:
# 🌟 3. 無論成功、失敗、或使用者提早關閉網頁,都確保會解鎖
IS_SCANNING = False
return StreamingResponse(event_generator(), media_type="text/event-stream")
# 🌟 新增:清除特定選項快取的 API (使用您原本定義的 CACHE_FILE)
@router.post("/clear_cache") @router.post("/clear_cache")
async def clear_specific_cache(paths_to_clear: list = Body(...)): async def clear_specific_cache(paths_to_clear: list = Body(...)):
cleared_count = 0 cleared_count = 0
# 1. 檢查快取檔案是否存在
if not os.path.exists(CACHE_FILE): if not os.path.exists(CACHE_FILE):
return {"status": "success", "cleared_count": 0, "message": "快取檔案不存在"} return {"status": "success", "cleared_count": 0, "message": "快取檔案不存在"}
try: try:
# 2. 讀取現有的 JSON 快取資料
with open(CACHE_FILE, "r", encoding="utf-8") as f: with open(CACHE_FILE, "r", encoding="utf-8") as f:
cache_data = json.load(f) cache_data = json.load(f)
# 3. 逐一比對並刪除指定的路徑
for path in paths_to_clear: for path in paths_to_clear:
if path in cache_data: if path in cache_data:
del cache_data[path] del cache_data[path]
cleared_count += 1 cleared_count += 1
# 4. 如果有刪除資料,將更新後的內容寫回 JSON 檔案
if cleared_count > 0: if cleared_count > 0:
with open(CACHE_FILE, "w", encoding="utf-8") as f: with open(CACHE_FILE, "w", encoding="utf-8") as f:
json.dump(cache_data, f, ensure_ascii=False, indent=4) json.dump(cache_data, f, ensure_ascii=False, indent=4)

View File

@ -37,16 +37,67 @@ import {
// ============================================================================ // ============================================================================
// 🌟 1. 全域生命週期與閒置偵測 (Lifecycle & Idle Detection) // 🌟 1. 全域生命週期、閒置偵測與 SSE 廣播監聽
// ============================================================================ // ============================================================================
let idleTimer; let idleTimer;
const IDLE_TIMEOUT = 300000; // 5 分鐘 (毫秒) const IDLE_TIMEOUT = 300000; // 5 分鐘 (毫秒)
let globalSSE = null;
// 初始化全域 SSE 監聽器
function initGlobalSSE() {
if (globalSSE) return;
globalSSE = new EventSource('/api/v1/cmts-leaf-options/stream');
globalSSE.onmessage = (event) => {
const data = JSON.parse(event.data);
const statusEl = document.getElementById('scan-status');
// 🌟 關鍵修復:只要收到「開始」或「進度」訊號,一律強制鎖定按鈕!(確保中途加入的使用者也會被鎖定)
if (data.event === 'scan_start' || data.event === 'progress') {
setAdminButtonsState(true, "⏳ 系統更新中...");
}
if (data.event === 'scan_start') {
if (statusEl) {
statusEl.innerHTML = `⏳ 系統正在背景更新快取,請稍候...`;
statusEl.style.color = "#f39c12";
}
}
else if (data.event === 'progress') {
if (statusEl) {
statusEl.innerHTML = `⏳ 背景掃描進度:<b>${data.current} / ${data.total}</b> (正在處理: ${data.current_path})`;
statusEl.style.color = "#f39c12";
}
}
else if (data.event === 'done') {
if (statusEl) {
statusEl.innerHTML = `✅ 掃描完美達成!快取已全面更新。`;
statusEl.style.color = "#27ae60";
setTimeout(() => statusEl.textContent = "", 5000);
}
// 掃描完成才解鎖
setAdminButtonsState(false);
localStorage.removeItem('scanLockUntil');
}
else if (data.event === 'error') {
if (statusEl) {
statusEl.innerHTML = `❌ 錯誤: ${data.message}`;
statusEl.style.color = "#e74c3c";
}
// 發生錯誤也要解鎖
setAdminButtonsState(false);
localStorage.removeItem('scanLockUntil');
}
};
}
// 頁面載入與縮放初始化 // 頁面載入與縮放初始化
window.onload = () => { window.onload = () => {
initTerminal(); initTerminal();
resetIdleTimer(); // 頁面載入時啟動閒置計時器 resetIdleTimer(); // 頁面載入時啟動閒置計時器
initGlobalSSE(); // 啟動全域廣播監聽
}; };
window.onresize = () => { if (typeof fitAddon !== 'undefined' && fitAddon) fitAddon.fit(); }; window.onresize = () => { if (typeof fitAddon !== 'undefined' && fitAddon) fitAddon.fit(); };
@ -56,12 +107,10 @@ function resetIdleTimer() {
idleTimer = setTimeout(releaseAllLocks, IDLE_TIMEOUT); idleTimer = setTimeout(releaseAllLocks, IDLE_TIMEOUT);
} }
// 監聽使用者的互動事件,只要有動作就重置計時器
window.addEventListener('mousemove', resetIdleTimer); window.addEventListener('mousemove', resetIdleTimer);
window.addEventListener('keydown', resetIdleTimer); window.addEventListener('keydown', resetIdleTimer);
window.addEventListener('click', resetIdleTimer); window.addEventListener('click', resetIdleTimer);
// ============================================================================ // ============================================================================
// 🌟 2. 頁籤切換與 UI 狀態控制 (Tabs & UI State) // 🌟 2. 頁籤切換與 UI 狀態控制 (Tabs & UI State)
// ============================================================================ // ============================================================================
@ -317,7 +366,7 @@ async function enhanceInputWithOptions(path, elementId) {
} }
// ============================================================================ // ============================================================================
// 🌟 核心功能:執行掃描 (支援局部 / 全域) // 🌟 核心功能:執行掃描 (廣播升級版)
// ============================================================================ // ============================================================================
async function performScan(isGlobal) { async function performScan(isGlobal) {
const isSomeoneScanning = await apiGetScanStatus(); const isSomeoneScanning = await apiGetScanStatus();
@ -330,13 +379,6 @@ async function performScan(isGlobal) {
if (!treeContainer) return; if (!treeContainer) return;
const statusEl = document.getElementById('scan-status'); const statusEl = document.getElementById('scan-status');
const LOCK_DURATION_MS = 60000;
localStorage.setItem('scanLockUntil', Date.now() + LOCK_DURATION_MS);
// 鎖定所有 4 顆按鈕
setAdminButtonsState(true, "⏳ 掃描冷卻中...");
statusEl.innerHTML = `⏳ 正在比對快取資料...`;
statusEl.style.color = "#f39c12";
try { try {
const optRes = await fetch('/api/v1/cmts-leaf-options'); const optRes = await fetch('/api/v1/cmts-leaf-options');
@ -347,7 +389,6 @@ async function performScan(isGlobal) {
allContainers.forEach(container => { allContainers.forEach(container => {
const isHiddenByFolder = container.closest('details:not([open])') !== null; const isHiddenByFolder = container.closest('details:not([open])') !== null;
// 如果是全域掃描,或是該節點沒有被收合(局部可見),就納入檢查
if (isGlobal || !isHiddenByFolder) { if (isGlobal || !isHiddenByFolder) {
const path = container.getAttribute('data-path'); const path = container.getAttribute('data-path');
if (path) { if (path) {
@ -364,64 +405,41 @@ async function performScan(isGlobal) {
statusEl.textContent = isGlobal ? "✅ 全域所有欄位都已有最新選項,無需掃描!" : "✅ 局部展開的欄位都已有最新選項,無需掃描!"; statusEl.textContent = isGlobal ? "✅ 全域所有欄位都已有最新選項,無需掃描!" : "✅ 局部展開的欄位都已有最新選項,無需掃描!";
statusEl.style.color = "#27ae60"; statusEl.style.color = "#27ae60";
setTimeout(() => statusEl.textContent = "", 3000); setTimeout(() => statusEl.textContent = "", 3000);
setAdminButtonsState(false);
localStorage.removeItem('scanLockUntil');
return; return;
} }
statusEl.innerHTML = `⏳ 正在建立即時連線... (共需掃描 ${pathsToSync.length} 個項目)`; // 直接發送 POST 請求觸發背景任務UI 變化交給 SSE 處理
statusEl.style.color = "#e67e22"; const response = await fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST',
await apiSyncLeafOptionsStream(pathsToSync, (data) => { headers: { 'Content-Type': 'application/json' },
if (data.event === 'progress') { body: JSON.stringify({ leaf_paths: pathsToSync })
statusEl.innerHTML = `⏳ 背景掃描進度:<b>${data.current} / ${data.total}</b> (正在處理: ${data.current_path})`;
statusEl.style.color = "#f39c12";
}
else if (data.event === 'done') {
statusEl.innerHTML = `✅ 掃描完美達成!快取已全面更新。`;
statusEl.style.color = "#27ae60";
setAdminButtonsState(false);
localStorage.removeItem('scanLockUntil');
setTimeout(() => statusEl.textContent = "", 5000);
}
else if (data.event === 'error') {
statusEl.innerHTML = `❌ 錯誤: ${data.message}`;
statusEl.style.color = "#e74c3c";
setAdminButtonsState(false);
localStorage.removeItem('scanLockUntil');
}
}); });
const result = await response.json();
if (result.status === 'busy') {
alert(result.message);
}
} catch (error) { } catch (error) {
console.error("掃描請求失敗:", error); console.error("掃描請求發送失敗:", error);
statusEl.textContent = "❌ 掃描請求發送失敗,請檢查網路連線。"; statusEl.textContent = "❌ 掃描請求發送失敗,請檢查網路連線。";
statusEl.style.color = "#e74c3c"; statusEl.style.color = "#e74c3c";
setAdminButtonsState(false);
localStorage.removeItem('scanLockUntil');
} }
} }
// ============================================================================ // ============================================================================
// 🌟 核心功能:清除快取 (支援局部 / 全域) // 🌟 核心功能:清除快取 (廣播升級版)
// ============================================================================ // ============================================================================
async function performClear(isGlobal) { async function performClear(isGlobal) {
const isSomeoneScanning = await apiGetScanStatus(); const isSomeoneScanning = await apiGetScanStatus();
if (isSomeoneScanning) { if (isSomeoneScanning) {
alert("⚠️ 系統正在進行全域選項掃描更新中!\n為避免資料衝突請稍候掃描完成再執行清除動作。"); alert("⚠️ 系統正在進行全域選項掃描更新中!\n為避免資料衝突請稍候掃描完成再執行清除動作。");
lockScanButton(60000);
const statusEl = document.getElementById('scan-status');
if (statusEl) {
statusEl.innerHTML = `⏳ 其他使用者正在背景更新快取,請稍候...`;
statusEl.style.color = "#e67e22";
}
return; return;
} }
let pathsToClear = []; let pathsToClear = [];
if (isGlobal) { if (isGlobal) {
// 全域清除:直接向後端要所有的快取 Key
try { try {
const optRes = await fetch('/api/v1/cmts-leaf-options'); const optRes = await fetch('/api/v1/cmts-leaf-options');
const optData = await optRes.json(); const optData = await optRes.json();
@ -431,7 +449,6 @@ async function performClear(isGlobal) {
return alert("❌ 無法取得全域快取清單。"); return alert("❌ 無法取得全域快取清單。");
} }
} else { } else {
// 局部清除:只抓畫面上展開的節點
const elements = document.querySelectorAll('.leaf-container'); const elements = document.querySelectorAll('.leaf-container');
elements.forEach(el => { elements.forEach(el => {
const isHiddenByFolder = el.closest('details:not([open])') !== null; const isHiddenByFolder = el.closest('details:not([open])') !== null;
@ -449,13 +466,21 @@ async function performClear(isGlobal) {
const msg = isGlobal const msg = isGlobal
? `確定要清除「全域」共 ${pathsToClear.length} 個選項的快取嗎?\n(這將會清空所有已儲存的下拉選單資料)` ? `確定要清除「全域」共 ${pathsToClear.length} 個選項的快取嗎?\n(這將會清空所有已儲存的下拉選單資料)`
: `確定要清除局部 ${pathsToClear.length} 個選項的快取嗎?\n(清除後請重新點擊「掃描局部缺失選項」)`; : `確定要清除局部 ${pathsToClear.length} 個選項的快取嗎?\n(清除後將自動為您重新掃描)`;
if (!confirm(msg)) return; if (!confirm(msg)) return;
try { try {
const result = await apiClearCache(pathsToClear); const result = await apiClearCache(pathsToClear);
alert(`✅ 成功清除 ${result.cleared_count} 筆快取!`); const statusEl = document.getElementById('scan-status');
if (statusEl) {
statusEl.textContent = `✅ 成功清除 ${result.cleared_count} 筆快取!準備重新掃描...`;
statusEl.style.color = "#27ae60";
}
// 清除完畢後,自動呼叫簡化版的 performScan
await performScan(isGlobal);
} catch (error) { } catch (error) {
console.error("清除快取失敗:", error); console.error("清除快取失敗:", error);
alert("❌ 清除快取失敗,請檢查網路連線或後端 API 是否正確啟動。"); alert("❌ 清除快取失敗,請檢查網路連線或後端 API 是否正確啟動。");