feat: implement CitySelectorDropdown for ScanTerminalDashboard with multi-filter search and regional categorization

This commit is contained in:
2569718930@qq.com
2026-05-30 16:39:23 +08:00
parent a03b8095ee
commit 5470da6b3a
5 changed files with 79 additions and 7 deletions
@@ -860,7 +860,7 @@ function PolyWeatherTerminal({
setActiveSearchSlotIndex(null); setActiveSearchSlotIndex(null);
}} }}
onClose={() => 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"
/> />
)} )}
</div> </div>
@@ -113,6 +113,9 @@ const getCityCode = (city: string): string => {
return CITY_IATA_MAP[normalized] || normalized.substring(0, 3).toUpperCase(); 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({ export function CitySelectorDropdown({
isEn, isEn,
rows, rows,
@@ -124,6 +127,7 @@ export function CitySelectorDropdown({
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [activeTab, setActiveTab] = useState<string>("all"); const [activeTab, setActiveTab] = useState<string>("all");
const [viewportNudgeY, setViewportNudgeY] = useState(0);
// Auto-focus input on mount // Auto-focus input on mount
useEffect(() => { useEffect(() => {
@@ -193,6 +197,35 @@ export function CitySelectorDropdown({
}); });
}, [rows, searchQuery, activeTab]); }, [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 getRegionLabel = (regionKey: string): string => {
const match = REGIONS.find((r) => r.key === regionKey); const match = REGIONS.find((r) => r.key === regionKey);
if (!match) return regionKey; if (!match) return regionKey;
@@ -203,9 +236,10 @@ export function CitySelectorDropdown({
<div <div
ref={containerRef} ref={containerRef}
className={clsx( className={clsx(
"flex flex-col bg-white border border-slate-300 rounded shadow-2xl overflow-hidden text-xs text-[#202833] animate-in fade-in-50 zoom-in-95 duration-100", "flex max-h-[calc(100vh-72px)] min-h-0 flex-col bg-white border border-slate-300 rounded shadow-2xl overflow-hidden text-xs text-[#202833] animate-in fade-in-50 zoom-in-95 duration-100",
className className
)} )}
style={viewportNudgeY ? { marginTop: viewportNudgeY } : undefined}
onClick={(e) => e.stopPropagation()} // Prevent triggering slot clicks onClick={(e) => e.stopPropagation()} // Prevent triggering slot clicks
> >
{/* Search Input Area */} {/* Search Input Area */}
@@ -243,7 +277,7 @@ export function CitySelectorDropdown({
</div> </div>
{/* Results List */} {/* Results List */}
<div className="flex-1 overflow-y-auto max-h-[380px] divide-y divide-slate-100"> <div className="min-h-0 flex-1 overflow-y-auto max-h-[380px] divide-y divide-slate-100">
{filteredRows.length === 0 ? ( {filteredRows.length === 0 ? (
<div className="p-4 text-center text-slate-400 font-medium"> <div className="p-4 text-center text-slate-400 font-medium">
{isEn ? "No matching cities" : "无匹配城市"} {isEn ? "No matching cities" : "无匹配城市"}
@@ -233,6 +233,13 @@ export function runTests() {
chartCanvas.includes("canToggleRunwayDetails") && chartCanvas.includes("individualRunwaySeriesCount > 1"), 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", "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("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(!chart.includes("build3DayChartData"), "temperature chart component must not render future prediction curves");
assert( assert(
@@ -23,6 +23,10 @@ export function runTests() {
path.join(projectRoot, "components", "dashboard", "scan-terminal", "TemperatureChartCanvas.tsx"), path.join(projectRoot, "components", "dashboard", "scan-terminal", "TemperatureChartCanvas.tsx"),
"utf8", "utf8",
); );
const citySelectorSource = fs.readFileSync(
path.join(projectRoot, "components", "dashboard", "scan-terminal", "CitySelectorDropdown.tsx"),
"utf8",
);
assert( assert(
dashboardSource.includes("MAX_TERMINAL_CHARTS = 9"), dashboardSource.includes("MAX_TERMINAL_CHARTS = 9"),
@@ -54,6 +58,22 @@ export function runTests() {
dashboardSource.includes("handleSelectCityForSlot(slotIndex, null);"), dashboardSource.includes("handleSelectCityForSlot(slotIndex, null);"),
"stale saved chart slots must render the empty city picker instead of a row=null Temperature Chart", "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( assert(
chartSource.includes("setLiveTemp(null);") && chartSource.includes("setLiveTemp(null);") &&
chartSource.includes("lastAppliedPatchRevisionRef.current = 0;"), chartSource.includes("lastAppliedPatchRevisionRef.current = 0;"),
@@ -356,6 +356,7 @@ const HOURLY_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar;
const _hourlyCache = new Map<string, { ts: number; data: HourlyForecast }>(); const _hourlyCache = new Map<string, { ts: number; data: HourlyForecast }>();
const _hourlyRequestCache = new Map<string, Promise<HourlyForecast>>(); const _hourlyRequestCache = new Map<string, Promise<HourlyForecast>>();
const MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS = 3; const MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS = 3;
const HOURLY_DETAIL_REQUEST_TIMEOUT_MS = 12_000;
let _hourlyActiveDetailRequests = 0; let _hourlyActiveDetailRequests = 0;
const _hourlyDetailRequestQueue: Array<() => void> = []; const _hourlyDetailRequestQueue: Array<() => void> = [];
const RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c", "#64748b"]; const RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c", "#64748b"];
@@ -985,11 +986,9 @@ async function fetchHourlyForecastForCity(
if (pending) return pending; if (pending) return pending;
const request = runQueuedHourlyDetailRequest(() => const request = runQueuedHourlyDetailRequest(() =>
fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false&resolution=${resParam}`, { fetchCityDetailWithTimeout(city, resParam)
headers: { Accept: "application/json" },
})
.then(async (res) => { .then(async (res) => {
if (!res.ok) return null; if (!res || !res.ok) return null;
return res.json() as Promise<CityDetail>; return res.json() as Promise<CityDetail>;
}) })
.then((json) => { .then((json) => {
@@ -1008,6 +1007,17 @@ async function fetchHourlyForecastForCity(
return request; 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({ function shouldPollLiveChart({
city, city,
compact, compact,
@@ -2246,6 +2256,7 @@ function binObservationsToSlots(
export { export {
MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS, MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS,
HOURLY_DETAIL_REQUEST_TIMEOUT_MS,
HOURLY_CACHE_TTL_MS, HOURLY_CACHE_TTL_MS,
_hourlyCache, _hourlyCache,
__resetHourlyDetailRequestQueueForTest, __resetHourlyDetailRequestQueueForTest,