From 8d05812a7e4ccf3a5f4949561b6b4fa378625dbc Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 28 Apr 2026 11:25:14 +0800 Subject: [PATCH] Keep dense dashboard lists stable during selection changes Opportunity and calendar cards now isolate item rendering behind memoized components. This preserves the current action grouping and copy while preventing selection or parent dashboard updates from re-rendering every dense card body. Constraint: Do not change ranking, grouping, or product wording in this performance pass. Rejected: Introduce virtual list dependency | the current list size can benefit from memo boundaries first without new dependencies. Confidence: high Scope-risk: narrow Reversibility: clean Tested: TypeScript diagnostics for CalendarView and OpportunityOverview Tested: npm run build Not-tested: Browser profiler capture with a large production city set. --- .../dashboard/scan-terminal/CalendarView.tsx | 129 +++++++++++------- .../scan-terminal/OpportunityOverview.tsx | 11 +- 2 files changed, 83 insertions(+), 57 deletions(-) diff --git a/frontend/components/dashboard/scan-terminal/CalendarView.tsx b/frontend/components/dashboard/scan-terminal/CalendarView.tsx index d76fee56..af76e189 100644 --- a/frontend/components/dashboard/scan-terminal/CalendarView.tsx +++ b/frontend/components/dashboard/scan-terminal/CalendarView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { memo, useEffect, useMemo, useState } from "react"; import { getWindowPhaseMeta } from "@/components/dashboard/OpportunityTable"; import type { ScanOpportunityRow } from "@/lib/dashboard-types"; import { getLocalizedCityName } from "@/lib/dashboard-home-copy"; @@ -26,6 +26,12 @@ type CalendarActionGroup = { sort: number; }; +type CalendarActionItem = { + row: ScanOpportunityRow; + meta: CalendarMeta; + reason: string; +}; + function normalizeCalendarCityKey(value?: string | null) { return String(value || "") .trim() @@ -291,6 +297,66 @@ function buildCalendarCoreReason( : "先放入行动面板,等待下一轮观测"; } +const CalendarActionCard = memo(function CalendarActionCard({ + item, + locale, + selected, + onSelectRow, +}: { + item: CalendarActionItem; + locale: string; + selected: boolean; + onSelectRow: (row: ScanOpportunityRow) => void; +}) { + const { row, meta, reason } = item; + const tempSymbol = row.temp_symbol || "°C"; + const phaseMeta = getWindowPhaseMeta(row, locale); + + return ( + + ); +}); + export function CalendarView({ rows, locale, @@ -324,7 +390,7 @@ export function CalendarView({ label: string; subtitle: string; sort: number; - items: Array<{ row: ScanOpportunityRow; meta: CalendarMeta; reason: string }>; + items: CalendarActionItem[]; } >(); dedupeCalendarRows(rows).forEach((row) => { @@ -385,57 +451,14 @@ export function CalendarView({
- {group.items.map(({ row, meta, reason }) => ( - + {group.items.map((item) => ( + ))}
diff --git a/frontend/components/dashboard/scan-terminal/OpportunityOverview.tsx b/frontend/components/dashboard/scan-terminal/OpportunityOverview.tsx index 638af1e8..3e5b567f 100644 --- a/frontend/components/dashboard/scan-terminal/OpportunityOverview.tsx +++ b/frontend/components/dashboard/scan-terminal/OpportunityOverview.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { memo, useMemo } from "react"; import clsx from "clsx"; import type { ScanOpportunityRow, ScanTerminalResponse } from "@/lib/dashboard-types"; import { formatTemperatureValue } from "@/lib/dashboard-utils"; @@ -13,7 +13,7 @@ import { pickOpportunitySections, } from "@/components/dashboard/scan-terminal/decision-utils"; -function OpportunityDecisionCard({ +const OpportunityDecisionCard = memo(function OpportunityDecisionCard({ row, locale, selected, @@ -91,7 +91,7 @@ function OpportunityDecisionCard({ ); -} +}); export function OpportunityOverview({ rows, @@ -116,7 +116,10 @@ export function OpportunityOverview({ }) { const isEn = locale === "en-US"; const sections = useMemo(() => pickOpportunitySections(rows, locale), [locale, rows]); - const visibleSections = sections.filter((section) => section.rows.length > 0); + const visibleSections = useMemo( + () => sections.filter((section) => section.rows.length > 0), + [sections], + ); const summary = terminalData?.summary; if (loading) {