feat: implement ScanTerminalDashboard with integrated monitoring, paywall, and AI-driven city analysis features
This commit is contained in:
@@ -14,7 +14,7 @@ const TELEGRAM_GROUP_URL = String(
|
|||||||
const SUBSCRIPTION_HELP_HREF = "/subscription-help";
|
const SUBSCRIPTION_HELP_HREF = "/subscription-help";
|
||||||
|
|
||||||
type ProFeaturePaywallProps = {
|
type ProFeaturePaywallProps = {
|
||||||
feature: "today" | "history" | "future" | "assistant" | "scan";
|
feature: "today" | "history" | "future" | "assistant" | "scan" | "monitor";
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -602,7 +602,22 @@ function ScanTerminalScreen() {
|
|||||||
renderMainView()
|
renderMainView()
|
||||||
)}
|
)}
|
||||||
{resolvedView === "monitor" && (
|
{resolvedView === "monitor" && (
|
||||||
<MonitorPanel />
|
isPro ? (
|
||||||
|
<MonitorPanel
|
||||||
|
onCityClick={(cityName) => {
|
||||||
|
const matchedRow = findRowForCity(timeSortedRows, cityName);
|
||||||
|
if (matchedRow) {
|
||||||
|
setSelectedRowId(matchedRow.id);
|
||||||
|
store.preloadCityFromRow(matchedRow);
|
||||||
|
}
|
||||||
|
addAiPinnedCity(cityName);
|
||||||
|
setActiveView("analysis");
|
||||||
|
void store.selectCity(cityName);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ProFeaturePaywall feature="monitor" />
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -135,6 +135,19 @@
|
|||||||
border-color: rgba(124, 58, 237, 0.3);
|
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 ── */
|
/* ── Card header ── */
|
||||||
.root :global(.monitor-card-head) {
|
.root :global(.monitor-card-head) {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -85,40 +85,14 @@ function trendSymbol(tr: "rising" | "falling" | "flat") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve today's observed high temperature from airport METAR sources only.
|
* Resolve today's observed high temperature.
|
||||||
*
|
* Aligned with Telegram bot (_get_airport_daily_high):
|
||||||
* Priority (all METAR / airport station sources):
|
* only airport_current.max_so_far — no fallback.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
function resolveMaxSoFar(detail: CityDetail | undefined): number | null {
|
function resolveMaxSoFar(detail: CityDetail | undefined): number | null {
|
||||||
// ① Backend rolling max from airport METAR — primary and most reliable
|
const v = detail?.airport_current?.max_so_far ?? null;
|
||||||
const backendMax = detail?.airport_current?.max_so_far ?? null;
|
if (v == null) return null;
|
||||||
if (backendMax != null) return backendMax;
|
return Math.round(v * 10) / 10;
|
||||||
|
|
||||||
// ② 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Airport names ───────────────────────────────────────────── */
|
/* ── Airport names ───────────────────────────────────────────── */
|
||||||
@@ -176,7 +150,11 @@ function FreshnessDot({ level, title }: { level: Freshness; title: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Main component ──────────────────────────────────────────── */
|
/* ── Main component ──────────────────────────────────────────── */
|
||||||
export default function MonitorPanel() {
|
export default function MonitorPanel({
|
||||||
|
onCityClick,
|
||||||
|
}: {
|
||||||
|
onCityClick?: (cityName: string) => void;
|
||||||
|
}) {
|
||||||
const store = useDashboardStore();
|
const store = useDashboardStore();
|
||||||
const { locale } = useI18n();
|
const { locale } = useI18n();
|
||||||
const isEn = locale === "en-US";
|
const isEn = locale === "en-US";
|
||||||
@@ -388,6 +366,7 @@ export default function MonitorPanel() {
|
|||||||
const obs = ac?.obs_time ?? detail.local_time ?? "";
|
const obs = ac?.obs_time ?? detail.local_time ?? "";
|
||||||
const age = ac?.obs_age_min ?? null;
|
const age = ac?.obs_age_min ?? null;
|
||||||
const freshness = freshnessLevel(age);
|
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 newHigh = cur != null && max != null && cur >= max + 0.3;
|
||||||
const warm = !newHigh && cur != null && cur >= 30;
|
const warm = !newHigh && cur != null && cur >= 30;
|
||||||
const tr = trendClass(detail);
|
const tr = trendClass(detail);
|
||||||
@@ -402,7 +381,13 @@ export default function MonitorPanel() {
|
|||||||
"monitor-card",
|
"monitor-card",
|
||||||
newHigh ? "new-high" : "",
|
newHigh ? "new-high" : "",
|
||||||
isRefreshing ? "refreshing" : "",
|
isRefreshing ? "refreshing" : "",
|
||||||
|
onCityClick ? "clickable" : "",
|
||||||
].filter(Boolean).join(" ")}
|
].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 */}
|
{/* Refresh progress bar */}
|
||||||
{isRefreshing && <div className="monitor-refresh-bar" />}
|
{isRefreshing && <div className="monitor-refresh-bar" />}
|
||||||
@@ -430,7 +415,7 @@ export default function MonitorPanel() {
|
|||||||
<span className={`monitor-temp-value${newHigh ? " new-high" : warm ? " warm" : ""}`}>
|
<span className={`monitor-temp-value${newHigh ? " new-high" : warm ? " warm" : ""}`}>
|
||||||
{cur.toFixed(1)}
|
{cur.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
<span className="monitor-temp-unit">°C</span>
|
<span className="monitor-temp-unit">{tempSymbol}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="monitor-temp-missing">--</span>
|
<span className="monitor-temp-missing">--</span>
|
||||||
@@ -443,7 +428,7 @@ export default function MonitorPanel() {
|
|||||||
<span className="monitor-stat-label">{t("High", "高温", lang)}</span>
|
<span className="monitor-stat-label">{t("High", "高温", lang)}</span>
|
||||||
{max != null ? (
|
{max != null ? (
|
||||||
<>
|
<>
|
||||||
<span className="monitor-high-value">{max.toFixed(1)}°C</span>
|
<span className="monitor-high-value">{max.toFixed(1)}{tempSymbol}</span>
|
||||||
{mtt && <span className="monitor-high-time">{mtt}</span>}
|
{mtt && <span className="monitor-high-time">{mtt}</span>}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -456,13 +441,15 @@ export default function MonitorPanel() {
|
|||||||
{t("Airport METAR", "机场报文", lang)}
|
{t("Airport METAR", "机场报文", lang)}
|
||||||
</span>
|
</span>
|
||||||
<span className={`monitor-obs-age ${freshness}`}>
|
<span className={`monitor-obs-age ${freshness}`}>
|
||||||
{age != null
|
{age != null ? (
|
||||||
? (isEn
|
age < 60
|
||||||
? `${age} min ago`
|
? (isEn ? `${age} min ago` : `${age} 分钟未更新`)
|
||||||
: age < 60
|
: (isEn
|
||||||
? `${age} 分钟未更新`
|
? `⚠ last ${obs || "?"}`
|
||||||
: `${Math.round(age / 60)} 小时未更新`)
|
: `⚠ 最后报文 ${obs || "--"}`)
|
||||||
: <span className="monitor-stat-missing">--</span>}
|
) : (
|
||||||
|
<span className="monitor-stat-missing">--</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user