MonitorPanel 从独立 API 改为复用 DashboardStore 数据,砍掉 /api/m 和 /m/json

This commit is contained in:
2569718930@qq.com
2026-05-14 00:43:19 +08:00
parent 7a28810f25
commit c006d13fea
4 changed files with 133 additions and 246 deletions
-13
View File
@@ -1,13 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json({ error: "API not configured" }, { status: 500 });
}
const auth = await buildBackendRequestHeaders(req, { includeSupabaseIdentity: false });
const res = await fetch(`${API_BASE}/m/json`, { headers: auth.headers });
return NextResponse.json(await res.json());
}
@@ -1,71 +1,116 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useMemo, useState, useEffect } from "react";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import type { CityDetail } from "@/lib/dashboard-types";
interface RunwayPair {
label: string;
temp: number;
const MONITOR_KEYS = [
"seoul", "busan", "tokyo", "ankara", "helsinki", "amsterdam",
"istanbul", "paris", "hong kong", "lau fau shan", "taipei",
] as const;
type MonitorCity = {
key: string;
detail: CityDetail | undefined;
};
function fmt(v: number | null | undefined): string {
return v != null ? v.toFixed(1) : "--";
}
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;
function trendIcon(detail: CityDetail | undefined): { s: string; c: string } {
if (!detail?.airport_current) return { s: "→", c: "flat" };
const ac = detail.airport_current;
const cur = ac.temp ?? detail.current?.temp ?? null;
const max = ac.max_so_far ?? null;
if (cur != null && max != null && cur >= max + 0.3) return { s: "↑", c: "rising" };
if (cur != null && max != null && cur < max - 1.0) return { s: "↓", c: "falling" };
return { s: "→", c: "flat" };
}
export default function MonitorPanel() {
const [cities, setCities] = useState<CitySnapshot[] | null>(null);
const store = useDashboardStore();
const details = store.cityDetailsByName;
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");
const data = await res.json();
setCities(data);
setTime(new Date().toLocaleTimeString());
} catch {}
}, []);
useEffect(() => {
fetchData();
const t = setInterval(fetchData, 60_000);
setTime(new Date().toLocaleTimeString());
const t = setInterval(() => setTime(new Date().toLocaleTimeString()), 60_000);
return () => clearInterval(t);
}, [fetchData]);
}, []);
const [notify, setNotify] = useState(() => {
if (typeof window === "undefined") return false;
return localStorage.getItem("monitor_notify") !== "off";
});
const cities: MonitorCity[] = useMemo(() => {
return MONITOR_KEYS.map((k) => ({ key: k, detail: details[k] }));
}, [details]);
// Sort by temp descending
const sorted = useMemo(() => {
return [...cities].sort((a, b) => {
const ta = a.detail?.airport_current?.temp ?? a.detail?.current?.temp ?? null;
const tb = b.detail?.airport_current?.temp ?? b.detail?.current?.temp ?? null;
if (ta == null && tb == null) return 0;
if (ta == null) return 1;
if (tb == null) return -1;
return tb - ta;
});
}, [cities]);
const toggleNotify = () => {
const next = !notify;
setNotify(next);
localStorage.setItem("monitor_notify", next ? "on" : "off");
if (next && "Notification" in window && Notification.permission === "default") {
if (next && typeof Notification !== "undefined" && Notification.permission === "default") {
Notification.requestPermission();
}
};
// Check for new highs and fire notifications
useEffect(() => {
if (!notify || typeof Notification === "undefined" || Notification.permission !== "granted") return;
for (const c of sorted) {
const ac = c.detail?.airport_current;
const cur = ac?.temp ?? c.detail?.current?.temp ?? null;
const max = ac?.max_so_far ?? null;
if (cur != null && max != null && cur >= max + 0.3) {
const key = `${c.key}|${cur}`;
const today = new Date().toDateString();
let notified: Record<string, unknown> = {};
try {
notified = JSON.parse(localStorage.getItem("monitor_notified_highs") || "{}");
} catch {}
if (notified._day !== today) notified = { _day: today };
if (!notified[key]) {
notified[key] = true;
localStorage.setItem("monitor_notified_highs", JSON.stringify(notified));
const name = c.detail?.display_name || c.key;
new Notification(`🔴 New High — ${name}`, {
body: `${cur}°C\nNew daily high.`,
tag: key,
requireInteraction: true,
});
}
}
}
}, [sorted, notify]);
const airportName = (key: string): string => {
const m: Record<string, string> = {
seoul: "Incheon", busan: "Gimhae", tokyo: "Haneda",
ankara: "Esenboğa", helsinki: "Vantaa", amsterdam: "Schiphol",
istanbul: "Airport", paris: "Le Bourget",
"hong kong": "Observatory", "lau fau shan": "Lau Fau Shan",
taipei: "Songshan",
};
return m[key] || "";
};
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",
@@ -78,7 +123,6 @@ export default function MonitorPanel() {
background: "none", border: "1px solid #2a2e40", borderRadius: 6,
padding: "2px 8px", fontSize: 16, cursor: "pointer", opacity: notify ? 1 : 0.4,
}}
title="新高提醒开关"
>
{notify ? "🔔" : "🔕"}
</button>
@@ -86,30 +130,34 @@ export default function MonitorPanel() {
</div>
</div>
{/* Card Grid */}
{cities === null ? (
<div style={{ color: "#5a6170", textAlign: "center", padding: 60, fontSize: 15 }}>
Loading
</div>
) : (
<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) : [];
{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 (
<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 && (
<div key={c.key} style={{
background: "#161822", border: `1px solid ${newHigh ? "rgba(124,58,237,0.3)" : "#1e2130"}`,
borderRadius: 12, padding: "20px 24px",
}}>
<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: "#6a7180" }}>/ {airportName(c.key)}</span>
<span style={{ marginLeft: "auto", color: "#4a5160", fontSize: 14 }}>{obs}</span>
{newHigh && (
<span style={{ fontSize: 12, padding: "1px 6px", borderRadius: 4,
background: "rgba(124,58,237,.18)", color: "#a78bfa", fontWeight: 600 }}>
@@ -117,13 +165,12 @@ export default function MonitorPanel() {
)}
</div>
{/* Temp */}
<div style={{ margin: "8px 0 10px", lineHeight: 1.15 }}>
{c.current_temp != null ? (
{cur != null ? (
<>
<span style={{ fontSize: 48, fontWeight: 700, letterSpacing: "-.03em",
color: c.new_high ? "#c084fc" : c.temp_warm ? "#f59e0b" : "#e8eaed" }}>
{c.current_temp.toFixed(1)}
color: newHigh ? "#c084fc" : warm ? "#f59e0b" : "#e8eaed" }}>
{cur.toFixed(1)}
</span>
<span style={{ fontSize: 20, color: "#5a6170", marginLeft: 3 }}>°C</span>
</>
@@ -132,63 +179,51 @@ export default function MonitorPanel() {
)}
</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 ? (
{max != 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: "#9aa0b0" }}>{max.toFixed(1)}°C</span>
{mtt && <span style={{ fontSize: 12, color: "#4a5160", marginLeft: 2 }}>{mtt}</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}
color: tr.c === "rising" ? "#34d399" : tr.c === "falling" ? "#60a5fa" : "#5a6170" }}>
{tr.s}
</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>
{age != null ? (
<span style={{ color: "#5a6170", fontSize: 13 }}>{age} min ago</span>
) : (
<span style={{ color: "#3a4050" }}>--</span>
)}
</div>
</div>
{/* Runway */}
{rw.length > 0 && (
{rwPairs.length > 0 && rwTemps.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>
))}
{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>
);
}
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",
};
-2
View File
@@ -22,10 +22,8 @@ from web.analysis_service import ( # noqa: E402
)
from web.core import app # noqa: E402
from web.routes import router # noqa: E402
from web.monitor_routes import router as monitor_router # noqa: E402
app.include_router(router)
app.include_router(monitor_router)
__all__ = [
"app",
-133
View File
@@ -1,133 +0,0 @@
"""市场监控 — JSON API for frontend React component."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from fastapi import APIRouter
from loguru import logger
from web.analysis_service import _analyze
router = APIRouter()
_CITIES: List[Dict[str, Any]] = [
{"key": "seoul", "en_name": "Seoul", "icao": "RKSI", "airport": "Incheon", "tz": 9, "tz_abbr": "KST", "rw": True},
{"key": "busan", "en_name": "Busan", "icao": "RKPK", "airport": "Gimhae", "tz": 9, "tz_abbr": "KST", "rw": True},
{"key": "tokyo", "en_name": "Tokyo", "icao": "44166", "airport": "Haneda", "tz": 9, "tz_abbr": "JST", "rw": False},
{"key": "ankara", "en_name": "Ankara", "icao": "17128", "airport": "Esenboğa", "tz": 3, "tz_abbr": "TRT", "rw": False},
{"key": "helsinki", "en_name": "Helsinki", "icao": "EFHK", "airport": "Vantaa", "tz": 3, "tz_abbr": "EEST", "rw": False},
{"key": "amsterdam", "en_name": "Amsterdam", "icao": "EHAM", "airport": "Schiphol", "tz": 2, "tz_abbr": "CEST", "rw": False},
{"key": "istanbul", "en_name": "Istanbul", "icao": "17058", "airport": "Airport", "tz": 3, "tz_abbr": "TRT", "rw": False},
{"key": "paris", "en_name": "Paris", "icao": "LFPB", "airport": "Le Bourget", "tz": 2, "tz_abbr": "CEST", "rw": False},
{"key": "hong kong", "en_name": "Hong Kong", "icao": "HKO", "airport": "Observatory", "tz": 8, "tz_abbr": "HKT", "rw": False},
{"key": "lau fau shan","en_name": "Lau Fau Shan","icao": "LFS", "airport": "Lau Fau Shan", "tz": 8, "tz_abbr": "HKT", "rw": False},
{"key": "taipei", "en_name": "Taipei", "icao": "466920", "airport": "Songshan", "tz": 8, "tz_abbr": "TST", "rw": False},
]
def _sf(v: Any) -> Optional[float]:
if v is None:
return None
try:
return round(float(v), 1)
except (ValueError, TypeError):
return None
def _trend_info(icao: str) -> tuple:
try:
from src.utils.telegram_push import _check_rising_trend
if _check_rising_trend(icao):
return ("", "rising")
except Exception:
pass
try:
from src.database.db_manager import DBManager
obs = DBManager().get_airport_obs_recent(icao, minutes=60)
temps = [r.get("temp_c") for r in obs if r.get("temp_c") is not None]
if len(temps) >= 4 and temps[-1] < temps[len(temps) // 2]:
return ("", "falling")
except Exception:
pass
return ("", "flat")
def _obs_age(obs_time_str: Optional[str]) -> Optional[int]:
if not obs_time_str:
return None
try:
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"):
try:
dt = datetime.strptime(str(obs_time_str)[:26], fmt)
dt = dt.replace(tzinfo=timezone.utc)
age = (datetime.now(timezone.utc) - dt).total_seconds()
return max(0, int(age // 60))
except (ValueError, TypeError):
continue
ts = float(obs_time_str)
if ts > 1_000_000_000:
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
age = (datetime.now(timezone.utc) - dt).total_seconds()
return max(0, int(age // 60))
except (ValueError, TypeError, OSError):
pass
return None
def _build_cards() -> list:
cards = []
for cfg in _CITIES:
try:
cw = _analyze(cfg["key"])
ac = cw.get("airport_current") or {}
cur = cw.get("current") or {}
ct = _sf(ac.get("temp")) or _sf(cur.get("temp"))
msf = ac.get("max_so_far")
mtt = ac.get("max_temp_time") or ""
obs = ac.get("obs_time") or ""
local_time = cw.get("local_time") or ""
new_high = (ct is not None and msf is not None and ct >= msf + 0.3)
tsym, tcss = _trend_info(cfg["icao"])
age = _obs_age(obs)
rw_html = ""
if cfg.get("rw"):
amos = cw.get("amos") or {}
rw_obs = (amos.get("runway_obs") or {}) if amos else {}
pairs = rw_obs.get("runway_pairs") or []
temps = rw_obs.get("temperatures") or []
for (r1, r2), (t, _d) in zip(pairs, temps):
if t is not None:
rw_html += f'<div class="runway-row"><span class="runway-label">{r1}/{r2}</span><span class="runway-temp">{round(float(t),1):.1f}°C</span></div>\n'
cards.append({
"en_name": cfg["en_name"], "airport": cfg["airport"],
"obs_time": obs or local_time,
"current_temp": ct, "max_so_far": _sf(msf), "max_temp_time": mtt,
"trend_sym": tsym, "trend_css": tcss, "obs_age_min": age,
"new_high": new_high,
"temp_warm": ct is not None and ct >= 30,
"runway_pairs": rw_html,
})
except Exception:
logger.exception("monitor: failed city {}", cfg["key"])
cards.sort(key=lambda c: (c["current_temp"] is not None, c["current_temp"] or -999), reverse=True)
return cards
_cache = None
_cache_ts = 0
@router.get("/m/json")
async def monitor_json():
global _cache, _cache_ts
now = __import__("time").time()
if _cache is not None and now - _cache_ts < 30:
return _cache
_cache = _build_cards()
_cache_ts = now
return _cache