终端布局和搜索框清理,新增网格策略业务测试
This commit is contained in:
@@ -144,6 +144,36 @@ const TERM = {
|
|||||||
closed: { en: "Closed", zh: "已关闭" },
|
closed: { en: "Closed", zh: "已关闭" },
|
||||||
} as const;
|
} 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<string | null>, 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) {
|
function t(key: keyof typeof TERM, isEn: boolean) {
|
||||||
return isEn ? TERM[key].en : TERM[key].zh;
|
return isEn ? TERM[key].en : TERM[key].zh;
|
||||||
}
|
}
|
||||||
@@ -359,102 +389,51 @@ function PolyWeatherTerminal({
|
|||||||
const [activeNavKey, setActiveNavKey] = useState<string>("thresholds");
|
const [activeNavKey, setActiveNavKey] = useState<string>("thresholds");
|
||||||
|
|
||||||
const [gridCols, setGridCols] = useState<number>(() => {
|
const [gridCols, setGridCols] = useState<number>(() => {
|
||||||
if (typeof window !== "undefined") {
|
return getStoredGridSide("polyweather_terminal_grid_cols");
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const [gridRows, setGridRows] = useState<number>(() => {
|
const [gridRows, setGridRows] = useState<number>(() => {
|
||||||
if (typeof window !== "undefined") {
|
return getStoredGridSide("polyweather_terminal_grid_rows");
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalSlots = gridCols * gridRows;
|
const totalSlots = getSlotCount(gridCols, gridRows);
|
||||||
|
|
||||||
const [slots, setSlots] = useState<Array<string | null>>(() => {
|
const [slots, setSlots] = useState<Array<string | null>>(() => {
|
||||||
|
const storedCols = getStoredGridSide("polyweather_terminal_grid_cols");
|
||||||
|
const storedRows = getStoredGridSide("polyweather_terminal_grid_rows");
|
||||||
|
const initialSlotCount = getSlotCount(storedCols, storedRows);
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem("polyweather_terminal_slots");
|
const stored = localStorage.getItem("polyweather_terminal_slots");
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const parsed = JSON.parse(stored);
|
const parsed = JSON.parse(stored);
|
||||||
if (Array.isArray(parsed)) {
|
if (Array.isArray(parsed)) {
|
||||||
let targetLen = 4;
|
return normalizeSlotList(parsed, initialSlotCount);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
let defaultLen = 4;
|
return Array(initialSlotCount).fill(null);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
const [activeSlotIndex, setActiveSlotIndex] = useState<number>(0);
|
const [activeSlotIndex, setActiveSlotIndex] = useState<number>(0);
|
||||||
const [maximizedSlotIndex, setMaximizedSlotIndex] = useState<number | null>(null);
|
const [maximizedSlotIndex, setMaximizedSlotIndex] = useState<number | null>(null);
|
||||||
const [activeSearchSlotIndex, setActiveSearchSlotIndex] = useState<number | null>(null);
|
const [activeSearchSlotIndex, setActiveSearchSlotIndex] = useState<number | null>(null);
|
||||||
|
const visibleSlots = useMemo(() => normalizeSlotList(slots, totalSlots), [slots, totalSlots]);
|
||||||
|
|
||||||
const handleSetGridSize = (cols: number, rows: number) => {
|
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);
|
setGridCols(safeCols);
|
||||||
setGridRows(rows);
|
setGridRows(safeRows);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
localStorage.setItem("polyweather_terminal_grid_cols", String(cols));
|
localStorage.setItem("polyweather_terminal_grid_cols", String(safeCols));
|
||||||
localStorage.setItem("polyweather_terminal_grid_rows", String(rows));
|
localStorage.setItem("polyweather_terminal_grid_rows", String(safeRows));
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
let nextSlots = [...slots];
|
const nextSlots = normalizeSlotList(visibleSlots, nextTotalSlots);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
setSlots(nextSlots);
|
setSlots(nextSlots);
|
||||||
try {
|
try {
|
||||||
@@ -489,7 +468,7 @@ function PolyWeatherTerminal({
|
|||||||
}, [rows, selectedRegionKey]);
|
}, [rows, selectedRegionKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (filteredRegionRows.length && slots.every((s) => s === null)) {
|
if (filteredRegionRows.length && visibleSlots.every((s) => s === null)) {
|
||||||
const next = Array(totalSlots)
|
const next = Array(totalSlots)
|
||||||
.fill(null)
|
.fill(null)
|
||||||
.map((_, idx) => filteredRegionRows[idx]?.city || null);
|
.map((_, idx) => filteredRegionRows[idx]?.city || null);
|
||||||
@@ -498,10 +477,11 @@ function PolyWeatherTerminal({
|
|||||||
localStorage.setItem("polyweather_terminal_slots", JSON.stringify(next));
|
localStorage.setItem("polyweather_terminal_slots", JSON.stringify(next));
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
}, [filteredRegionRows, slots, totalSlots]);
|
}, [filteredRegionRows, visibleSlots, totalSlots]);
|
||||||
|
|
||||||
const handleSelectCityForSlot = (index: number, city: string | null) => {
|
const handleSelectCityForSlot = (index: number, city: string | null) => {
|
||||||
const next = [...slots];
|
if (index < 0 || index >= totalSlots) return;
|
||||||
|
const next = [...visibleSlots];
|
||||||
next[index] = city;
|
next[index] = city;
|
||||||
setSlots(next);
|
setSlots(next);
|
||||||
try {
|
try {
|
||||||
@@ -561,6 +541,14 @@ function PolyWeatherTerminal({
|
|||||||
setMobileTab(continentGroups[0].key);
|
setMobileTab(continentGroups[0].key);
|
||||||
}
|
}
|
||||||
}, [continentGroups, mobileTab]);
|
}, [continentGroups, mobileTab]);
|
||||||
|
const mobileChartRow = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedRow ||
|
||||||
|
mobileActiveGroup?.rows.slice(0, MOBILE_TERMINAL_CHARTS)[0] ||
|
||||||
|
filteredRegionRows[0] ||
|
||||||
|
null,
|
||||||
|
[filteredRegionRows, mobileActiveGroup?.rows, selectedRow],
|
||||||
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!filteredRegionRows.length) return;
|
if (!filteredRegionRows.length) return;
|
||||||
if (!selectedRow || !filteredRegionRows.some((row) => row.id === selectedRow.id)) {
|
if (!selectedRow || !filteredRegionRows.some((row) => row.id === selectedRow.id)) {
|
||||||
@@ -722,6 +710,22 @@ function PolyWeatherTerminal({
|
|||||||
isEn={isEn}
|
isEn={isEn}
|
||||||
onSelectTab={setMobileTab}
|
onSelectTab={setMobileTab}
|
||||||
/>
|
/>
|
||||||
|
<div className="rounded border border-blue-200 bg-blue-50 px-3 py-2 text-[11px] font-semibold leading-4 text-blue-700">
|
||||||
|
{isEn
|
||||||
|
? "Mobile renders one chart. Rotate to landscape to inspect the full terminal grid."
|
||||||
|
: "手机端仅渲染 1 个图表。建议横屏查看完整终端网格。"}
|
||||||
|
</div>
|
||||||
|
{mobileChartRow && (
|
||||||
|
<div className="h-[420px] min-h-[420px] overflow-hidden rounded border border-[#d2d9e2] bg-white">
|
||||||
|
<LiveTemperatureThresholdChart
|
||||||
|
isEn={isEn}
|
||||||
|
row={mobileChartRow}
|
||||||
|
allRows={filteredRegionRows}
|
||||||
|
compact={false}
|
||||||
|
disableClose={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-2 px-1">
|
<div className="space-y-2 px-1">
|
||||||
{mobileActiveGroup?.rows.map((row) => (
|
{mobileActiveGroup?.rows.map((row) => (
|
||||||
<MobileCityCard
|
<MobileCityCard
|
||||||
@@ -769,7 +773,7 @@ function PolyWeatherTerminal({
|
|||||||
>
|
>
|
||||||
<LiveTemperatureThresholdChart
|
<LiveTemperatureThresholdChart
|
||||||
isEn={isEn}
|
isEn={isEn}
|
||||||
row={filteredRegionRows.find((r) => String(r.city || "").toLowerCase() === slots[maximizedSlotIndex]) || null}
|
row={filteredRegionRows.find((r) => String(r.city || "").toLowerCase() === visibleSlots[maximizedSlotIndex]) || null}
|
||||||
allRows={filteredRegionRows}
|
allRows={filteredRegionRows}
|
||||||
compact={false}
|
compact={false}
|
||||||
onSearchClick={() => setActiveSearchSlotIndex(maximizedSlotIndex)}
|
onSearchClick={() => setActiveSearchSlotIndex(maximizedSlotIndex)}
|
||||||
@@ -779,7 +783,7 @@ function PolyWeatherTerminal({
|
|||||||
setMaximizedSlotIndex(null);
|
setMaximizedSlotIndex(null);
|
||||||
}}
|
}}
|
||||||
isMaximized={true}
|
isMaximized={true}
|
||||||
disableClose={slots.filter(Boolean).length <= 1}
|
disableClose={visibleSlots.filter(Boolean).length <= 1}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{activeSearchSlotIndex === maximizedSlotIndex && (
|
{activeSearchSlotIndex === maximizedSlotIndex && (
|
||||||
@@ -804,9 +808,8 @@ function PolyWeatherTerminal({
|
|||||||
gridTemplateRows: `repeat(${gridRows}, minmax(0, 1fr))`,
|
gridTemplateRows: `repeat(${gridRows}, minmax(0, 1fr))`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Array.from({ length: totalSlots }).map((_, slotIndex) => {
|
{visibleSlots.map((cityInSlot, slotIndex) => {
|
||||||
const isSlotActive = activeSlotIndex === slotIndex;
|
const isSlotActive = activeSlotIndex === slotIndex;
|
||||||
const cityInSlot = slots[slotIndex];
|
|
||||||
|
|
||||||
if (!cityInSlot) {
|
if (!cityInSlot) {
|
||||||
return (
|
return (
|
||||||
@@ -865,7 +868,7 @@ function PolyWeatherTerminal({
|
|||||||
handleSelectCityForSlot(slotIndex, null);
|
handleSelectCityForSlot(slotIndex, null);
|
||||||
}}
|
}}
|
||||||
isMaximized={false}
|
isMaximized={false}
|
||||||
disableClose={slots.filter(Boolean).length <= 1}
|
disableClose={visibleSlots.filter(Boolean).length <= 1}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{activeSearchSlotIndex === slotIndex && (
|
{activeSearchSlotIndex === slotIndex && (
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user