From 5470da6b3a9947cbf803b654403758fc11ed3ae2 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sat, 30 May 2026 16:39:23 +0800 Subject: [PATCH] feat: implement CitySelectorDropdown for ScanTerminalDashboard with multi-filter search and regional categorization --- .../dashboard/ScanTerminalDashboard.tsx | 2 +- .../scan-terminal/CitySelectorDropdown.tsx | 38 ++++++++++++++++++- .../__tests__/ssePatchArchitecture.test.ts | 7 ++++ .../__tests__/terminalGridPolicy.test.ts | 20 ++++++++++ .../scan-terminal/temperature-chart-logic.ts | 19 ++++++++-- 5 files changed, 79 insertions(+), 7 deletions(-) diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 4c656a3e..0d8e14be 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -860,7 +860,7 @@ function PolyWeatherTerminal({ setActiveSearchSlotIndex(null); }} onClose={() => setActiveSearchSlotIndex(null)} - className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-[380px] bg-white border border-slate-200 rounded shadow-lg p-2" + className="absolute left-1/2 top-12 z-50 w-[380px] -translate-x-1/2 bg-white border border-slate-200 rounded shadow-lg p-2" /> )} diff --git a/frontend/components/dashboard/scan-terminal/CitySelectorDropdown.tsx b/frontend/components/dashboard/scan-terminal/CitySelectorDropdown.tsx index 1af2e481..44516c63 100644 --- a/frontend/components/dashboard/scan-terminal/CitySelectorDropdown.tsx +++ b/frontend/components/dashboard/scan-terminal/CitySelectorDropdown.tsx @@ -113,6 +113,9 @@ const getCityCode = (city: string): string => { return CITY_IATA_MAP[normalized] || normalized.substring(0, 3).toUpperCase(); }; +const MIN_DROPDOWN_TOP_PX = 56; +const DROPDOWN_VIEWPORT_PADDING_PX = 12; + export function CitySelectorDropdown({ isEn, rows, @@ -124,6 +127,7 @@ export function CitySelectorDropdown({ const inputRef = useRef(null); const [searchQuery, setSearchQuery] = useState(""); const [activeTab, setActiveTab] = useState("all"); + const [viewportNudgeY, setViewportNudgeY] = useState(0); // Auto-focus input on mount useEffect(() => { @@ -193,6 +197,35 @@ export function CitySelectorDropdown({ }); }, [rows, searchQuery, activeTab]); + useEffect(() => { + let frame = 0; + const updatePosition = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + const el = containerRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + let next = 0; + if (rect.top < MIN_DROPDOWN_TOP_PX) { + next = MIN_DROPDOWN_TOP_PX - rect.top; + } else if (rect.bottom > window.innerHeight - DROPDOWN_VIEWPORT_PADDING_PX) { + next = Math.max( + MIN_DROPDOWN_TOP_PX - rect.top, + window.innerHeight - DROPDOWN_VIEWPORT_PADDING_PX - rect.bottom, + ); + } + setViewportNudgeY((prev) => (Math.abs(prev - next) < 1 ? prev : next)); + }); + }; + + updatePosition(); + window.addEventListener("resize", updatePosition); + return () => { + cancelAnimationFrame(frame); + window.removeEventListener("resize", updatePosition); + }; + }, [filteredRows.length]); + const getRegionLabel = (regionKey: string): string => { const match = REGIONS.find((r) => r.key === regionKey); if (!match) return regionKey; @@ -203,9 +236,10 @@ export function CitySelectorDropdown({
e.stopPropagation()} // Prevent triggering slot clicks > {/* Search Input Area */} @@ -243,7 +277,7 @@ export function CitySelectorDropdown({
{/* Results List */} -
+
{filteredRows.length === 0 ? (
{isEn ? "No matching cities" : "无匹配城市"} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts index 4cb70d25..2bac47fe 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts @@ -233,6 +233,13 @@ export function runTests() { chartCanvas.includes("canToggleRunwayDetails") && chartCanvas.includes("individualRunwaySeriesCount > 1"), "single-runway charts must not show the runway-detail toggle because aggregate and individual views are visually redundant", ); + assert( + chartLogic.includes("HOURLY_DETAIL_REQUEST_TIMEOUT_MS = 12_000") && + chartLogic.includes("fetchCityDetailWithTimeout") && + chartLogic.includes("signal: controller.signal") && + chartLogic.includes("controller.abort()"), + "city detail chart fetches must have a frontend timeout so panels cannot stay on 加载图表 forever", + ); assert(!chart.includes("3D"), "temperature chart UI must not expose a 3D/future-forecast mode"); assert(!chart.includes("build3DayChartData"), "temperature chart component must not render future prediction curves"); assert( diff --git a/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts index da5afed4..b503971d 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts @@ -23,6 +23,10 @@ export function runTests() { path.join(projectRoot, "components", "dashboard", "scan-terminal", "TemperatureChartCanvas.tsx"), "utf8", ); + const citySelectorSource = fs.readFileSync( + path.join(projectRoot, "components", "dashboard", "scan-terminal", "CitySelectorDropdown.tsx"), + "utf8", + ); assert( dashboardSource.includes("MAX_TERMINAL_CHARTS = 9"), @@ -54,6 +58,22 @@ export function runTests() { dashboardSource.includes("handleSelectCityForSlot(slotIndex, null);"), "stale saved chart slots must render the empty city picker instead of a row=null Temperature Chart", ); + assert( + dashboardSource.includes("absolute left-1/2 top-12 z-50") && + !dashboardSource.includes("top-1/2 -translate-x-1/2 -translate-y-1/2"), + "empty top-row slots must open the city selector downward so the search input is not clipped by the viewport header", + ); + assert( + citySelectorSource.includes("max-h-[calc(100vh-72px)]") && + citySelectorSource.includes("min-h-0 flex-1 overflow-y-auto"), + "city selector dropdown must cap its viewport height and keep the result list scrollable", + ); + assert( + citySelectorSource.includes("MIN_DROPDOWN_TOP_PX") && + citySelectorSource.includes("viewportNudgeY") && + citySelectorSource.includes("getBoundingClientRect()"), + "city selector dropdown must nudge itself inside the viewport when opened from top-row chart cards", + ); assert( chartSource.includes("setLiveTemp(null);") && chartSource.includes("lastAppliedPatchRevisionRef.current = 0;"), diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts index ba5d5b32..f72afb5b 100644 --- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts +++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts @@ -356,6 +356,7 @@ const HOURLY_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar; const _hourlyCache = new Map(); const _hourlyRequestCache = new Map>(); const MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS = 3; +const HOURLY_DETAIL_REQUEST_TIMEOUT_MS = 12_000; let _hourlyActiveDetailRequests = 0; const _hourlyDetailRequestQueue: Array<() => void> = []; const RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c", "#64748b"]; @@ -985,11 +986,9 @@ async function fetchHourlyForecastForCity( if (pending) return pending; const request = runQueuedHourlyDetailRequest(() => - fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false&resolution=${resParam}`, { - headers: { Accept: "application/json" }, - }) + fetchCityDetailWithTimeout(city, resParam) .then(async (res) => { - if (!res.ok) return null; + if (!res || !res.ok) return null; return res.json() as Promise; }) .then((json) => { @@ -1008,6 +1007,17 @@ async function fetchHourlyForecastForCity( return request; } +function fetchCityDetailWithTimeout(city: string, resolution: string) { + const controller = new AbortController(); + const timeoutId = globalThis.setTimeout(() => controller.abort(), HOURLY_DETAIL_REQUEST_TIMEOUT_MS); + return fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false&resolution=${resolution}`, { + headers: { Accept: "application/json" }, + signal: controller.signal, + }) + .catch(() => null) + .finally(() => globalThis.clearTimeout(timeoutId)); +} + function shouldPollLiveChart({ city, compact, @@ -2246,6 +2256,7 @@ function binObservationsToSlots( export { MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS, + HOURLY_DETAIL_REQUEST_TIMEOUT_MS, HOURLY_CACHE_TTL_MS, _hourlyCache, __resetHourlyDetailRequestQueueForTest,