From f5acae3c81fbebf604f55c536f07ee57903ac5b8 Mon Sep 17 00:00:00 2001
From: "2569718930@qq.com" <2569718930@qq.com>
Date: Thu, 14 May 2026 02:32:56 +0800
Subject: [PATCH] feat: implement ScanTerminalDashboard with integrated
monitoring, paywall, and AI-driven city analysis features
---
.../dashboard/ProFeaturePaywall.tsx | 2 +-
.../dashboard/ScanTerminalDashboard.tsx | 17 ++++-
.../monitoring/MonitorPanel.module.css | 13 ++++
.../dashboard/monitoring/MonitorPanel.tsx | 71 ++++++++-----------
4 files changed, 59 insertions(+), 44 deletions(-)
diff --git a/frontend/components/dashboard/ProFeaturePaywall.tsx b/frontend/components/dashboard/ProFeaturePaywall.tsx
index 33e99c54..58a44f0a 100644
--- a/frontend/components/dashboard/ProFeaturePaywall.tsx
+++ b/frontend/components/dashboard/ProFeaturePaywall.tsx
@@ -14,7 +14,7 @@ const TELEGRAM_GROUP_URL = String(
const SUBSCRIPTION_HELP_HREF = "/subscription-help";
type ProFeaturePaywallProps = {
- feature: "today" | "history" | "future" | "assistant" | "scan";
+ feature: "today" | "history" | "future" | "assistant" | "scan" | "monitor";
onClose?: () => void;
};
diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx
index 76aafe72..c230dd57 100644
--- a/frontend/components/dashboard/ScanTerminalDashboard.tsx
+++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx
@@ -602,7 +602,22 @@ function ScanTerminalScreen() {
renderMainView()
)}
{resolvedView === "monitor" && (
-
+ isPro ? (
+ {
+ const matchedRow = findRowForCity(timeSortedRows, cityName);
+ if (matchedRow) {
+ setSelectedRowId(matchedRow.id);
+ store.preloadCityFromRow(matchedRow);
+ }
+ addAiPinnedCity(cityName);
+ setActiveView("analysis");
+ void store.selectCity(cityName);
+ }}
+ />
+ ) : (
+
+ )
)}
diff --git a/frontend/components/dashboard/monitoring/MonitorPanel.module.css b/frontend/components/dashboard/monitoring/MonitorPanel.module.css
index 0b4318fb..9a679133 100644
--- a/frontend/components/dashboard/monitoring/MonitorPanel.module.css
+++ b/frontend/components/dashboard/monitoring/MonitorPanel.module.css
@@ -135,6 +135,19 @@
border-color: rgba(124, 58, 237, 0.3);
}
+.root :global(.monitor-card.clickable) {
+ cursor: pointer;
+}
+.root :global(.monitor-card.clickable:hover) {
+ border-color: var(--color-accent, #7c3aed);
+ transform: translateY(-2px);
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
+}
+.root :global(.monitor-card.clickable:focus-visible) {
+ outline: 2px solid var(--color-accent, #7c3aed);
+ outline-offset: 2px;
+}
+
/* ── Card header ── */
.root :global(.monitor-card-head) {
display: flex;
diff --git a/frontend/components/dashboard/monitoring/MonitorPanel.tsx b/frontend/components/dashboard/monitoring/MonitorPanel.tsx
index 7467cd2e..d0aefa76 100644
--- a/frontend/components/dashboard/monitoring/MonitorPanel.tsx
+++ b/frontend/components/dashboard/monitoring/MonitorPanel.tsx
@@ -85,40 +85,14 @@ function trendSymbol(tr: "rising" | "falling" | "flat") {
}
/**
- * Resolve today's observed high temperature from airport METAR sources only.
- *
- * Priority (all METAR / airport station sources):
- * 1. airport_current.max_so_far — backend rolling max (most authoritative)
- * 2. max(airport_primary_today_obs) — client-derived from airport primary station's today series
- * 3. max(metar_today_obs) — client-derived from METAR today obs series
- *
- * The settlement station (current.max_so_far) is intentionally excluded —
- * it may be a different microclimate and is not the airport METAR reading.
+ * Resolve today's observed high temperature.
+ * Aligned with Telegram bot (_get_airport_daily_high):
+ * only airport_current.max_so_far — no fallback.
*/
function resolveMaxSoFar(detail: CityDetail | undefined): number | null {
- // ① Backend rolling max from airport METAR — primary and most reliable
- const backendMax = detail?.airport_current?.max_so_far ?? null;
- if (backendMax != null) return backendMax;
-
- // ② Client-derived max from airport primary station's today obs series
- const primaryObs = detail?.airport_primary_today_obs ?? [];
- if (primaryObs.length) {
- const temps = primaryObs
- .map((o) => o.temp)
- .filter((t): t is number => t != null);
- if (temps.length) return Math.max(...temps);
- }
-
- // ③ Client-derived max from METAR today obs series
- const metarObs = detail?.metar_today_obs ?? [];
- if (metarObs.length) {
- const temps = metarObs
- .map((o) => o.temp)
- .filter((t): t is number => t != null);
- if (temps.length) return Math.max(...temps);
- }
-
- return null;
+ const v = detail?.airport_current?.max_so_far ?? null;
+ if (v == null) return null;
+ return Math.round(v * 10) / 10;
}
/* ── Airport names ───────────────────────────────────────────── */
@@ -176,7 +150,11 @@ function FreshnessDot({ level, title }: { level: Freshness; title: string }) {
}
/* ── Main component ──────────────────────────────────────────── */
-export default function MonitorPanel() {
+export default function MonitorPanel({
+ onCityClick,
+}: {
+ onCityClick?: (cityName: string) => void;
+}) {
const store = useDashboardStore();
const { locale } = useI18n();
const isEn = locale === "en-US";
@@ -388,6 +366,7 @@ export default function MonitorPanel() {
const obs = ac?.obs_time ?? detail.local_time ?? "";
const age = ac?.obs_age_min ?? null;
const freshness = freshnessLevel(age);
+ const tempSymbol = detail.temp_symbol || "°C"; // °F for US cities
const newHigh = cur != null && max != null && cur >= max + 0.3;
const warm = !newHigh && cur != null && cur >= 30;
const tr = trendClass(detail);
@@ -402,7 +381,13 @@ export default function MonitorPanel() {
"monitor-card",
newHigh ? "new-high" : "",
isRefreshing ? "refreshing" : "",
+ onCityClick ? "clickable" : "",
].filter(Boolean).join(" ")}
+ role={onCityClick ? "button" : undefined}
+ tabIndex={onCityClick ? 0 : undefined}
+ onClick={onCityClick ? () => onCityClick(key) : undefined}
+ onKeyDown={onCityClick ? (e) => { if (e.key === "Enter" || e.key === " ") onCityClick(key); } : undefined}
+ title={onCityClick ? (isEn ? `Open ${detail.display_name || key} decision card` : `打开 ${detail.display_name || key} 决策卡`) : undefined}
>
{/* Refresh progress bar */}
{isRefreshing && }
@@ -430,7 +415,7 @@ export default function MonitorPanel() {
{cur.toFixed(1)}
- °C
+ {tempSymbol}
>
) : (
--
@@ -443,7 +428,7 @@ export default function MonitorPanel() {
{t("High", "高温", lang)}
{max != null ? (
<>
- {max.toFixed(1)}°C
+ {max.toFixed(1)}{tempSymbol}
{mtt && {mtt}}
>
) : (
@@ -456,13 +441,15 @@ export default function MonitorPanel() {
{t("Airport METAR", "机场报文", lang)}
- {age != null
- ? (isEn
- ? `${age} min ago`
- : age < 60
- ? `${age} 分钟未更新`
- : `${Math.round(age / 60)} 小时未更新`)
- : --}
+ {age != null ? (
+ age < 60
+ ? (isEn ? `${age} min ago` : `${age} 分钟未更新`)
+ : (isEn
+ ? `⚠ last ${obs || "?"}`
+ : `⚠ 最后报文 ${obs || "--"}`)
+ ) : (
+ --
+ )}