feat: implement scan terminal mobile dashboard components and data management logic

This commit is contained in:
2569718930@qq.com
2026-05-17 15:32:47 +08:00
parent 04e0369255
commit 2d9aa576dd
11 changed files with 450 additions and 4 deletions
@@ -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;
@@ -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 (
<div className="scan-mobile-city-list-view">
<div className="scan-mobile-city-list-head">
<div>
<span>{isEn ? "Lightweight mobile entry" : "移动端轻量入口"}</span>
<strong>{isEn ? "Pick one city to inspect" : "选择一个城市进入决策卡"}</strong>
</div>
<p>
{isEn
? "Cached scan rows are used first. The map and heavier evidence load only when opened."
: "优先使用缓存扫描结果;地图和重证据只在打开后加载。"}
</p>
</div>
<div className="scan-mobile-city-list">
{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 (
<button
key={row.id || `${cityName}-${index}`}
type="button"
className="scan-mobile-city-row"
onClick={() => handleOpenDecisionRow(row)}
>
<span className="scan-mobile-city-rank">{String(index + 1).padStart(2, "0")}</span>
<span className="scan-mobile-city-main">
<b>{display}</b>
<small>{row.market_question || row.target_label || row.local_time || "--"}</small>
</span>
<span className="scan-mobile-city-metrics">
<b>
{currentTemp != null && Number.isFinite(Number(currentTemp))
? `${Number(currentTemp).toFixed(1)}${row.temp_symbol || "°C"}`
: "--"}
</b>
<small>
DEB{" "}
{deb != null && Number.isFinite(Number(deb))
? `${Number(deb).toFixed(1)}${row.temp_symbol || "°C"}`
: "--"}
</small>
</span>
</button>
);
})}
</div>
{!timeSortedRows.length ? (
<div className="scan-empty-state">
<div className="scan-empty-title">
{scanLoading
? isEn
? "Loading cached scan"
: "正在读取缓存扫描"
: isEn
? "No city rows yet"
: "暂无城市列表"}
</div>
<div className="scan-empty-copy">
{isEn
? "Use refresh only when you need a new server-side scan."
: "只有需要新的服务端扫描时再手动刷新。"}
</div>
</div>
) : null}
</div>
);
}
if (resolvedView === "map") {
return (
<div className="scan-map-view">
@@ -396,6 +471,7 @@ function ScanTerminalScreen() {
<div
className={clsx(
"scan-terminal",
resolvedView === "city-list" && "city-list-view-active",
resolvedView === "map" && "map-view-active",
resolvedView !== "map" && "focus-view-active",
resolvedView === "analysis" && "analysis-view-active",
@@ -432,6 +508,17 @@ function ScanTerminalScreen() {
<section className="scan-list-section">
<div className="scan-list-header">
<div className="scan-list-tabs" role="tablist" aria-label={isEn ? "Content view" : "内容视图"}>
<button
type="button"
role="tab"
aria-selected={resolvedView === "city-list"}
className={resolvedView === "city-list" ? "active" : ""}
onClick={() => {
setActiveView("city-list");
}}
>
{isEn ? "City List" : "城市列表"}
</button>
<button
type="button"
role="tab"
@@ -6,6 +6,100 @@
padding-bottom: 14px;
}
.root :global(.scan-mobile-city-list-view) {
display: grid;
gap: 14px;
padding: 16px;
}
.root :global(.scan-mobile-city-list-head) {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
border: 1px solid rgba(77, 163, 255, 0.16);
border-radius: 20px;
background: rgba(5, 12, 24, 0.72);
padding: 16px;
}
.root :global(.scan-mobile-city-list-head span) {
display: block;
color: var(--color-accent-primary);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.root :global(.scan-mobile-city-list-head strong) {
display: block;
margin-top: 5px;
color: var(--color-text-primary);
font-size: 17px;
}
.root :global(.scan-mobile-city-list-head p) {
max-width: 360px;
margin: 0;
color: var(--color-text-secondary);
font-size: 12px;
line-height: 1.55;
}
.root :global(.scan-mobile-city-list) {
display: grid;
gap: 10px;
}
.root :global(.scan-mobile-city-row) {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
width: 100%;
border: 1px solid rgba(118, 150, 192, 0.16);
border-radius: 18px;
background: rgba(8, 18, 35, 0.76);
color: inherit;
padding: 13px 14px;
text-align: left;
}
.root :global(.scan-mobile-city-rank) {
display: inline-grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 12px;
background: rgba(77, 163, 255, 0.12);
color: var(--color-accent-primary);
font-size: 12px;
font-weight: 800;
}
.root :global(.scan-mobile-city-main),
.root :global(.scan-mobile-city-metrics) {
display: grid;
gap: 4px;
}
.root :global(.scan-mobile-city-main b),
.root :global(.scan-mobile-city-metrics b) {
color: var(--color-text-primary);
font-size: 14px;
}
.root :global(.scan-mobile-city-main small),
.root :global(.scan-mobile-city-metrics small) {
color: var(--color-text-secondary);
font-size: 11px;
}
.root :global(.scan-mobile-city-metrics) {
justify-items: end;
}
.root :global(.scan-mobile-decision-head) {
display: flex;
align-items: flex-start;
@@ -213,6 +307,7 @@
}
.root :global(.scan-ai-workspace-head),
.root :global(.scan-mobile-city-list-head),
.root :global(.scan-opportunity-hero),
.root :global(.scan-ai-city-hero),
.root :global(.scan-ai-city-section-head) {
@@ -225,6 +320,7 @@
}
.root :global(.scan-ai-workspace-head p),
.root :global(.scan-mobile-city-list-head p),
.root :global(.scan-ai-city-hero-side) {
text-align: left;
justify-items: start;
@@ -5,6 +5,7 @@ import type { MouseEvent } from "react";
import { useEffect, 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 { CityCardHeader } from "@/components/dashboard/scan-terminal/CityCardHeader";
import { MobileDecisionCard } from "@/components/dashboard/scan-terminal/MobileDecisionCard";
import { ModelEvidencePanel } from "@/components/dashboard/scan-terminal/ModelEvidencePanel";
@@ -654,6 +655,7 @@ export function AiPinnedCityCard({
/>
</div>
<AirportEvidencePanel detail={detail} isEn={isEn} />
<ModelEvidencePanel detail={detail} isEn={isEn} />
</div>
) : !detail ? (
@@ -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<string, Array<[string, string]>> = {
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 (
<section className="scan-ai-city-section scan-airport-evidence">
<div className="scan-ai-city-section-head">
<div>
<span className="scan-ai-city-kicker">
{isEn ? "Airport live evidence" : "机场实时证据"}
</span>
<h4>{isEn ? "Airport / focused runway" : "机场主站 / 重点跑道"}</h4>
</div>
</div>
<div className="scan-airport-evidence-grid">
{station ? (
<div className="scan-airport-evidence-card">
<span>{isEn ? "Airport station" : "机场主站"}</span>
<b>
{station.temp != null && Number.isFinite(Number(station.temp))
? formatTemperatureValue(Number(station.temp), tempSymbol, { digits: 1 })
: "--"}
</b>
<small>
{[station.station_label || station.station_code, station.source_label || "METAR", formatObsTime(station.obs_time || station.report_time)]
.filter(Boolean)
.join(" · ")}
</small>
</div>
) : null}
{runwayEvidence?.rows.map((row) => (
<div className="scan-airport-evidence-card runway" key={row.label}>
<span>{isEn ? "Focused runway" : "重点跑道"}</span>
<b>{formatTemperatureValue(row.maxTemp, tempSymbol, { digits: 1 })}</b>
<small>
{[row.label, runwayEvidence.sourceLabel, runwayEvidence.observedAt]
.filter(Boolean)
.join(" · ")}
</small>
</div>
))}
</div>
</section>
);
}
@@ -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}
/>
<details
className="scan-ai-city-section scan-mobile-fold"
open={airportOpen}
onToggle={(event) => setAirportOpen(event.currentTarget.open)}
>
<summary className="scan-ai-city-section-title">
{isEn ? "Airport live evidence" : "机场实时证据"}
</summary>
{airportOpen ? <AirportEvidencePanel detail={detail} isEn={isEn} /> : null}
</details>
<details
className="scan-ai-city-section scan-mobile-fold"
open={modelOpen}
@@ -14,7 +14,7 @@ import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
import type { Locale } from "@/lib/i18n";
export type ScanTerminalContentView = "analysis" | "map";
export type ScanTerminalContentView = "city-list" | "analysis" | "map";
type ThemeMode = "dark" | "light";
@@ -42,6 +42,12 @@ export function runTests() {
!shellPartsSource.includes('"monitor"') && !shellPartsSource.includes('"runway"'),
"scan terminal content views must not include market monitor or runway tabs",
);
assert(
shellPartsSource.includes('"city-list"') &&
dashboardSource.includes("scan-mobile-city-list-view") &&
dashboardSource.includes('setActiveView("city-list")'),
"mobile web should expose the lightweight city-list entry view",
);
assert(
!dashboardSource.includes('setActiveView("monitor")') &&
!dashboardSource.includes('setActiveView("runway")') &&
@@ -0,0 +1,52 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const queryPath = path.join(
projectRoot,
"components",
"dashboard",
"scan-terminal",
"use-scan-terminal-query.ts",
);
const dashboardPath = path.join(
projectRoot,
"components",
"dashboard",
"ScanTerminalDashboard.tsx",
);
const airportEvidencePath = path.join(
projectRoot,
"components",
"dashboard",
"scan-terminal",
"AirportEvidencePanel.tsx",
);
const querySource = fs.readFileSync(queryPath, "utf8");
const dashboardSource = fs.readFileSync(dashboardPath, "utf8");
const airportEvidenceSource = fs.readFileSync(airportEvidencePath, "utf8");
assert(
querySource.includes("void fetchScanTerminal({ forceRefresh: false, showLoading: false })"),
"web auto refresh must read cached scan data instead of forcing a full server scan",
);
assert(
dashboardSource.includes("scan-mobile-city-list-view") &&
dashboardSource.includes("MapCanvas") &&
dashboardSource.indexOf("scan-mobile-city-list-view") < dashboardSource.indexOf("scan-map-view"),
"mobile city list should be the lightweight entry before the optional map view",
);
assert(
airportEvidenceSource.includes("FOCUS_RUNWAY_PAIRS") &&
airportEvidenceSource.includes("chongqing") &&
airportEvidenceSource.includes("seoul") &&
!airportEvidenceSource.includes("busan:"),
"airport evidence must only expose configured focused runways, not all runway observations",
);
}
@@ -84,7 +84,7 @@ export function useScanTerminalQuery({
) {
return;
}
void fetchScanTerminal({ forceRefresh: true, showLoading: false });
void fetchScanTerminal({ forceRefresh: false, showLoading: false });
}, scanTerminalQueryPolicy.autoRefreshMs);
return () => window.clearInterval(intervalId);
}, [fetchScanTerminal, isLoading, isPro, proAccessLoading]);
+7 -1
View File
@@ -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] = []