enha: 修改進度條為全廣播機制
This commit is contained in:
parent
f86a449642
commit
d7d6e63a7c
|
|
@ -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
|
||||
|
||||
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 = []
|
||||
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))
|
||||
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
|
||||
# 將任務丟入背景執行,並立刻回傳 JSON 給前端
|
||||
background_tasks.add_task(run_scan_task, cmts_query_paths)
|
||||
return {"status": "started"}
|
||||
|
||||
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)
|
||||
|
|
|
|||
125
static/app.js
125
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 = `⏳ 背景掃描進度:<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 = () => {
|
||||
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,13 +379,6 @@ 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');
|
||||
|
|
@ -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 = `⏳ 背景掃描進度:<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');
|
||||
}
|
||||
// 直接發送 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 是否正確啟動。");
|
||||
|
|
|
|||
Loading…
Reference in New Issue