From d7d6e63a7c16f9743dd7597f318e9b4cc53eca70 Mon Sep 17 00:00:00 2001 From: swpa Date: Mon, 18 May 2026 15:10:38 +0800 Subject: [PATCH] =?UTF-8?q?enha:=20=E4=BF=AE=E6=94=B9=E9=80=B2=E5=BA=A6?= =?UTF-8?q?=E6=A2=9D=E7=82=BA=E5=85=A8=E5=BB=A3=E6=92=AD=E6=A9=9F=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- routers/leaf_options.py | 139 +++++++++++++++++++++++++--------------- static/app.js | 125 +++++++++++++++++++++--------------- 2 files changed, 164 insertions(+), 100 deletions(-) diff --git a/routers/leaf_options.py b/routers/leaf_options.py index 95258a4..3048e7e 100644 --- a/routers/leaf_options.py +++ b/routers/leaf_options.py @@ -1,29 +1,67 @@ # --- 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 pydantic import BaseModel 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 shared import CMTS_DEVICE -# 🌟 移除原本硬寫的 prefix="/api/v1",只留下 tags router = APIRouter(tags=["Options"]) CACHE_FILE = "cmts_leaf_options_cache.json" -# 🌟 1. 改用全域布林變數,狀態切換更即時,消滅非同步時間差 +# ========================================== +# 🌟 全域廣播機制 (SSE) +# ========================================== IS_SCANNING = False +active_clients = set() + +async def broadcast_message(message_dict: dict): + """將訊息推播給所有連線中的前端""" + dead_clients = set() + + # 🌟 關鍵修復 1:SSE 協定嚴格要求必須以 "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") async def get_scan_status(): global IS_SCANNING return {"is_scanning": IS_SCANNING} +# ========================================== +# 🌟 快取讀取與背景掃描任務 +# ========================================== class SyncOptionsRequest(BaseModel): leaf_paths: List[str] @@ -33,72 +71,73 @@ async def get_leaf_options(): with open(CACHE_FILE, "r", encoding="utf-8") as 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") -async def sync_leaf_options(request: SyncOptionsRequest): - global IS_SCANNING # 宣告我們要修改全域變數 +async def sync_leaf_options(request: SyncOptionsRequest, background_tasks: BackgroundTasks): + """前端點擊掃描時,只負責觸發背景任務並立即回傳 JSON""" + global IS_SCANNING if not request.leaf_paths: raise HTTPException(status_code=400) - # 🌟 2. 第一時間檢查並「立即」阻擋 if IS_SCANNING: - async def busy_stream(): - yield json.dumps({"event": "error", "message": "⚠️ 掃描任務正在執行中,請勿重複點擊!"}) + "\n" - return StreamingResponse(busy_stream(), media_type="text/event-stream") + return {"status": "busy", "message": "⚠️ 掃描任務正在執行中,請勿重複點擊!"} - # 如果沒人掃描,立刻把狀態改為 True (在建立串流之前就上鎖) IS_SCANNING = True + + cmts_query_paths = [] + for p in request.leaf_paths: + clean_p = p.replace("::", " ") + clean_p = re.sub(r"\s*\[\d+\]", "", clean_p) + cmts_query_paths.append(clean_p) + + cmts_query_paths = list(dict.fromkeys(cmts_query_paths)) + + # 將任務丟入背景執行,並立刻回傳 JSON 給前端 + background_tasks.add_task(run_scan_task, cmts_query_paths) + return {"status": "started"} - async def event_generator(): - global IS_SCANNING - try: - cmts_query_paths = [] - for p in request.leaf_paths: - clean_p = p.replace("::", " ") - clean_p = re.sub(r"\s*\[\d+\]", "", clean_p) - cmts_query_paths.append(clean_p) - - cmts_query_paths = list(dict.fromkeys(cmts_query_paths)) - - # 🌟 因為爬蟲自己會存檔,我們只要專心把進度轉發給前端就好! - 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=cmts_query_paths - ): - yield chunk - - except Exception as e: - yield json.dumps({"event": "error", "message": str(e)}) + "\n" - - finally: - # 🌟 3. 無論成功、失敗、或使用者提早關閉網頁,都確保會解鎖 - IS_SCANNING = False - - return StreamingResponse(event_generator(), media_type="text/event-stream") - -# 🌟 新增:清除特定選項快取的 API (使用您原本定義的 CACHE_FILE) +# ========================================== +# 🌟 清除快取 API (對應 api.js 的 POST 請求) +# ========================================== @router.post("/clear_cache") async def clear_specific_cache(paths_to_clear: list = Body(...)): cleared_count = 0 - - # 1. 檢查快取檔案是否存在 if not os.path.exists(CACHE_FILE): return {"status": "success", "cleared_count": 0, "message": "快取檔案不存在"} try: - # 2. 讀取現有的 JSON 快取資料 with open(CACHE_FILE, "r", encoding="utf-8") as f: cache_data = json.load(f) - # 3. 逐一比對並刪除指定的路徑 for path in paths_to_clear: if path in cache_data: del cache_data[path] cleared_count += 1 - # 4. 如果有刪除資料,將更新後的內容寫回 JSON 檔案 if cleared_count > 0: with open(CACHE_FILE, "w", encoding="utf-8") as f: json.dump(cache_data, f, ensure_ascii=False, indent=4) diff --git a/static/app.js b/static/app.js index 1e86448..37004d0 100644 --- a/static/app.js +++ b/static/app.js @@ -37,16 +37,67 @@ import { // ============================================================================ -// 🌟 1. 全域生命週期與閒置偵測 (Lifecycle & Idle Detection) +// 🌟 1. 全域生命週期、閒置偵測與 SSE 廣播監聽 // ============================================================================ let idleTimer; 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 = `⏳ 背景掃描進度:${data.current} / ${data.total} (正在處理: ${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 = () => { initTerminal(); resetIdleTimer(); // 頁面載入時啟動閒置計時器 + initGlobalSSE(); // 啟動全域廣播監聽 }; window.onresize = () => { if (typeof fitAddon !== 'undefined' && fitAddon) fitAddon.fit(); }; @@ -56,12 +107,10 @@ function resetIdleTimer() { idleTimer = setTimeout(releaseAllLocks, IDLE_TIMEOUT); } -// 監聽使用者的互動事件,只要有動作就重置計時器 window.addEventListener('mousemove', resetIdleTimer); window.addEventListener('keydown', resetIdleTimer); window.addEventListener('click', resetIdleTimer); - // ============================================================================ // 🌟 2. 頁籤切換與 UI 狀態控制 (Tabs & UI State) // ============================================================================ @@ -317,7 +366,7 @@ async function enhanceInputWithOptions(path, elementId) { } // ============================================================================ -// 🌟 核心功能:執行掃描 (支援局部 / 全域) +// 🌟 核心功能:執行掃描 (廣播升級版) // ============================================================================ async function performScan(isGlobal) { const isSomeoneScanning = await apiGetScanStatus(); @@ -330,14 +379,7 @@ async function performScan(isGlobal) { if (!treeContainer) return; 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 { const optRes = await fetch('/api/v1/cmts-leaf-options'); const optData = await optRes.json(); @@ -347,7 +389,6 @@ async function performScan(isGlobal) { allContainers.forEach(container => { const isHiddenByFolder = container.closest('details:not([open])') !== null; - // 如果是全域掃描,或是該節點沒有被收合(局部可見),就納入檢查 if (isGlobal || !isHiddenByFolder) { const path = container.getAttribute('data-path'); if (path) { @@ -364,64 +405,41 @@ async function performScan(isGlobal) { statusEl.textContent = isGlobal ? "✅ 全域所有欄位都已有最新選項,無需掃描!" : "✅ 局部展開的欄位都已有最新選項,無需掃描!"; statusEl.style.color = "#27ae60"; setTimeout(() => statusEl.textContent = "", 3000); - - setAdminButtonsState(false); - localStorage.removeItem('scanLockUntil'); return; } - statusEl.innerHTML = `⏳ 正在建立即時連線... (共需掃描 ${pathsToSync.length} 個項目)`; - statusEl.style.color = "#e67e22"; - - await apiSyncLeafOptionsStream(pathsToSync, (data) => { - if (data.event === 'progress') { - statusEl.innerHTML = `⏳ 背景掃描進度:${data.current} / ${data.total} (正在處理: ${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'); - } + // 直接發送 POST 請求觸發背景任務,UI 變化交給 SSE 處理 + const response = await fetch('/api/v1/cmts-leaf-options/sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ leaf_paths: pathsToSync }) }); + + const result = await response.json(); + if (result.status === 'busy') { + alert(result.message); + } } catch (error) { - console.error("掃描請求失敗:", error); + console.error("掃描請求發送失敗:", error); statusEl.textContent = "❌ 掃描請求發送失敗,請檢查網路連線。"; statusEl.style.color = "#e74c3c"; - setAdminButtonsState(false); - localStorage.removeItem('scanLockUntil'); } } // ============================================================================ -// 🌟 核心功能:清除快取 (支援局部 / 全域) +// 🌟 核心功能:清除快取 (廣播升級版) // ============================================================================ async function performClear(isGlobal) { const isSomeoneScanning = await apiGetScanStatus(); if (isSomeoneScanning) { alert("⚠️ 系統正在進行全域選項掃描更新中!\n為避免資料衝突,請稍候掃描完成再執行清除動作。"); - lockScanButton(60000); - const statusEl = document.getElementById('scan-status'); - if (statusEl) { - statusEl.innerHTML = `⏳ 其他使用者正在背景更新快取,請稍候...`; - statusEl.style.color = "#e67e22"; - } return; } let pathsToClear = []; if (isGlobal) { - // 全域清除:直接向後端要所有的快取 Key try { const optRes = await fetch('/api/v1/cmts-leaf-options'); const optData = await optRes.json(); @@ -431,7 +449,6 @@ async function performClear(isGlobal) { return alert("❌ 無法取得全域快取清單。"); } } else { - // 局部清除:只抓畫面上展開的節點 const elements = document.querySelectorAll('.leaf-container'); elements.forEach(el => { const isHiddenByFolder = el.closest('details:not([open])') !== null; @@ -449,13 +466,21 @@ async function performClear(isGlobal) { const msg = isGlobal ? `確定要清除「全域」共 ${pathsToClear.length} 個選項的快取嗎?\n(這將會清空所有已儲存的下拉選單資料)` - : `確定要清除局部 ${pathsToClear.length} 個選項的快取嗎?\n(清除後請重新點擊「掃描局部缺失選項」)`; + : `確定要清除局部 ${pathsToClear.length} 個選項的快取嗎?\n(清除後將自動為您重新掃描)`; if (!confirm(msg)) return; try { 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) { console.error("清除快取失敗:", error); alert("❌ 清除快取失敗,請檢查網路連線或後端 API 是否正確啟動。");