feat: implement MonitorPanel dashboard component with status indicators and grid view
This commit is contained in:
@@ -43,13 +43,34 @@
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.root :global(.monitor-load-progress) {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.root :global(.monitor-syncing-badge) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
color: var(--color-accent-primary);
|
||||
background: rgba(77, 163, 255, 0.08);
|
||||
border: 1px solid rgba(77, 163, 255, 0.18);
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.root :global(.monitor-loading-dot) {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent-primary);
|
||||
animation: monitorPulse 1.2s ease-in-out infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.root :global(.monitor-notify-btn) {
|
||||
@@ -240,7 +261,200 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Runway sub-section ── */
|
||||
/* ── Freshness legend ── */
|
||||
.root :global(.monitor-legend) {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
margin-top: -12px;
|
||||
}
|
||||
|
||||
.root :global(.monitor-legend-item) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ── Freshness dot ── */
|
||||
.root :global(.monitor-freshness-dot) {
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.root :global(.monitor-freshness-dot.fresh) {
|
||||
background: var(--color-signal-success);
|
||||
box-shadow: 0 0 5px rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
|
||||
.root :global(.monitor-freshness-dot.aging) {
|
||||
background: var(--color-signal-warning);
|
||||
box-shadow: 0 0 5px rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
.root :global(.monitor-freshness-dot.stale) {
|
||||
background: var(--color-signal-danger);
|
||||
box-shadow: 0 0 5px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.root :global(.monitor-freshness-dot.unknown) {
|
||||
background: var(--color-text-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ── Obs-age with freshness colour ── */
|
||||
.root :global(.monitor-obs-age) {
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.root :global(.monitor-obs-age.fresh) { color: var(--color-signal-success); }
|
||||
.root :global(.monitor-obs-age.aging) { color: var(--color-signal-warning); }
|
||||
.root :global(.monitor-obs-age.stale) { color: var(--color-signal-danger); }
|
||||
.root :global(.monitor-obs-age.unknown) { color: var(--color-text-muted); }
|
||||
|
||||
/* ── Refresh progress bar (top edge of card while fetching) ── */
|
||||
.root :global(.monitor-refresh-bar) {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
border-radius: 12px 12px 0 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
var(--color-accent-primary) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: monitorSweep 1.4s linear infinite;
|
||||
}
|
||||
|
||||
/* ── Card refreshing state ── */
|
||||
.root :global(.monitor-card.refreshing) {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Slight dim on stale cards so fresh ones stand out */
|
||||
.root :global(.monitor-card:not(.refreshing)) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
LIGHT THEME OVERRIDES
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-panel) {
|
||||
background: #F7F9FC;
|
||||
color: #0F172A;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-header) {
|
||||
border-bottom-color: #E2E8F0;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-title) {
|
||||
color: #0F172A;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-legend-item) {
|
||||
color: #64748B;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-time),
|
||||
.root :global(.scan-terminal.light .monitor-stat-label),
|
||||
.root :global(.scan-terminal.light .monitor-obs-time),
|
||||
.root :global(.scan-terminal.light .monitor-rw-label),
|
||||
.root :global(.scan-terminal.light .monitor-high-time),
|
||||
.root :global(.scan-terminal.light .monitor-load-progress) {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-card) {
|
||||
background: #FFFFFF;
|
||||
border-color: #E2E8F0;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-card.new-high) {
|
||||
border-color: rgba(124, 58, 237, 0.24);
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-skeleton-card) {
|
||||
background: #FFFFFF;
|
||||
border-color: #E2E8F0;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-skeleton-line) {
|
||||
background: #E2E8F0;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-city-name) { color: #0F172A; }
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-airport-name),
|
||||
.root :global(.scan-terminal.light .monitor-high-value),
|
||||
.root :global(.scan-terminal.light .monitor-rw-temp) {
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-temp-value) { color: #0F172A; }
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-temp-unit),
|
||||
.root :global(.scan-terminal.light .monitor-temp-missing),
|
||||
.root :global(.scan-terminal.light .monitor-stat-missing) {
|
||||
color: #94A3B8;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-divider) { background: #E2E8F0; }
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-notify-btn) {
|
||||
border-color: rgba(148, 163, 184, 0.3);
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-notify-btn:hover) {
|
||||
border-color: rgba(37, 99, 235, 0.38);
|
||||
background: #EEF2F7;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-trend.rising) { color: #16a34a; }
|
||||
.root :global(.scan-terminal.light .monitor-trend.falling) { color: #2563EB; }
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-syncing-badge) {
|
||||
color: #2563EB;
|
||||
background: #EFF6FF;
|
||||
border-color: rgba(37, 99, 235, 0.22);
|
||||
}
|
||||
|
||||
/* obs-age colours stay semantic in light mode */
|
||||
.root :global(.scan-terminal.light .monitor-obs-age.fresh) { color: #16a34a; }
|
||||
.root :global(.scan-terminal.light .monitor-obs-age.aging) { color: #d97706; }
|
||||
.root :global(.scan-terminal.light .monitor-obs-age.stale) { color: #dc2626; }
|
||||
.root :global(.scan-terminal.light .monitor-obs-age.unknown) { color: #64748b; }
|
||||
|
||||
/* ── Animations ── */
|
||||
@keyframes monitorPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
@keyframes monitorShimmer {
|
||||
0%, 100% { opacity: 0.55; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes monitorSweep {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.root :global(.monitor-divider) {
|
||||
height: 1px;
|
||||
background: var(--color-border-subtle);
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import { useMemo, useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import type { CityDetail } from "@/lib/dashboard-types";
|
||||
|
||||
/* ── Constants ───────────────────────────────────────────────── */
|
||||
const MONITOR_KEYS = [
|
||||
"seoul", "busan", "tokyo", "ankara", "helsinki", "amsterdam",
|
||||
"istanbul", "paris", "hong kong", "lau fau shan", "taipei",
|
||||
@@ -11,14 +13,35 @@ const MONITOR_KEYS = [
|
||||
"miami", "san francisco", "houston", "dallas", "austin", "seattle",
|
||||
] as const;
|
||||
|
||||
const MONITOR_FETCH_CONCURRENCY = 6;
|
||||
const MONITOR_REFRESH_INTERVAL_MS = 60_000;
|
||||
const MONITOR_FRESHNESS_TTL_MS = 45_000;
|
||||
type MonitorKey = (typeof MONITOR_KEYS)[number];
|
||||
|
||||
type MonitorCity = {
|
||||
key: string;
|
||||
detail: CityDetail | undefined;
|
||||
};
|
||||
const CONCURRENCY = 6;
|
||||
const REFRESH_INTERVAL_MS = 60_000;
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────── */
|
||||
type Lang = { isEn: boolean };
|
||||
function t(en: string, zh: string, { isEn }: Lang) { return isEn ? en : zh; }
|
||||
|
||||
type Freshness = "fresh" | "aging" | "stale" | "unknown";
|
||||
|
||||
function freshnessLevel(ageMin: number | null | undefined): Freshness {
|
||||
if (ageMin == null) return "unknown";
|
||||
if (ageMin < 20) return "fresh";
|
||||
if (ageMin < 45) return "aging";
|
||||
return "stale";
|
||||
}
|
||||
|
||||
function freshnessDotTitle(level: Freshness, ageMin: number | null | undefined, isEn: boolean): string {
|
||||
const age = ageMin != null ? (isEn ? `${ageMin} min ago` : `${ageMin} 分钟前`) : "--";
|
||||
if (isEn) {
|
||||
return level === "fresh" ? `Fresh · ${age}` :
|
||||
level === "aging" ? `Aging · ${age}` :
|
||||
level === "stale" ? `Stale · ${age}` : "Unknown age";
|
||||
}
|
||||
return level === "fresh" ? `数据新鲜 · ${age}` :
|
||||
level === "aging" ? `数据变旧 · ${age}` :
|
||||
level === "stale" ? `数据陈旧 · ${age}` : "更新时间未知";
|
||||
}
|
||||
|
||||
function trendClass(detail: CityDetail | undefined): "rising" | "falling" | "flat" {
|
||||
if (!detail?.airport_current) return "flat";
|
||||
@@ -30,64 +53,84 @@ function trendClass(detail: CityDetail | undefined): "rising" | "falling" | "fla
|
||||
return "flat";
|
||||
}
|
||||
|
||||
function trendSymbol(t: "rising" | "falling" | "flat"): string {
|
||||
return t === "rising" ? "↑" : t === "falling" ? "↓" : "→";
|
||||
function trendSymbol(tr: "rising" | "falling" | "flat") {
|
||||
return tr === "rising" ? "↑" : tr === "falling" ? "↓" : "→";
|
||||
}
|
||||
|
||||
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 { /* individual city failures show "--" */ }
|
||||
}
|
||||
});
|
||||
await Promise.allSettled(workers);
|
||||
/* ── Airport names ───────────────────────────────────────────── */
|
||||
const AIRPORT_NAMES: Record<string, { en: string; zh: string }> = {
|
||||
seoul: { en: "Incheon", zh: "仁川" },
|
||||
busan: { en: "Gimhae", zh: "金海" },
|
||||
tokyo: { en: "Haneda", zh: "羽田" },
|
||||
ankara: { en: "Esenboğa", zh: "埃森博阿" },
|
||||
helsinki: { en: "Vantaa", zh: "万塔" },
|
||||
amsterdam: { en: "Schiphol", zh: "史基浦" },
|
||||
istanbul: { en: "Airport", zh: "机场站" },
|
||||
paris: { en: "Le Bourget", zh: "勒布尔热" },
|
||||
"hong kong": { en: "Observatory", zh: "天文台" },
|
||||
"lau fau shan": { en: "Lau Fau Shan",zh: "流浮山" },
|
||||
taipei: { en: "Songshan", zh: "松山" },
|
||||
"new york": { en: "LaGuardia", zh: "拉瓜迪亚" },
|
||||
"los angeles": { en: "LAX", zh: "洛杉矶" },
|
||||
chicago: { en: "O'Hare", zh: "奥黑尔" },
|
||||
denver: { en: "Buckley", zh: "巴克利" },
|
||||
atlanta: { en: "Hartsfield", zh: "哈茨菲尔德" },
|
||||
miami: { en: "MIA", zh: "迈阿密" },
|
||||
"san francisco": { en: "SFO", zh: "旧金山" },
|
||||
houston: { en: "Hobby", zh: "霍比" },
|
||||
dallas: { en: "Love Field", zh: "勒芙机场" },
|
||||
austin: { en: "Bergstrom", zh: "伯格斯特罗姆" },
|
||||
seattle: { en: "SeaTac", zh: "西塔克" },
|
||||
};
|
||||
|
||||
function airportLabel(key: string, isEn: boolean) {
|
||||
const e = AIRPORT_NAMES[key];
|
||||
return e ? (isEn ? e.en : e.zh) : "";
|
||||
}
|
||||
|
||||
let lastRefreshCompletedAt = 0;
|
||||
|
||||
function SkeletonCard() {
|
||||
/* ── Skeleton Card ───────────────────────────────────────────── */
|
||||
function SkeletonCard({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="monitor-skeleton-card">
|
||||
<div className="monitor-skeleton-line" style={{ height: 14, width: "45%", marginBottom: 14 }} />
|
||||
<div className="monitor-skeleton-line" style={{ height: 52, width: "55%", marginBottom: 16 }} />
|
||||
<div className="monitor-skeleton-line" style={{ height: 12, width: "70%" }} />
|
||||
<div className="monitor-skeleton-line" style={{ height: 12, width: "40%", marginTop: 8 }} />
|
||||
<div className="monitor-skeleton-card" aria-label={label}>
|
||||
<div className="monitor-skeleton-line" style={{ height: 13, width: "42%", marginBottom: 14 }} />
|
||||
<div className="monitor-skeleton-line" style={{ height: 50, width: "52%", marginBottom: 16 }} />
|
||||
<div className="monitor-skeleton-line" style={{ height: 11, width: "68%" }} />
|
||||
<div className="monitor-skeleton-line" style={{ height: 11, width: "38%", marginTop: 8 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const AIRPORT_NAMES: 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", "new york": "LaGuardia",
|
||||
"los angeles": "LAX", chicago: "O'Hare", denver: "Buckley",
|
||||
atlanta: "Hartsfield", miami: "MIA", "san francisco": "SFO",
|
||||
houston: "Hobby", dallas: "Love Field", austin: "Bergstrom",
|
||||
seattle: "SeaTac",
|
||||
};
|
||||
/* ── Freshness dot ───────────────────────────────────────────── */
|
||||
function FreshnessDot({ level, title }: { level: Freshness; title: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`monitor-freshness-dot ${level}`}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main component ──────────────────────────────────────────── */
|
||||
export default function MonitorPanel() {
|
||||
const store = useDashboardStore();
|
||||
const { locale } = useI18n();
|
||||
const isEn = locale === "en-US";
|
||||
const lang: Lang = { isEn };
|
||||
|
||||
const details = store.cityDetailsByName;
|
||||
const detailsRef = useRef(details);
|
||||
detailsRef.current = details;
|
||||
|
||||
const [time, setTime] = useState("");
|
||||
const [initialLoadDone, setInitialLoadDone] = useState(false);
|
||||
const [fetchingKeys, setFetchingKeys] = useState<ReadonlySet<string>>(new Set());
|
||||
const cancelledRef = useRef(false);
|
||||
const fetchingRef = useRef(false);
|
||||
const globalFetchingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTime(new Date().toLocaleTimeString());
|
||||
const t = setInterval(() => setTime(new Date().toLocaleTimeString()), 60_000);
|
||||
return () => clearInterval(t);
|
||||
const timer = setInterval(() => setTime(new Date().toLocaleTimeString()), 30_000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const [notify, setNotify] = useState(() => {
|
||||
@@ -95,60 +138,88 @@ export default function MonitorPanel() {
|
||||
return localStorage.getItem("monitor_notify") !== "off";
|
||||
});
|
||||
|
||||
const refreshAll = useCallback(
|
||||
async (force: boolean) => {
|
||||
if (fetchingRef.current) return;
|
||||
if (
|
||||
!force &&
|
||||
lastRefreshCompletedAt > 0 &&
|
||||
Date.now() - lastRefreshCompletedAt < MONITOR_FRESHNESS_TTL_MS
|
||||
) {
|
||||
setInitialLoadDone(true);
|
||||
return;
|
||||
/* Per-city fetch with loading-key tracking */
|
||||
const fetchCity = useCallback(
|
||||
async (key: MonitorKey, force: boolean) => {
|
||||
setFetchingKeys((prev) => new Set([...prev, key]));
|
||||
try {
|
||||
await store.ensureCityDetail(key, force, "panel");
|
||||
} catch {
|
||||
/* individual city errors are shown as "--" in the card */
|
||||
} finally {
|
||||
setFetchingKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
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],
|
||||
);
|
||||
|
||||
/* Refresh all cities, sorted by staleness (most stale first). */
|
||||
const refreshAll = useCallback(
|
||||
async (force: boolean) => {
|
||||
if (globalFetchingRef.current) return;
|
||||
globalFetchingRef.current = true;
|
||||
|
||||
/* Sort keys: cities with no data first, then by obs_age_min descending */
|
||||
const sorted = [...MONITOR_KEYS].sort((a, b) => {
|
||||
const d = detailsRef.current;
|
||||
const ageA = d[a]?.airport_current?.obs_age_min ?? Infinity;
|
||||
const ageB = d[b]?.airport_current?.obs_age_min ?? Infinity;
|
||||
return ageB - ageA; // stale first
|
||||
});
|
||||
|
||||
const queue = sorted as MonitorKey[];
|
||||
const workers = Array.from({ length: CONCURRENCY }, async () => {
|
||||
while (queue.length > 0) {
|
||||
if (cancelledRef.current) return;
|
||||
const key = queue.shift();
|
||||
if (!key) break;
|
||||
await fetchCity(key, force);
|
||||
}
|
||||
});
|
||||
await Promise.allSettled(workers);
|
||||
|
||||
globalFetchingRef.current = false;
|
||||
},
|
||||
[fetchCity],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
cancelledRef.current = false;
|
||||
void refreshAll(false);
|
||||
const t = setInterval(() => {
|
||||
const timer = setInterval(() => {
|
||||
if (!document.hidden) void refreshAll(true);
|
||||
}, MONITOR_REFRESH_INTERVAL_MS);
|
||||
}, REFRESH_INTERVAL_MS);
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
clearInterval(t);
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [refreshAll]);
|
||||
|
||||
const cities: MonitorCity[] = useMemo(
|
||||
() => MONITOR_KEYS.map((k) => ({ key: k, detail: details[k] })),
|
||||
[details],
|
||||
);
|
||||
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...cities].sort((a, b) => {
|
||||
/* City list sorted by current temp descending */
|
||||
const sorted = useMemo(() => {
|
||||
return [...MONITOR_KEYS]
|
||||
.map((k) => ({ key: k, detail: details[k] }))
|
||||
.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],
|
||||
});
|
||||
}, [details]);
|
||||
|
||||
/* Summary counts for the header */
|
||||
const loadedCount = useMemo(
|
||||
() => MONITOR_KEYS.filter((k) => details[k] != null).length,
|
||||
[details],
|
||||
);
|
||||
const totalCount = MONITOR_KEYS.length;
|
||||
const allLoaded = loadedCount === totalCount;
|
||||
|
||||
const toggleNotify = () => {
|
||||
const next = !notify;
|
||||
@@ -159,6 +230,7 @@ export default function MonitorPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
/* Browser notifications for new highs */
|
||||
useEffect(() => {
|
||||
if (!notify || typeof Notification === "undefined" || Notification.permission !== "granted") return;
|
||||
const today = new Date().toDateString();
|
||||
@@ -166,41 +238,56 @@ export default function MonitorPanel() {
|
||||
try { notified = JSON.parse(localStorage.getItem("monitor_notified_highs") || "{}"); } catch {}
|
||||
if (notified._day !== today) notified = { _day: today };
|
||||
|
||||
for (const c of sorted) {
|
||||
const ac = c.detail?.airport_current;
|
||||
const cur = ac?.temp ?? c.detail?.current?.temp ?? null;
|
||||
for (const { key, detail } of sorted) {
|
||||
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) {
|
||||
const key = `${c.key}|${cur}`;
|
||||
if (!notified[key]) {
|
||||
notified[key] = true;
|
||||
const id = `${key}|${cur}`;
|
||||
if (!notified[id]) {
|
||||
notified[id] = 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 · New daily high.`,
|
||||
tag: key,
|
||||
const name = detail?.display_name || key;
|
||||
new Notification(isEn ? `🔴 New High — ${name}` : `🔴 新高 — ${name}`, {
|
||||
body: isEn ? `${cur}°C · New daily high.` : `${cur}°C · 今日新高。`,
|
||||
tag: id,
|
||||
requireInteraction: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [sorted, notify]);
|
||||
}, [sorted, notify, isEn]);
|
||||
|
||||
/* ── Render ── */
|
||||
return (
|
||||
<div className="monitor-panel">
|
||||
{/* Header */}
|
||||
<div className="monitor-header">
|
||||
<h2 className="monitor-title">🔥 市场监控</h2>
|
||||
<div>
|
||||
<h2 className="monitor-title">
|
||||
{t("🔥 Market Monitor", "🔥 市场监控", lang)}
|
||||
</h2>
|
||||
{!allLoaded && (
|
||||
<p className="monitor-load-progress">
|
||||
{isEn
|
||||
? `Loading ${loadedCount} / ${totalCount} cities…`
|
||||
: `正在加载 ${loadedCount} / ${totalCount} 个城市…`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="monitor-controls">
|
||||
{!initialLoadDone && (
|
||||
<span className="monitor-loading-indicator">
|
||||
{fetchingKeys.size > 0 && (
|
||||
<span className="monitor-syncing-badge">
|
||||
<span className="monitor-loading-dot" />
|
||||
加载中…
|
||||
{isEn ? `Syncing ${fetchingKeys.size}` : `同步中 ${fetchingKeys.size}`}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className={`monitor-notify-btn${notify ? "" : " muted"}`}
|
||||
onClick={toggleNotify}
|
||||
title={notify ? "关闭价格提醒" : "开启价格提醒"}
|
||||
title={notify
|
||||
? t("Disable alerts", "关闭提醒", lang)
|
||||
: t("Enable alerts", "开启提醒", lang)}
|
||||
>
|
||||
{notify ? "🔔" : "🔕"}
|
||||
</button>
|
||||
@@ -208,92 +295,130 @@ export default function MonitorPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Freshness legend */}
|
||||
<div className="monitor-legend">
|
||||
<span className="monitor-legend-item">
|
||||
<span className="monitor-freshness-dot fresh" />
|
||||
{t("< 20 min", "< 20 分", lang)}
|
||||
</span>
|
||||
<span className="monitor-legend-item">
|
||||
<span className="monitor-freshness-dot aging" />
|
||||
{t("20–45 min", "20–45 分", lang)}
|
||||
</span>
|
||||
<span className="monitor-legend-item">
|
||||
<span className="monitor-freshness-dot stale" />
|
||||
{t("> 45 min", "> 45 分", lang)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Card grid — progressive: show skeleton only for cities not yet loaded */}
|
||||
<div className="monitor-grid">
|
||||
{!initialLoadDone
|
||||
? MONITOR_KEYS.map((k) => <SkeletonCard key={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 = !newHigh && cur != null && cur >= 30;
|
||||
const tr = trendClass(c.detail);
|
||||
const rwPairs = c.detail?.amos?.runway_obs?.runway_pairs ?? [];
|
||||
const rwTemps = c.detail?.amos?.runway_obs?.temperatures ?? [];
|
||||
{sorted.map(({ key, detail }) => {
|
||||
const isRefreshing = fetchingKeys.has(key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={c.key}
|
||||
className={`monitor-card${newHigh ? " new-high" : ""}`}
|
||||
>
|
||||
<div className="monitor-card-head">
|
||||
<span className="monitor-city-name">
|
||||
{c.detail?.display_name || c.key}
|
||||
/* City not yet loaded at all → skeleton */
|
||||
if (!detail) {
|
||||
return <SkeletonCard key={key} label={key} />;
|
||||
}
|
||||
|
||||
const ac = detail.airport_current;
|
||||
const cur = ac?.temp ?? detail.current?.temp ?? null;
|
||||
const max = ac?.max_so_far ?? null;
|
||||
const mtt = ac?.max_temp_time ?? null;
|
||||
const obs = ac?.obs_time ?? detail.local_time ?? "";
|
||||
const age = ac?.obs_age_min ?? null;
|
||||
const freshness = freshnessLevel(age);
|
||||
const newHigh = cur != null && max != null && cur >= max + 0.3;
|
||||
const warm = !newHigh && cur != null && cur >= 30;
|
||||
const tr = trendClass(detail);
|
||||
const rwPairs = detail.amos?.runway_obs?.runway_pairs ?? [];
|
||||
const rwTemps = detail.amos?.runway_obs?.temperatures ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={[
|
||||
"monitor-card",
|
||||
newHigh ? "new-high" : "",
|
||||
isRefreshing ? "refreshing" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
>
|
||||
{/* Refresh progress bar */}
|
||||
{isRefreshing && <div className="monitor-refresh-bar" />}
|
||||
|
||||
{/* Card header */}
|
||||
<div className="monitor-card-head">
|
||||
<span className="monitor-city-name">{detail.display_name || key}</span>
|
||||
<span className="monitor-airport-name">/ {airportLabel(key, isEn)}</span>
|
||||
<FreshnessDot
|
||||
level={freshness}
|
||||
title={freshnessDotTitle(freshness, age, isEn)}
|
||||
/>
|
||||
{newHigh && (
|
||||
<span className="monitor-new-high-badge">
|
||||
{t("◆ New High", "◆新高", lang)}
|
||||
</span>
|
||||
)}
|
||||
<span className="monitor-obs-time">{obs}</span>
|
||||
</div>
|
||||
|
||||
{/* Temperature */}
|
||||
<div className="monitor-temp-display">
|
||||
{cur != null ? (
|
||||
<>
|
||||
<span className={`monitor-temp-value${newHigh ? " new-high" : warm ? " warm" : ""}`}>
|
||||
{cur.toFixed(1)}
|
||||
</span>
|
||||
<span className="monitor-airport-name">/ {AIRPORT_NAMES[c.key]}</span>
|
||||
<span className="monitor-obs-time">{obs}</span>
|
||||
{newHigh && (
|
||||
<span className="monitor-new-high-badge">◆新高</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="monitor-temp-unit">°C</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="monitor-temp-missing">--</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="monitor-temp-display">
|
||||
{cur != null ? (
|
||||
<>
|
||||
<span className={`monitor-temp-value${newHigh ? " new-high" : warm ? " warm" : ""}`}>
|
||||
{cur.toFixed(1)}
|
||||
</span>
|
||||
<span className="monitor-temp-unit">°C</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="monitor-temp-missing">--</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="monitor-stats">
|
||||
<div className="monitor-high-row">
|
||||
<span className="monitor-stat-label">High</span>
|
||||
{max != null ? (
|
||||
<>
|
||||
<span className="monitor-high-value">{max.toFixed(1)}°C</span>
|
||||
{mtt && <span className="monitor-high-time">{mtt}</span>}
|
||||
</>
|
||||
) : (
|
||||
<span className="monitor-stat-missing">--</span>
|
||||
)}
|
||||
<span className={`monitor-trend ${tr}`}>{trendSymbol(tr)}</span>
|
||||
</div>
|
||||
<div className="monitor-obs-row">
|
||||
<span className="monitor-stat-label">Obs</span>
|
||||
{age != null ? (
|
||||
<span className="monitor-obs-age">{age} min ago</span>
|
||||
) : (
|
||||
<span className="monitor-stat-missing">--</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rwPairs.length > 0 && rwTemps.length > 0 && (
|
||||
{/* Stats */}
|
||||
<div className="monitor-stats">
|
||||
<div className="monitor-high-row">
|
||||
<span className="monitor-stat-label">{t("High", "高温", lang)}</span>
|
||||
{max != null ? (
|
||||
<>
|
||||
<div className="monitor-divider" />
|
||||
{rwPairs.map((p, i) => {
|
||||
const t = rwTemps[i]?.[0];
|
||||
if (t == null) return null;
|
||||
return (
|
||||
<div key={i} className="monitor-rw-row">
|
||||
<span className="monitor-rw-label">{p[0]}/{p[1]}</span>
|
||||
<span className="monitor-rw-temp">{t.toFixed(1)}°C</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<span className="monitor-high-value">{max.toFixed(1)}°C</span>
|
||||
{mtt && <span className="monitor-high-time">{mtt}</span>}
|
||||
</>
|
||||
) : (
|
||||
<span className="monitor-stat-missing">--</span>
|
||||
)}
|
||||
<span className={`monitor-trend ${tr}`}>{trendSymbol(tr)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="monitor-obs-row">
|
||||
<span className="monitor-stat-label">{t("Obs", "观测", lang)}</span>
|
||||
<span className={`monitor-obs-age ${freshness}`}>
|
||||
{age != null
|
||||
? (isEn ? `${age} min ago` : `${age} 分钟前`)
|
||||
: <span className="monitor-stat-missing">--</span>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Runway temps */}
|
||||
{rwPairs.length > 0 && rwTemps.length > 0 && (
|
||||
<>
|
||||
<div className="monitor-divider" />
|
||||
{rwPairs.map((p, i) => {
|
||||
const temp = rwTemps[i]?.[0];
|
||||
if (temp == null) return null;
|
||||
return (
|
||||
<div key={i} className="monitor-rw-row">
|
||||
<span className="monitor-rw-label">{p[0]}/{p[1]}</span>
|
||||
<span className="monitor-rw-temp">{temp.toFixed(1)}°C</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user