diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 37124103..7b84685d 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -144,6 +144,36 @@ const TERM = { closed: { en: "Closed", zh: "已关闭" }, } as const; +const MAX_TERMINAL_GRID_SIDE = 3; +const MAX_TERMINAL_CHARTS = 9; +const MOBILE_TERMINAL_CHARTS = 1; +const DEFAULT_TERMINAL_GRID_SIDE = 2; + +function clampGridSide(value: number) { + if (!Number.isFinite(value)) return DEFAULT_TERMINAL_GRID_SIDE; + return Math.max(1, Math.min(MAX_TERMINAL_GRID_SIDE, Math.floor(value))); +} + +function getStoredGridSide(key: string) { + if (typeof window === "undefined") return DEFAULT_TERMINAL_GRID_SIDE; + try { + const raw = localStorage.getItem(key); + if (!raw) return DEFAULT_TERMINAL_GRID_SIDE; + return clampGridSide(parseInt(raw, 10)); + } catch {} + return DEFAULT_TERMINAL_GRID_SIDE; +} + +function getSlotCount(cols: number, rows: number) { + return Math.min(MAX_TERMINAL_CHARTS, clampGridSide(cols) * clampGridSide(rows)); +} + +function normalizeSlotList(slots: Array, totalSlots: number) { + if (slots.length === totalSlots) return slots; + if (slots.length > totalSlots) return slots.slice(0, totalSlots); + return [...slots, ...Array(totalSlots - slots.length).fill(null)]; +} + function t(key: keyof typeof TERM, isEn: boolean) { return isEn ? TERM[key].en : TERM[key].zh; } @@ -359,102 +389,51 @@ function PolyWeatherTerminal({ const [activeNavKey, setActiveNavKey] = useState("thresholds"); const [gridCols, setGridCols] = useState(() => { - if (typeof window !== "undefined") { - try { - const val = localStorage.getItem("polyweather_terminal_grid_cols"); - if (val) { - const num = parseInt(val, 10); - if (num >= 1 && num <= 3) return num; - } - } catch {} - } - return 2; + return getStoredGridSide("polyweather_terminal_grid_cols"); }); const [gridRows, setGridRows] = useState(() => { - if (typeof window !== "undefined") { - try { - const val = localStorage.getItem("polyweather_terminal_grid_rows"); - if (val) { - const num = parseInt(val, 10); - if (num >= 1 && num <= 3) return num; - } - } catch {} - } - return 2; + return getStoredGridSide("polyweather_terminal_grid_rows"); }); - const totalSlots = gridCols * gridRows; + const totalSlots = getSlotCount(gridCols, gridRows); const [slots, setSlots] = useState>(() => { + const storedCols = getStoredGridSide("polyweather_terminal_grid_cols"); + const storedRows = getStoredGridSide("polyweather_terminal_grid_rows"); + const initialSlotCount = getSlotCount(storedCols, storedRows); if (typeof window !== "undefined") { try { const stored = localStorage.getItem("polyweather_terminal_slots"); if (stored) { const parsed = JSON.parse(stored); if (Array.isArray(parsed)) { - let targetLen = 4; - try { - const cVal = localStorage.getItem("polyweather_terminal_grid_cols"); - const rVal = localStorage.getItem("polyweather_terminal_grid_rows"); - if (cVal && rVal) { - const c = parseInt(cVal, 10); - const r = parseInt(rVal, 10); - if (c >= 1 && c <= 3 && r >= 1 && r <= 3) { - targetLen = c * r; - } - } - } catch {} - - if (parsed.length === targetLen) { - return parsed; - } else if (parsed.length < targetLen) { - return [...parsed, ...Array(targetLen - parsed.length).fill(null)]; - } else { - return parsed.slice(0, targetLen); - } + return normalizeSlotList(parsed, initialSlotCount); } } } catch {} } - let defaultLen = 4; - if (typeof window !== "undefined") { - try { - const cVal = localStorage.getItem("polyweather_terminal_grid_cols"); - const rVal = localStorage.getItem("polyweather_terminal_grid_rows"); - if (cVal && rVal) { - const c = parseInt(cVal, 10); - const r = parseInt(rVal, 10); - if (c >= 1 && c <= 3 && r >= 1 && r <= 3) { - defaultLen = c * r; - } - } - } catch {} - } - return Array(defaultLen).fill(null); + return Array(initialSlotCount).fill(null); }); const [activeSlotIndex, setActiveSlotIndex] = useState(0); const [maximizedSlotIndex, setMaximizedSlotIndex] = useState(null); const [activeSearchSlotIndex, setActiveSearchSlotIndex] = useState(null); + const visibleSlots = useMemo(() => normalizeSlotList(slots, totalSlots), [slots, totalSlots]); const handleSetGridSize = (cols: number, rows: number) => { - const nextTotalSlots = cols * rows; + const safeCols = clampGridSide(cols); + const safeRows = clampGridSide(rows); + const nextTotalSlots = getSlotCount(safeCols, safeRows); - setGridCols(cols); - setGridRows(rows); + setGridCols(safeCols); + setGridRows(safeRows); try { - localStorage.setItem("polyweather_terminal_grid_cols", String(cols)); - localStorage.setItem("polyweather_terminal_grid_rows", String(rows)); + localStorage.setItem("polyweather_terminal_grid_cols", String(safeCols)); + localStorage.setItem("polyweather_terminal_grid_rows", String(safeRows)); } catch {} - let nextSlots = [...slots]; - if (nextSlots.length < nextTotalSlots) { - const diff = nextTotalSlots - nextSlots.length; - nextSlots = [...nextSlots, ...Array(diff).fill(null)]; - } else if (nextSlots.length > nextTotalSlots) { - nextSlots = nextSlots.slice(0, nextTotalSlots); - } + const nextSlots = normalizeSlotList(visibleSlots, nextTotalSlots); setSlots(nextSlots); try { @@ -489,7 +468,7 @@ function PolyWeatherTerminal({ }, [rows, selectedRegionKey]); useEffect(() => { - if (filteredRegionRows.length && slots.every((s) => s === null)) { + if (filteredRegionRows.length && visibleSlots.every((s) => s === null)) { const next = Array(totalSlots) .fill(null) .map((_, idx) => filteredRegionRows[idx]?.city || null); @@ -498,10 +477,11 @@ function PolyWeatherTerminal({ localStorage.setItem("polyweather_terminal_slots", JSON.stringify(next)); } catch {} } - }, [filteredRegionRows, slots, totalSlots]); + }, [filteredRegionRows, visibleSlots, totalSlots]); const handleSelectCityForSlot = (index: number, city: string | null) => { - const next = [...slots]; + if (index < 0 || index >= totalSlots) return; + const next = [...visibleSlots]; next[index] = city; setSlots(next); try { @@ -561,6 +541,14 @@ function PolyWeatherTerminal({ setMobileTab(continentGroups[0].key); } }, [continentGroups, mobileTab]); + const mobileChartRow = useMemo( + () => + selectedRow || + mobileActiveGroup?.rows.slice(0, MOBILE_TERMINAL_CHARTS)[0] || + filteredRegionRows[0] || + null, + [filteredRegionRows, mobileActiveGroup?.rows, selectedRow], + ); useEffect(() => { if (!filteredRegionRows.length) return; if (!selectedRow || !filteredRegionRows.some((row) => row.id === selectedRow.id)) { @@ -722,6 +710,22 @@ function PolyWeatherTerminal({ isEn={isEn} onSelectTab={setMobileTab} /> +
+ {isEn + ? "Mobile renders one chart. Rotate to landscape to inspect the full terminal grid." + : "手机端仅渲染 1 个图表。建议横屏查看完整终端网格。"} +
+ {mobileChartRow && ( +
+ +
+ )}
{mobileActiveGroup?.rows.map((row) => ( String(r.city || "").toLowerCase() === slots[maximizedSlotIndex]) || null} + row={filteredRegionRows.find((r) => String(r.city || "").toLowerCase() === visibleSlots[maximizedSlotIndex]) || null} allRows={filteredRegionRows} compact={false} onSearchClick={() => setActiveSearchSlotIndex(maximizedSlotIndex)} @@ -779,7 +783,7 @@ function PolyWeatherTerminal({ setMaximizedSlotIndex(null); }} isMaximized={true} - disableClose={slots.filter(Boolean).length <= 1} + disableClose={visibleSlots.filter(Boolean).length <= 1} /> {activeSearchSlotIndex === maximizedSlotIndex && ( @@ -804,9 +808,8 @@ function PolyWeatherTerminal({ gridTemplateRows: `repeat(${gridRows}, minmax(0, 1fr))`, }} > - {Array.from({ length: totalSlots }).map((_, slotIndex) => { + {visibleSlots.map((cityInSlot, slotIndex) => { const isSlotActive = activeSlotIndex === slotIndex; - const cityInSlot = slots[slotIndex]; if (!cityInSlot) { return ( @@ -865,7 +868,7 @@ function PolyWeatherTerminal({ handleSelectCityForSlot(slotIndex, null); }} isMaximized={false} - disableClose={slots.filter(Boolean).length <= 1} + disableClose={visibleSlots.filter(Boolean).length <= 1} /> {activeSearchSlotIndex === slotIndex && ( diff --git a/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts new file mode 100644 index 00000000..6ebf9cb8 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/__tests__/terminalGridPolicy.test.ts @@ -0,0 +1,44 @@ +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 dashboardSource = fs.readFileSync( + path.join(projectRoot, "components", "dashboard", "ScanTerminalDashboard.tsx"), + "utf8", + ); + const selectorSource = fs.readFileSync( + path.join(projectRoot, "components", "dashboard", "scan-terminal", "GridLayoutSelector.tsx"), + "utf8", + ); + + assert( + dashboardSource.includes("MAX_TERMINAL_CHARTS = 9"), + "terminal dashboard must cap the desktop homepage at 9 charts", + ); + assert( + dashboardSource.includes("MOBILE_TERMINAL_CHARTS = 1"), + "terminal dashboard must document that mobile renders exactly one chart", + ); + assert( + dashboardSource.includes("clampGridSide") && + dashboardSource.includes("Math.min(MAX_TERMINAL_CHARTS"), + "terminal grid dimensions must be clamped before rendering or persisting", + ); + assert( + dashboardSource.includes("mobileChartRow") && + dashboardSource.includes("建议横屏") && + dashboardSource.includes("Rotate to landscape") && + dashboardSource.includes("disableClose={true}"), + "mobile terminal should render one selected chart and suggest landscape for the full grid", + ); + assert( + selectorSource.includes("[1, 2, 3].map") && + selectorSource.includes("grid grid-cols-3"), + "grid selector must expose at most a 3 by 3 chart layout", + ); +}