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] = []