数据链路 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:
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user