From 2d9aa576dd2e850d2224297938605fb02c13e34f Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sun, 17 May 2026 15:32:47 +0800 Subject: [PATCH] feat: implement scan terminal mobile dashboard components and data management logic --- .../dashboard/ScanTerminalCard.module.css | 38 +++++ .../dashboard/ScanTerminalDashboard.tsx | 89 ++++++++++- .../dashboard/ScanTerminalMobile.module.css | 96 ++++++++++++ .../scan-terminal/AiPinnedCityCard.tsx | 2 + .../scan-terminal/AirportEvidencePanel.tsx | 146 ++++++++++++++++++ .../scan-terminal/MobileDecisionCard.tsx | 13 ++ .../scan-terminal/ScanTerminalShellParts.tsx | 2 +- .../removedMonitorRunwayTabs.test.ts | 6 + .../stableServerRefreshPolicy.test.ts | 52 +++++++ .../scan-terminal/use-scan-terminal-query.ts | 2 +- web/scan_terminal_service.py | 8 +- 11 files changed, 450 insertions(+), 4 deletions(-) create mode 100644 frontend/components/dashboard/scan-terminal/AirportEvidencePanel.tsx create mode 100644 frontend/components/dashboard/scan-terminal/__tests__/stableServerRefreshPolicy.test.ts diff --git a/frontend/components/dashboard/ScanTerminalCard.module.css b/frontend/components/dashboard/ScanTerminalCard.module.css index 155c851a..d6ddfac2 100644 --- a/frontend/components/dashboard/ScanTerminalCard.module.css +++ b/frontend/components/dashboard/ScanTerminalCard.module.css @@ -865,6 +865,44 @@ padding: 16px; } +.root :global(.scan-airport-evidence-grid) { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; + margin-top: 12px; +} + +.root :global(.scan-airport-evidence-card) { + display: grid; + gap: 5px; + border: 1px solid rgba(77, 163, 255, 0.16); + border-radius: 14px; + background: rgba(77, 163, 255, 0.08); + padding: 12px; +} + +.root :global(.scan-airport-evidence-card.runway) { + border-color: rgba(255, 184, 77, 0.24); + background: rgba(255, 184, 77, 0.08); +} + +.root :global(.scan-airport-evidence-card span) { + color: var(--color-text-secondary); + font-size: 11px; + font-weight: 700; +} + +.root :global(.scan-airport-evidence-card b) { + color: var(--color-text-primary); + font-size: 20px; +} + +.root :global(.scan-airport-evidence-card small) { + color: var(--color-text-muted); + font-size: 11px; + line-height: 1.4; +} + .root :global(.scan-ai-city-section-title) { display: inline-flex; align-items: center; diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 6051f9bf..d2cd3743 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -98,7 +98,12 @@ function ScanTerminalScreen() { useEffect(() => { if (typeof window === "undefined" || !window.matchMedia) return; const media = window.matchMedia("(max-width: 768px)"); - const syncMobileViewport = () => setIsMobileViewport(media.matches); + const syncMobileViewport = () => { + setIsMobileViewport(media.matches); + if (media.matches) { + setActiveView((current) => (current === "map" ? "city-list" : current)); + } + }; syncMobileViewport(); media.addEventListener("change", syncMobileViewport); return () => media.removeEventListener("change", syncMobileViewport); @@ -336,6 +341,76 @@ function ScanTerminalScreen() { }, []); const renderMainView = () => { + if (resolvedView === "city-list") { + return ( +
+
+
+ {isEn ? "Lightweight mobile entry" : "移动端轻量入口"} + {isEn ? "Pick one city to inspect" : "选择一个城市进入决策卡"} +
+

+ {isEn + ? "Cached scan rows are used first. The map and heavier evidence load only when opened." + : "优先使用缓存扫描结果;地图和重证据只在打开后加载。"} +

+
+
+ {timeSortedRows.slice(0, 24).map((row, index) => { + const cityName = row.city || row.city_display_name || row.display_name || ""; + const display = row.city_display_name || row.display_name || cityName; + const currentTemp = row.current_temp ?? row.current_max_so_far ?? null; + const deb = row.deb_prediction ?? null; + return ( + + ); + })} +
+ {!timeSortedRows.length ? ( +
+
+ {scanLoading + ? isEn + ? "Loading cached scan" + : "正在读取缓存扫描" + : isEn + ? "No city rows yet" + : "暂无城市列表"} +
+
+ {isEn + ? "Use refresh only when you need a new server-side scan." + : "只有需要新的服务端扫描时再手动刷新。"} +
+
+ ) : null} +
+ ); + } if (resolvedView === "map") { return (
@@ -396,6 +471,7 @@ function ScanTerminalScreen() {
+
+
) : !detail ? ( diff --git a/frontend/components/dashboard/scan-terminal/AirportEvidencePanel.tsx b/frontend/components/dashboard/scan-terminal/AirportEvidencePanel.tsx new file mode 100644 index 00000000..c519d053 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/AirportEvidencePanel.tsx @@ -0,0 +1,146 @@ +"use client"; + +import type { CityDetail } from "@/lib/dashboard-types"; +import { getDisplayAirportPrimary } from "@/lib/airport-observation-display"; +import { formatTemperatureValue } from "@/lib/temperature-utils"; + +const FOCUS_RUNWAY_PAIRS: Record> = { + chongqing: [["02L", "20R"]], + shanghai: [["17L", "35R"]], + wuhan: [["04", "22"]], + beijing: [["01", "19"]], + guangzhou: [["02L", "20R"]], + chengdu: [["02L", "20R"]], + seoul: [["15R", "33L"]], +}; + +function normalizeRunwayLabel(value?: string | null) { + return String(value || "").trim().toUpperCase().replace(/\s+/g, ""); +} + +function normalizeCityKey(value?: string | null) { + return String(value || "").trim().toLowerCase().replace(/[\s_-]+/g, ""); +} + +function pairKey(pair: [string, string]) { + return pair.map(normalizeRunwayLabel).sort().join("/"); +} + +function toFiniteNumber(value: unknown) { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : null; +} + +function formatObsTime(value: unknown) { + const raw = String(value || "").trim(); + if (!raw) return ""; + if (raw.includes("T")) { + const parsed = new Date(raw); + if (!Number.isNaN(parsed.getTime())) { + return `${String(parsed.getUTCHours()).padStart(2, "0")}:${String( + parsed.getUTCMinutes(), + ).padStart(2, "0")}Z`; + } + } + return raw.length >= 16 && raw[10] === " " ? raw.slice(11, 16) : raw; +} + +function buildFocusedRunwayEvidence(detail: CityDetail | null) { + if (!detail) return null; + const cityKey = normalizeCityKey(detail.name) || normalizeCityKey(detail.display_name); + const focusPairs = FOCUS_RUNWAY_PAIRS[cityKey]; + if (!focusPairs?.length) return null; + const focusKeys = new Set(focusPairs.map(pairKey)); + const runwayObs = detail.amos?.runway_obs || {}; + const runwayPairs = runwayObs.runway_pairs || []; + const runwayTemps = runwayObs.temperatures || []; + const pointTemps = runwayObs.point_temperatures || []; + const rows: Array<{ + label: string; + maxTemp: number; + values: number[]; + }> = []; + + runwayPairs.forEach((rawPair, index) => { + const pair = rawPair as [string, string]; + if (!Array.isArray(pair) || pair.length < 2) return; + if (!focusKeys.has(pairKey(pair))) return; + const values = [ + ...(Array.isArray(runwayTemps[index]) ? runwayTemps[index] : []), + toFiniteNumber(pointTemps[index]?.tdz_temp), + toFiniteNumber(pointTemps[index]?.mid_temp), + toFiniteNumber(pointTemps[index]?.end_temp), + ].filter((value): value is number => Number.isFinite(value)); + if (!values.length) return; + rows.push({ + label: `${normalizeRunwayLabel(pair[0])}/${normalizeRunwayLabel(pair[1])}`, + maxTemp: Math.max(...values), + values, + }); + }); + + if (!rows.length) return null; + return { + observedAt: + formatObsTime(detail.amos?.observation_time_local) || + formatObsTime(detail.amos?.observation_time), + rows, + sourceLabel: detail.amos?.source_label || detail.amos?.source || "AMOS", + }; +} + +export function AirportEvidencePanel({ + detail, + isEn, +}: { + detail: CityDetail | null; + isEn: boolean; +}) { + const airportPrimary = getDisplayAirportPrimary(detail); + const airportCurrent = detail?.airport_current; + const station = airportPrimary || airportCurrent || null; + const runwayEvidence = buildFocusedRunwayEvidence(detail); + const tempSymbol = detail?.temp_symbol || "°C"; + if (!station && !runwayEvidence) return null; + + return ( +
+
+
+ + {isEn ? "Airport live evidence" : "机场实时证据"} + +

{isEn ? "Airport / focused runway" : "机场主站 / 重点跑道"}

+
+
+
+ {station ? ( +
+ {isEn ? "Airport station" : "机场主站"} + + {station.temp != null && Number.isFinite(Number(station.temp)) + ? formatTemperatureValue(Number(station.temp), tempSymbol, { digits: 1 }) + : "--"} + + + {[station.station_label || station.station_code, station.source_label || "METAR", formatObsTime(station.obs_time || station.report_time)] + .filter(Boolean) + .join(" · ")} + +
+ ) : null} + {runwayEvidence?.rows.map((row) => ( +
+ {isEn ? "Focused runway" : "重点跑道"} + {formatTemperatureValue(row.maxTemp, tempSymbol, { digits: 1 })} + + {[row.label, runwayEvidence.sourceLabel, runwayEvidence.observedAt] + .filter(Boolean) + .join(" · ")} + +
+ ))} +
+
+ ); +} diff --git a/frontend/components/dashboard/scan-terminal/MobileDecisionCard.tsx b/frontend/components/dashboard/scan-terminal/MobileDecisionCard.tsx index d73ea2ef..44afa7bc 100644 --- a/frontend/components/dashboard/scan-terminal/MobileDecisionCard.tsx +++ b/frontend/components/dashboard/scan-terminal/MobileDecisionCard.tsx @@ -5,6 +5,7 @@ import type { MouseEvent } from "react"; import { useState } from "react"; import { AiCityTemperatureChart } from "@/components/dashboard/scan-terminal/AiCityTemperatureChart"; import { AiEvidencePanel } from "@/components/dashboard/scan-terminal/AiEvidencePanel"; +import { AirportEvidencePanel } from "@/components/dashboard/scan-terminal/AirportEvidencePanel"; import { CityStatusTags, type CityStatusTag, @@ -102,6 +103,7 @@ export function MobileDecisionCard({ const loadingCopy = getCityLoadingCopy({ isEn, isHkoObservation }); const [modelOpen, setModelOpen] = useState(false); const [chartOpen, setChartOpen] = useState(false); + const [airportOpen, setAirportOpen] = useState(false); const statusTags: CityStatusTag[] = decisionState.badges.length ? decisionState.badges : [{ label: decisionState.aiStatusLabel, tone: decisionState.aiStatusTone as StatusTone }]; @@ -201,6 +203,17 @@ export function MobileDecisionCard({ tempSymbol={tempSymbol} /> +
setAirportOpen(event.currentTarget.open)} + > + + {isEn ? "Airport live evidence" : "机场实时证据"} + + {airportOpen ? : null} +
+
window.clearInterval(intervalId); }, [fetchScanTerminal, isLoading, isPro, proAccessLoading]); diff --git a/web/scan_terminal_service.py b/web/scan_terminal_service.py index d02b8895..11f1f5e7 100644 --- a/web/scan_terminal_service.py +++ b/web/scan_terminal_service.py @@ -96,6 +96,12 @@ SCAN_TERMINAL_BUILD_TIMEOUT_SEC = max( 8, int(os.getenv("POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC", "22")), ) +SCAN_TERMINAL_MAX_WORKERS = _env_int( + "POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS", + 2, + min_value=1, + max_value=4, +) DEFAULT_SCAN_AI_MODEL = "mimo-v2.5-pro" DEFAULT_SCAN_AI_BASE_URL = "https://token-plan-cn.xiaomimimo.com/v1" SCAN_AI_API_KEY_ENV_HINT = ( @@ -1126,7 +1132,7 @@ def _build_scan_terminal_payload_uncached( try: city_names = list(CITIES.keys()) - max_workers = max(1, min(4, len(city_names))) + max_workers = max(1, min(SCAN_TERMINAL_MAX_WORKERS, len(city_names))) city_results: List[Dict[str, Any]] = [] failed_cities: List[str] = [] failed_reasons: List[str] = []