监控页从 iframe 改为原生 React 组件:无加载延迟,30s JSON 轮询

This commit is contained in:
2569718930@qq.com
2026-05-14 00:18:26 +08:00
parent fd12c15518
commit fc48795299
4 changed files with 209 additions and 8 deletions
+5 -1
View File
@@ -8,9 +8,13 @@ export async function GET(req: NextRequest) {
return new NextResponse("API not configured", { status: 500 });
}
const isCards = req.nextUrl.searchParams.get("cards") === "1";
const path = isCards ? "/m/cards" : "/m";
const asJson = req.nextUrl.searchParams.get("json") === "1";
const path = asJson ? "/m/json" : isCards ? "/m/cards" : "/m";
const auth = await buildBackendRequestHeaders(req, { includeSupabaseIdentity: false });
const res = await fetch(`${API_BASE}${path}`, { headers: auth.headers });
if (asJson) {
return NextResponse.json(await res.json());
}
const html = await res.text();
return new NextResponse(html, {
status: res.status,
@@ -46,6 +46,7 @@ import {
useScanTerminalTheme,
useUserLocalClock,
} from "@/components/dashboard/scan-terminal/use-scan-terminal-ui-state";
import MonitorPanel from "@/components/dashboard/monitoring/MonitorPanel";
type ContentView = "analysis" | "map" | "monitor";
@@ -597,13 +598,9 @@ function ScanTerminalScreen() {
) : (
renderMainView()
)}
<div style={{ display: resolvedView === "monitor" ? "block" : "none", background: "#0f1117", flex: 1, minHeight: 0 }}>
<iframe
src="/api/m"
style={{ width: "100%", height: "100%", border: "none" }}
title="市场监控"
/>
</div>
{resolvedView === "monitor" && (
<MonitorPanel />
)}
</section>
</main>
@@ -0,0 +1,188 @@
"use client";
import { useEffect, useState, useCallback } from "react";
interface RunwayPair {
label: string;
temp: number;
}
interface CitySnapshot {
en_name: string;
airport: string;
obs_time: string;
current_temp: number | null;
max_so_far: number | null;
max_temp_time: string | null;
trend_sym: string;
trend_css: string;
obs_age_min: number | null;
new_high: boolean;
temp_warm: boolean;
runway_pairs?: string; // HTML from backend
}
function parseRunway(html: string): RunwayPair[] {
const pairs: RunwayPair[] = [];
const re = /runway-label">([^<]+)<.*?runway-temp">([\d.]+)/g;
let m;
while ((m = re.exec(html)) !== null) {
pairs.push({ label: m[1], temp: parseFloat(m[2]) });
}
return pairs;
}
export default function MonitorPanel() {
const [cities, setCities] = useState<CitySnapshot[]>([]);
const [time, setTime] = useState("");
const [notify, setNotify] = useState(
() => typeof window !== "undefined" && localStorage.getItem("monitor_notify") !== "off"
);
const fetchData = useCallback(async () => {
try {
const res = await fetch("/api/m?json=1");
const data = await res.json();
setCities(data);
setTime(new Date().toLocaleTimeString());
} catch {}
}, []);
useEffect(() => {
fetchData();
const t = setInterval(fetchData, 30_000);
return () => clearInterval(t);
}, [fetchData]);
const toggleNotify = () => {
const next = !notify;
setNotify(next);
localStorage.setItem("monitor_notify", next ? "on" : "off");
if (next && "Notification" in window && Notification.permission === "default") {
Notification.requestPermission();
}
};
return (
<div style={{ background: "#0f1117", height: "100%", overflow: "auto", padding: "20px 24px" }}>
{/* Header */}
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
marginBottom: 24, paddingBottom: 12, borderBottom: "1px solid #1e2130",
}}>
<h2 style={{ fontSize: 20, fontWeight: 600, color: "#e8eaed" }}>🔥 </h2>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<button
onClick={toggleNotify}
style={{
background: "none", border: "1px solid #2a2e40", borderRadius: 6,
padding: "2px 8px", fontSize: 16, cursor: "pointer", opacity: notify ? 1 : 0.4,
}}
title="新高提醒开关"
>
{notify ? "🔔" : "🔕"}
</button>
<span style={{ fontSize: 13, color: "#5a6170" }}>{time}</span>
</div>
</div>
{/* Card Grid */}
<div style={{
display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))",
gap: 14,
}}>
{cities.map((c) => {
const cn = c.new_high ? " new-high-card" : "";
const wc = c.temp_warm ? " warm" : "";
const nv = c.new_high ? " new-high-val" : "";
const rw = c.runway_pairs ? parseRunway(c.runway_pairs) : [];
return (
<div key={c.en_name} className={`card${cn}`} style={cardStyle}>
{/* Top */}
<div style={cardTopStyle}>
<span style={{ color: "#e0e3e8", fontWeight: 700 }}>{c.en_name}</span>
<span style={{ color: "#6a7180" }}>/ {c.airport}</span>
<span style={{ marginLeft: "auto", color: "#4a5160", fontSize: 14 }}>{c.obs_time}</span>
{c.new_high && (
<span style={{ fontSize: 12, padding: "1px 6px", borderRadius: 4,
background: "rgba(124,58,237,.18)", color: "#a78bfa", fontWeight: 600 }}>
</span>
)}
</div>
{/* Temp */}
<div style={{ margin: "8px 0 10px", lineHeight: 1.15 }}>
{c.current_temp != null ? (
<>
<span style={{ fontSize: 48, fontWeight: 700, letterSpacing: "-.03em",
color: c.new_high ? "#c084fc" : c.temp_warm ? "#f59e0b" : "#e8eaed" }}>
{c.current_temp.toFixed(1)}
</span>
<span style={{ fontSize: 20, color: "#5a6170", marginLeft: 3 }}>°C</span>
</>
) : (
<span style={{ fontSize: 30, color: "#3a4050" }}>--</span>
)}
</div>
{/* Meta */}
<div style={{ fontSize: 14 }}>
<div style={{ display: "flex", alignItems: "baseline", gap: 8 }}>
<span style={{ color: "#4a5160" }}>High</span>
{c.max_so_far != null ? (
<>
<span style={{ color: "#9aa0b0" }}>{c.max_so_far.toFixed(1)}°C</span>
{c.max_temp_time && (
<span style={{ fontSize: 12, color: "#4a5160", marginLeft: 2 }}>{c.max_temp_time}</span>
)}
</>
) : (
<span style={{ color: "#3a4050" }}>--</span>
)}
<span style={{ marginLeft: "auto", fontSize: 18, fontWeight: 700,
color: c.trend_css === "rising" ? "#34d399" :
c.trend_css === "falling" ? "#60a5fa" : "#5a6170" }}>
{c.trend_sym}
</span>
</div>
<div style={{ display: "flex", gap: 8, marginTop: 4 }}>
<span style={{ color: "#4a5160" }}>Obs</span>
{c.obs_age_min != null ? (
<span style={{ color: "#5a6170", fontSize: 13 }}>{c.obs_age_min} min ago</span>
) : (
<span style={{ color: "#3a4050" }}>--</span>
)}
</div>
</div>
{/* Runway */}
{rw.length > 0 && (
<div style={{ marginTop: 12 }}>
<div style={{ height: 1, background: "#1e2130", marginBottom: 8 }} />
{rw.map((r, i) => (
<div key={i} style={{ display: "flex", justifyContent: "space-between", fontSize: 13, marginBottom: 2 }}>
<span style={{ color: "#4a5160" }}>{r.label}</span>
<span style={{ color: "#7a8290" }}>{r.temp.toFixed(1)}°C</span>
</div>
))}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
const cardStyle: React.CSSProperties = {
background: "#161822", border: "1px solid #1e2130", borderRadius: 12,
padding: "20px 24px", position: "relative",
};
const cardTopStyle: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 8,
marginBottom: 14, fontSize: 15, flexWrap: "wrap",
};
+12
View File
@@ -254,3 +254,15 @@ async def monitor_page(request: Request):
async def monitor_cards(request: Request):
cards, gts = _build_cards()
return HTMLResponse(_render(cards, gts))
@router.get("/m/json")
async def monitor_json(request: Request):
cards, gts = _build_cards()
return [{
"en_name": c["en_name"], "airport": c["airport"],
"obs_time": c["time"], "current_temp": c["ct"],
"max_so_far": c["msf"], "max_temp_time": c["mtt"],
"trend_sym": c["tsym"], "trend_css": c["tcss"],
"obs_age_min": c["age"], "new_high": c["new_high"],
"temp_warm": c["warm"], "runway_pairs": c["rw_html"],
} for c in cards]