diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 3c759ffa..76aafe72 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -46,7 +46,10 @@ import { useScanTerminalTheme, useUserLocalClock, } from "@/components/dashboard/scan-terminal/use-scan-terminal-ui-state"; -import MonitorPanel from "@/components/dashboard/monitoring/MonitorPanel"; +const MonitorPanel = dynamic( + () => import("@/components/dashboard/monitoring/MonitorPanel"), + { ssr: false }, +); type ContentView = "analysis" | "map" | "monitor"; @@ -336,7 +339,7 @@ function ScanTerminalScreen() { ); } if (resolvedView === "monitor") { - return null; // iframe 在下方始终挂载,通过 display 切换 + return null; // MonitorPanel is rendered below the main view switch } if (!isPro) { return ( diff --git a/frontend/components/dashboard/monitoring/MonitorPanel.tsx b/frontend/components/dashboard/monitoring/MonitorPanel.tsx index 7ac32b90..d84be7b9 100644 --- a/frontend/components/dashboard/monitoring/MonitorPanel.tsx +++ b/frontend/components/dashboard/monitoring/MonitorPanel.tsx @@ -1,17 +1,25 @@ "use client"; -import { useMemo, useState, useEffect } from "react"; +import { useMemo, useState, useEffect, useRef, useCallback } from "react"; import { useDashboardStore } from "@/hooks/useDashboardStore"; import type { CityDetail } from "@/lib/dashboard-types"; const MONITOR_KEYS = [ "seoul", "busan", "tokyo", "ankara", "helsinki", "amsterdam", "istanbul", "paris", "hong kong", "lau fau shan", "taipei", - "shanghai", "beijing", "chengdu", "chongqing", "wuhan", "guangzhou", "qingdao", "new york", "los angeles", "chicago", "denver", "atlanta", "miami", "san francisco", "houston", "dallas", "austin", "seattle", ] as const; +/** Max concurrent city-detail requests while loading the monitor panel. */ +const MONITOR_FETCH_CONCURRENCY = 6; + +/** Minimum elapsed time (ms) before triggering another auto-refresh cycle. */ +const MONITOR_REFRESH_INTERVAL_MS = 60_000; + +/** How long (ms) fresh data is considered valid — avoids re-fetch on tab switch. */ +const MONITOR_FRESHNESS_TTL_MS = 45_000; + type MonitorCity = { key: string; detail: CityDetail | undefined; @@ -31,10 +39,58 @@ function trendIcon(detail: CityDetail | undefined): { s: string; c: string } { return { s: "→", c: "flat" }; } +/** + * Run an array of async tasks with capped concurrency. + * Resolves when every task has settled (success or error). + */ +async function runConcurrent( + tasks: Array<() => Promise>, + concurrency: number, + cancelled: () => boolean, +): Promise { + const queue = [...tasks]; + const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => { + while (queue.length > 0) { + if (cancelled()) return; + const task = queue.shift(); + if (!task) break; + try { + await task(); + } catch { + // Ignore individual city failures; the panel shows "--" gracefully. + } + } + }); + await Promise.allSettled(workers); +} + +// Module-level timestamp — survives tab switches so we don't re-fetch stale data. +let lastRefreshCompletedAt = 0; + +function SkeletonCard() { + return ( +
+
+
+
+
+
+
+
+
+ ); +} + export default function MonitorPanel() { const store = useDashboardStore(); const details = store.cityDetailsByName; const [time, setTime] = useState(""); + const [initialLoadDone, setInitialLoadDone] = useState(false); + const cancelledRef = useRef(false); + const fetchingRef = useRef(false); useEffect(() => { setTime(new Date().toLocaleTimeString()); @@ -47,27 +103,59 @@ export default function MonitorPanel() { return localStorage.getItem("monitor_notify") !== "off"; }); - // 1 min force-refresh all 11 monitoring cities - useEffect(() => { - let cancelled = false; - async function refreshAll() { - for (const k of MONITOR_KEYS) { - if (cancelled) break; - try { - await store.ensureCityDetail(k, true, "panel"); - } catch {} + /** Fetch all MONITOR_KEYS cities concurrently in batches. */ + const refreshAll = useCallback( + async (force: boolean) => { + if (fetchingRef.current) return; + + // Skip if data is still fresh (e.g. after a tab switch). + if ( + !force && + lastRefreshCompletedAt > 0 && + Date.now() - lastRefreshCompletedAt < MONITOR_FRESHNESS_TTL_MS + ) { + setInitialLoadDone(true); + return; } - } - refreshAll(); - const t = setInterval(refreshAll, 60_000); - return () => { cancelled = true; clearInterval(t); }; - }, [store.ensureCityDetail]); + + fetchingRef.current = true; + + const tasks = MONITOR_KEYS.map((k) => () => + store.ensureCityDetail(k, force, "panel"), + ); + + await runConcurrent(tasks, MONITOR_FETCH_CONCURRENCY, () => cancelledRef.current); + + if (!cancelledRef.current) { + lastRefreshCompletedAt = Date.now(); + setInitialLoadDone(true); + } + + fetchingRef.current = false; + }, + [store.ensureCityDetail], + ); + + // Initial load (non-force, honours freshness TTL) + periodic auto-refresh. + useEffect(() => { + cancelledRef.current = false; + void refreshAll(false); + + const t = setInterval(() => { + if (!document.hidden) void refreshAll(true); + }, MONITOR_REFRESH_INTERVAL_MS); + + return () => { + cancelledRef.current = true; + clearInterval(t); + }; + }, [refreshAll]); const cities: MonitorCity[] = useMemo(() => { return MONITOR_KEYS.map((k) => ({ key: k, detail: details[k] })); }, [details]); - // Sort by temp descending + // Sort by current temp descending. const sorted = useMemo(() => { return [...cities].sort((a, b) => { const ta = a.detail?.airport_current?.temp ?? a.detail?.current?.temp ?? null; @@ -88,7 +176,7 @@ export default function MonitorPanel() { } }; - // Check for new highs and fire notifications + // Check for new highs and fire browser notifications. useEffect(() => { if (!notify || typeof Notification === "undefined" || Notification.permission !== "granted") return; for (const c of sorted) { @@ -128,9 +216,6 @@ export default function MonitorPanel() { denver: "Buckley", atlanta: "Hartsfield", miami: "MIA", "san francisco": "SFO", houston: "Hobby", dallas: "Love Field", austin: "Bergstrom", seattle: "SeaTac", - shanghai: "Pudong", beijing: "Capital", - chengdu: "Shuangliu", chongqing: "Jiangbei", - wuhan: "Tianhe", guangzhou: "Baiyun", qingdao: "Liuting", }; return m[key] || ""; }; @@ -143,6 +228,15 @@ export default function MonitorPanel() { }}>

🔥 市场监控

+ {!initialLoadDone && ( + + + 加载中… + + )}
+ +
- {sorted.map((c) => { - const ac = c.detail?.airport_current; - const cur = ac?.temp ?? c.detail?.current?.temp ?? null; - const max = ac?.max_so_far ?? null; - const mtt = ac?.max_temp_time ?? null; - const obs = ac?.obs_time ?? c.detail?.local_time ?? ""; - const age = ac?.obs_age_min ?? null; - const newHigh = cur != null && max != null && cur >= max + 0.3; - const warm = cur != null && cur >= 30; - const tr = trendIcon(c.detail); - const rw = c.detail?.amos?.runway_obs; - const rwPairs = rw?.runway_pairs || []; - const rwTemps = rw?.temperatures || []; + {!initialLoadDone + ? MONITOR_KEYS.map((k) => ) + : sorted.map((c) => { + const ac = c.detail?.airport_current; + const cur = ac?.temp ?? c.detail?.current?.temp ?? null; + const max = ac?.max_so_far ?? null; + const mtt = ac?.max_temp_time ?? null; + const obs = ac?.obs_time ?? c.detail?.local_time ?? ""; + const age = ac?.obs_age_min ?? null; + const newHigh = cur != null && max != null && cur >= max + 0.3; + const warm = cur != null && cur >= 30; + const tr = trendIcon(c.detail); + const rw = c.detail?.amos?.runway_obs; + const rwPairs = rw?.runway_pairs || []; + const rwTemps = rw?.temperatures || []; - return ( -
-
- {c.detail?.display_name || c.key} - / {airportName(c.key)} - {obs} - {newHigh && ( - - ◆新高 - - )} -
+ return ( +
+
+ {c.detail?.display_name || c.key} + / {airportName(c.key)} + {obs} + {newHigh && ( + + ◆新高 + + )} +
-
- {cur != null ? ( - <> - - {cur.toFixed(1)} - - °C - - ) : ( - -- - )} -
+
+ {cur != null ? ( + <> + + {cur.toFixed(1)} + + °C + + ) : ( + -- + )} +
-
-
- High - {max != null ? ( - <> - {max.toFixed(1)}°C - {mtt && {mtt}} - - ) : ( - -- - )} - - {tr.s} - -
-
- Obs - {age != null ? ( - {age} min ago - ) : ( - -- +
+
+ High + {max != null ? ( + <> + {max.toFixed(1)}°C + {mtt && {mtt}} + + ) : ( + -- + )} + + {tr.s} + +
+
+ Obs + {age != null ? ( + {age} min ago + ) : ( + -- + )} +
+
+ + {rwPairs.length > 0 && rwTemps.length > 0 && ( +
+
+ {rwPairs.map((p, i) => { + const t = rwTemps[i]?.[0]; + if (t == null) return null; + return ( +
+ {p[0]}/{p[1]} + {t.toFixed(1)}°C +
+ ); + })} +
)}
-
- - {rwPairs.length > 0 && rwTemps.length > 0 && ( -
-
- {rwPairs.map((p, i) => { - const t = rwTemps[i]?.[0]; - if (t == null) return null; - return ( -
- {p[0]}/{p[1]} - {t.toFixed(1)}°C -
- ); - })} -
- )} -
- ); - })} + ); + })}
);