数据链路 P2 修复:stale-while-revalidate + 扫描数据复用

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
This commit is contained in:
2569718930@qq.com
2026-05-10 17:00:12 +08:00
parent b3ea8dcfa7
commit 2b2784d811
2 changed files with 49 additions and 26 deletions
@@ -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);
+45 -25
View File
@@ -59,6 +59,7 @@ interface DashboardStoreValue extends DashboardState {
forecastModalMode: ForecastModalMode | null;
futureModalDate: string | null;
loadCities: () => Promise<void>;
preloadCityFromRow: (row: { city?: string | null; city_display_name?: string | null; display_name?: string | null }) => void;
openFutureModal: (dateStr: string, forceRefresh?: boolean) => Promise<void>;
openHistory: () => Promise<void>;
openTodayModal: (forceRefresh?: boolean) => Promise<void>;
@@ -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) => {