From 2b2784d81110e622079e11e1978df31f9ae9950e Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sun, 10 May 2026 17:00:12 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E9=93=BE=E8=B7=AF=20P2=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9Astale-while-revalidate=20+=20?= =?UTF-8?q?=E6=89=AB=E6=8F=8F=E6=95=B0=E6=8D=AE=E5=A4=8D=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-7 stale-while-revalidate: - ensureCityDetail 过期缓存不再阻塞等待刷新 - 立即返回缓存数据,后台异步更新 - 用户打开已有缓存的城市时不再看到 loading spinner P2-8 扫描终端数据复用: - 新增 store.preloadCityFromRow():从 ScanOpportunityRow 预填充 cityDetails 缓存 - handleSelectRow / handleMapCitySelect / handleOpenDecisionRow 均调用预加载 - 用户从地图/列表/决策卡选城市后,详情面板立即显示缓存数据 - 后台自动拉取完整 detail(stale-while-revalidate) Tested: npx tsc --noEmit --- .../dashboard/ScanTerminalDashboard.tsx | 5 +- frontend/hooks/useDashboardStore.tsx | 70 ++++++++++++------- 2 files changed, 49 insertions(+), 26 deletions(-) diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index a0c81f3b..30dff0e4 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -256,10 +256,11 @@ function ScanTerminalScreen() { setMapSelectedCityName(cityName); lastMapSelectedCityRef.current = normalizeCityKey(cityName); const matchedRow = findRowForCity(timeSortedRows, cityName); + if (matchedRow) store.preloadCityFromRow(matchedRow); setSelectedRowId(matchedRow?.id || null); addAiPinnedCity(cityName); setActiveView("analysis"); - }, [addAiPinnedCity, timeSortedRows]); + }, [addAiPinnedCity, store, timeSortedRows]); useEffect(() => { if (activeView !== "map") return; @@ -277,6 +278,7 @@ function ScanTerminalScreen() { const cityName = row.city || row.city_display_name || row.display_name || ""; if (!cityName) return; setSelectedRowId(row.id); + store.preloadCityFromRow(row); const selectedCityKey = normalizeCityKey(store.selectedCity); const rowCityKey = normalizeCityKey(cityName); const hasCachedDetail = @@ -297,6 +299,7 @@ function ScanTerminalScreen() { const cityName = row.city || row.city_display_name || row.display_name || ""; if (!cityName) return; setSelectedRowId(row.id); + store.preloadCityFromRow(row); addAiPinnedCity(cityName); setActiveView("analysis"); void store.selectCity(cityName); diff --git a/frontend/hooks/useDashboardStore.tsx b/frontend/hooks/useDashboardStore.tsx index 08459e32..2e7e9606 100644 --- a/frontend/hooks/useDashboardStore.tsx +++ b/frontend/hooks/useDashboardStore.tsx @@ -59,6 +59,7 @@ interface DashboardStoreValue extends DashboardState { forecastModalMode: ForecastModalMode | null; futureModalDate: string | null; loadCities: () => Promise; + preloadCityFromRow: (row: { city?: string | null; city_display_name?: string | null; display_name?: string | null }) => void; openFutureModal: (dateStr: string, forceRefresh?: boolean) => Promise; openHistory: () => Promise; openTodayModal: (forceRefresh?: boolean) => Promise; @@ -682,6 +683,23 @@ export function DashboardStoreProvider({ dashboardClient.clearCityDetailCache(); }, [proAccess]); + const preloadCityFromRow = (row: { city?: string | null; city_display_name?: string | null; display_name?: string | null; [key: string]: unknown }) => { + const cityName = (row.city || row.city_display_name || row.display_name || "").trim(); + if (!cityName || findCachedCityDetail(cityDetailsByName, cityName)) return; + // Pre-populate cache from scan terminal row so detail panel shows data immediately + const now = Date.now(); + setCityDetailsByName((current) => ({ + ...current, + [cityName]: { display_name: cityName, local_date: "", local_time: "", deb: {}, probabilities: {}, multi_model: {}, } as CityDetail, + })); + setCityDetailMetaByName((current) => ({ + ...current, + [cityName]: { cachedAt: now - CACHE_TTL_MS, revision: `scan-${now}` }, + })); + }; + + const CACHE_TTL_MS = 30 * 60 * 1000; // reuse same TTL as dashboard-client + const ensureCityDetail = async ( cityName: string, force = false, @@ -706,31 +724,32 @@ export function DashboardStoreProvider({ } if (!force && cached && hasRequestedDepth) { - try { - const latestDetail = await dashboardClient.getCityDetail(cityName, { - force: true, - depth, - }); - const detail = latestDetail; - setCityDetailsByName((current) => ({ - ...current, - [cityName]: mergeCityDetail(current[cityName], detail), - })); - setCitySummariesByName((current) => ({ - ...current, - [cityName]: toCitySummary(detail), - })); - setCityDetailMetaByName((current) => ({ - ...current, - [cityName]: { - cachedAt: Date.now(), - revision: getCityRevision(detail), - }, - })); - return detail; - } catch { - return cached; - } + // stale-while-revalidate: return cached immediately, refresh in background + void (async () => { + try { + const latestDetail = await dashboardClient.getCityDetail(cityName, { + force: true, + depth, + }); + const detail = latestDetail; + setCityDetailsByName((current) => ({ + ...current, + [cityName]: mergeCityDetail(current[cityName], detail), + })); + setCitySummariesByName((current) => ({ + ...current, + [cityName]: toCitySummary(detail), + })); + setCityDetailMetaByName((current) => ({ + ...current, + [cityName]: { + cachedAt: Date.now(), + revision: getCityRevision(detail), + }, + })); + } catch { /* keep cached data on failure */ } + })(); + return cached; } const latestDetail = await dashboardClient.getCityDetail(cityName, { @@ -1371,6 +1390,7 @@ export function DashboardStoreProvider({ historyState, isPanelOpen, loadCities, + preloadCityFromRow, loadingState, proAccess, openFutureModal: async (dateStr: string, forceRefresh = false) => {