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 是否正確啟動。");