移除国内城市(NMC并非机场温度,无用)

This commit is contained in:
2569718930@qq.com
2026-05-14 01:41:16 +08:00
parent 329e3afe38
commit 3529d97d58
2 changed files with 218 additions and 107 deletions
@@ -46,7 +46,10 @@ import {
useScanTerminalTheme, useScanTerminalTheme,
useUserLocalClock, useUserLocalClock,
} from "@/components/dashboard/scan-terminal/use-scan-terminal-ui-state"; } 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"; type ContentView = "analysis" | "map" | "monitor";
@@ -336,7 +339,7 @@ function ScanTerminalScreen() {
); );
} }
if (resolvedView === "monitor") { if (resolvedView === "monitor") {
return null; // iframe 在下方始终挂载,通过 display 切换 return null; // MonitorPanel is rendered below the main view switch
} }
if (!isPro) { if (!isPro) {
return ( return (
@@ -1,17 +1,25 @@
"use client"; "use client";
import { useMemo, useState, useEffect } from "react"; import { useMemo, useState, useEffect, useRef, useCallback } from "react";
import { useDashboardStore } from "@/hooks/useDashboardStore"; import { useDashboardStore } from "@/hooks/useDashboardStore";
import type { CityDetail } from "@/lib/dashboard-types"; import type { CityDetail } from "@/lib/dashboard-types";
const MONITOR_KEYS = [ const MONITOR_KEYS = [
"seoul", "busan", "tokyo", "ankara", "helsinki", "amsterdam", "seoul", "busan", "tokyo", "ankara", "helsinki", "amsterdam",
"istanbul", "paris", "hong kong", "lau fau shan", "taipei", "istanbul", "paris", "hong kong", "lau fau shan", "taipei",
"shanghai", "beijing", "chengdu", "chongqing", "wuhan", "guangzhou", "qingdao",
"new york", "los angeles", "chicago", "denver", "atlanta", "new york", "los angeles", "chicago", "denver", "atlanta",
"miami", "san francisco", "houston", "dallas", "austin", "seattle", "miami", "san francisco", "houston", "dallas", "austin", "seattle",
] as const; ] 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 = { type MonitorCity = {
key: string; key: string;
detail: CityDetail | undefined; detail: CityDetail | undefined;
@@ -31,10 +39,58 @@ function trendIcon(detail: CityDetail | undefined): { s: string; c: string } {
return { s: "→", c: "flat" }; 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<T>(
tasks: Array<() => Promise<T>>,
concurrency: number,
cancelled: () => boolean,
): Promise<void> {
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 (
<div style={{
background: "#161822", border: "1px solid #1e2130",
borderRadius: 12, padding: "20px 24px", minHeight: 148,
}}>
<div style={{ display: "flex", gap: 10, marginBottom: 14, alignItems: "center" }}>
<div style={{ height: 14, width: "45%", background: "#1e2534", borderRadius: 4 }} />
<div style={{ height: 12, width: "25%", background: "#1a1f2b", borderRadius: 4 }} />
</div>
<div style={{ height: 52, width: "55%", background: "#1e2534", borderRadius: 6, marginBottom: 16 }} />
<div style={{ height: 12, width: "70%", background: "#1a1f2b", borderRadius: 4, marginBottom: 8 }} />
<div style={{ height: 12, width: "40%", background: "#1a1f2b", borderRadius: 4 }} />
</div>
);
}
export default function MonitorPanel() { export default function MonitorPanel() {
const store = useDashboardStore(); const store = useDashboardStore();
const details = store.cityDetailsByName; const details = store.cityDetailsByName;
const [time, setTime] = useState(""); const [time, setTime] = useState("");
const [initialLoadDone, setInitialLoadDone] = useState(false);
const cancelledRef = useRef(false);
const fetchingRef = useRef(false);
useEffect(() => { useEffect(() => {
setTime(new Date().toLocaleTimeString()); setTime(new Date().toLocaleTimeString());
@@ -47,27 +103,59 @@ export default function MonitorPanel() {
return localStorage.getItem("monitor_notify") !== "off"; return localStorage.getItem("monitor_notify") !== "off";
}); });
// 1 min force-refresh all 11 monitoring cities /** Fetch all MONITOR_KEYS cities concurrently in batches. */
useEffect(() => { const refreshAll = useCallback(
let cancelled = false; async (force: boolean) => {
async function refreshAll() { if (fetchingRef.current) return;
for (const k of MONITOR_KEYS) {
if (cancelled) break; // Skip if data is still fresh (e.g. after a tab switch).
try { if (
await store.ensureCityDetail(k, true, "panel"); !force &&
} catch {} lastRefreshCompletedAt > 0 &&
Date.now() - lastRefreshCompletedAt < MONITOR_FRESHNESS_TTL_MS
) {
setInitialLoadDone(true);
return;
} }
}
refreshAll(); fetchingRef.current = true;
const t = setInterval(refreshAll, 60_000);
return () => { cancelled = true; clearInterval(t); }; const tasks = MONITOR_KEYS.map((k) => () =>
}, [store.ensureCityDetail]); 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(() => { const cities: MonitorCity[] = useMemo(() => {
return MONITOR_KEYS.map((k) => ({ key: k, detail: details[k] })); return MONITOR_KEYS.map((k) => ({ key: k, detail: details[k] }));
}, [details]); }, [details]);
// Sort by temp descending // Sort by current temp descending.
const sorted = useMemo(() => { const sorted = useMemo(() => {
return [...cities].sort((a, b) => { return [...cities].sort((a, b) => {
const ta = a.detail?.airport_current?.temp ?? a.detail?.current?.temp ?? null; 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(() => { useEffect(() => {
if (!notify || typeof Notification === "undefined" || Notification.permission !== "granted") return; if (!notify || typeof Notification === "undefined" || Notification.permission !== "granted") return;
for (const c of sorted) { for (const c of sorted) {
@@ -128,9 +216,6 @@ export default function MonitorPanel() {
denver: "Buckley", atlanta: "Hartsfield", miami: "MIA", denver: "Buckley", atlanta: "Hartsfield", miami: "MIA",
"san francisco": "SFO", houston: "Hobby", dallas: "Love Field", "san francisco": "SFO", houston: "Hobby", dallas: "Love Field",
austin: "Bergstrom", seattle: "SeaTac", austin: "Bergstrom", seattle: "SeaTac",
shanghai: "Pudong", beijing: "Capital",
chengdu: "Shuangliu", chongqing: "Jiangbei",
wuhan: "Tianhe", guangzhou: "Baiyun", qingdao: "Liuting",
}; };
return m[key] || ""; return m[key] || "";
}; };
@@ -143,6 +228,15 @@ export default function MonitorPanel() {
}}> }}>
<h2 style={{ fontSize: 20, fontWeight: 600, color: "#e8eaed" }}>🔥 </h2> <h2 style={{ fontSize: 20, fontWeight: 600, color: "#e8eaed" }}>🔥 </h2>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}> <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
{!initialLoadDone && (
<span style={{ fontSize: 12, color: "#5a6170", display: "flex", alignItems: "center", gap: 6 }}>
<span style={{
display: "inline-block", width: 8, height: 8, borderRadius: "50%",
background: "#3b82f6", animation: "monitor-pulse 1.2s ease-in-out infinite",
}} />
</span>
)}
<button <button
onClick={toggleNotify} onClick={toggleNotify}
style={{ style={{
@@ -156,99 +250,113 @@ export default function MonitorPanel() {
</div> </div>
</div> </div>
<style>{`
@keyframes monitor-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
@keyframes monitor-shimmer {
0% { opacity: 0.5; }
50% { opacity: 1; }
100% { opacity: 0.5; }
}
`}</style>
<div style={{ <div style={{
display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))", display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))",
gap: 14, gap: 14,
}}> }}>
{sorted.map((c) => { {!initialLoadDone
const ac = c.detail?.airport_current; ? MONITOR_KEYS.map((k) => <SkeletonCard key={k} />)
const cur = ac?.temp ?? c.detail?.current?.temp ?? null; : sorted.map((c) => {
const max = ac?.max_so_far ?? null; const ac = c.detail?.airport_current;
const mtt = ac?.max_temp_time ?? null; const cur = ac?.temp ?? c.detail?.current?.temp ?? null;
const obs = ac?.obs_time ?? c.detail?.local_time ?? ""; const max = ac?.max_so_far ?? null;
const age = ac?.obs_age_min ?? null; const mtt = ac?.max_temp_time ?? null;
const newHigh = cur != null && max != null && cur >= max + 0.3; const obs = ac?.obs_time ?? c.detail?.local_time ?? "";
const warm = cur != null && cur >= 30; const age = ac?.obs_age_min ?? null;
const tr = trendIcon(c.detail); const newHigh = cur != null && max != null && cur >= max + 0.3;
const rw = c.detail?.amos?.runway_obs; const warm = cur != null && cur >= 30;
const rwPairs = rw?.runway_pairs || []; const tr = trendIcon(c.detail);
const rwTemps = rw?.temperatures || []; const rw = c.detail?.amos?.runway_obs;
const rwPairs = rw?.runway_pairs || [];
const rwTemps = rw?.temperatures || [];
return ( return (
<div key={c.key} style={{ <div key={c.key} style={{
background: "#161822", border: `1px solid ${newHigh ? "rgba(124,58,237,0.3)" : "#1e2130"}`, background: "#161822", border: `1px solid ${newHigh ? "rgba(124,58,237,0.3)" : "#1e2130"}`,
borderRadius: 12, padding: "20px 24px", borderRadius: 12, padding: "20px 24px",
}}> }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 14, fontSize: 15, flexWrap: "wrap" }}> <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 14, fontSize: 15, flexWrap: "wrap" }}>
<span style={{ color: "#e0e3e8", fontWeight: 700 }}>{c.detail?.display_name || c.key}</span> <span style={{ color: "#e0e3e8", fontWeight: 700 }}>{c.detail?.display_name || c.key}</span>
<span style={{ color: "#6a7180" }}>/ {airportName(c.key)}</span> <span style={{ color: "#6a7180" }}>/ {airportName(c.key)}</span>
<span style={{ marginLeft: "auto", color: "#4a5160", fontSize: 14 }}>{obs}</span> <span style={{ marginLeft: "auto", color: "#4a5160", fontSize: 14 }}>{obs}</span>
{newHigh && ( {newHigh && (
<span style={{ fontSize: 12, padding: "1px 6px", borderRadius: 4, <span style={{ fontSize: 12, padding: "1px 6px", borderRadius: 4,
background: "rgba(124,58,237,.18)", color: "#a78bfa", fontWeight: 600 }}> background: "rgba(124,58,237,.18)", color: "#a78bfa", fontWeight: 600 }}>
</span> </span>
)} )}
</div> </div>
<div style={{ margin: "8px 0 10px", lineHeight: 1.15 }}> <div style={{ margin: "8px 0 10px", lineHeight: 1.15 }}>
{cur != null ? ( {cur != null ? (
<> <>
<span style={{ fontSize: 48, fontWeight: 700, letterSpacing: "-.03em", <span style={{ fontSize: 48, fontWeight: 700, letterSpacing: "-.03em",
color: newHigh ? "#c084fc" : warm ? "#f59e0b" : "#e8eaed" }}> color: newHigh ? "#c084fc" : warm ? "#f59e0b" : "#e8eaed" }}>
{cur.toFixed(1)} {cur.toFixed(1)}
</span> </span>
<span style={{ fontSize: 20, color: "#5a6170", marginLeft: 3 }}>°C</span> <span style={{ fontSize: 20, color: "#5a6170", marginLeft: 3 }}>°C</span>
</> </>
) : ( ) : (
<span style={{ fontSize: 30, color: "#3a4050" }}>--</span> <span style={{ fontSize: 30, color: "#3a4050" }}>--</span>
)} )}
</div> </div>
<div style={{ fontSize: 14 }}> <div style={{ fontSize: 14 }}>
<div style={{ display: "flex", alignItems: "baseline", gap: 8 }}> <div style={{ display: "flex", alignItems: "baseline", gap: 8 }}>
<span style={{ color: "#4a5160" }}>High</span> <span style={{ color: "#4a5160" }}>High</span>
{max != null ? ( {max != null ? (
<> <>
<span style={{ color: "#9aa0b0" }}>{max.toFixed(1)}°C</span> <span style={{ color: "#9aa0b0" }}>{max.toFixed(1)}°C</span>
{mtt && <span style={{ fontSize: 12, color: "#4a5160", marginLeft: 2 }}>{mtt}</span>} {mtt && <span style={{ fontSize: 12, color: "#4a5160", marginLeft: 2 }}>{mtt}</span>}
</> </>
) : ( ) : (
<span style={{ color: "#3a4050" }}>--</span> <span style={{ color: "#3a4050" }}>--</span>
)} )}
<span style={{ marginLeft: "auto", fontSize: 18, fontWeight: 700, <span style={{ marginLeft: "auto", fontSize: 18, fontWeight: 700,
color: tr.c === "rising" ? "#34d399" : tr.c === "falling" ? "#60a5fa" : "#5a6170" }}> color: tr.c === "rising" ? "#34d399" : tr.c === "falling" ? "#60a5fa" : "#5a6170" }}>
{tr.s} {tr.s}
</span> </span>
</div> </div>
<div style={{ display: "flex", gap: 8, marginTop: 4 }}> <div style={{ display: "flex", gap: 8, marginTop: 4 }}>
<span style={{ color: "#4a5160" }}>Obs</span> <span style={{ color: "#4a5160" }}>Obs</span>
{age != null ? ( {age != null ? (
<span style={{ color: "#5a6170", fontSize: 13 }}>{age} min ago</span> <span style={{ color: "#5a6170", fontSize: 13 }}>{age} min ago</span>
) : ( ) : (
<span style={{ color: "#3a4050" }}>--</span> <span style={{ color: "#3a4050" }}>--</span>
)}
</div>
</div>
{rwPairs.length > 0 && rwTemps.length > 0 && (
<div style={{ marginTop: 12 }}>
<div style={{ height: 1, background: "#1e2130", marginBottom: 8 }} />
{rwPairs.map((p, i) => {
const t = rwTemps[i]?.[0];
if (t == null) return null;
return (
<div key={i} style={{ display: "flex", justifyContent: "space-between", fontSize: 13, marginBottom: 2 }}>
<span style={{ color: "#4a5160" }}>{p[0]}/{p[1]}</span>
<span style={{ color: "#7a8290" }}>{t.toFixed(1)}°C</span>
</div>
);
})}
</div>
)} )}
</div> </div>
</div> );
})}
{rwPairs.length > 0 && rwTemps.length > 0 && (
<div style={{ marginTop: 12 }}>
<div style={{ height: 1, background: "#1e2130", marginBottom: 8 }} />
{rwPairs.map((p, i) => {
const t = rwTemps[i]?.[0];
if (t == null) return null;
return (
<div key={i} style={{ display: "flex", justifyContent: "space-between", fontSize: 13, marginBottom: 2 }}>
<span style={{ color: "#4a5160" }}>{p[0]}/{p[1]}</span>
<span style={{ color: "#7a8290" }}>{t.toFixed(1)}°C</span>
</div>
);
})}
</div>
)}
</div>
);
})}
</div> </div>
</div> </div>
); );