数据链路 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);
|
setMapSelectedCityName(cityName);
|
||||||
lastMapSelectedCityRef.current = normalizeCityKey(cityName);
|
lastMapSelectedCityRef.current = normalizeCityKey(cityName);
|
||||||
const matchedRow = findRowForCity(timeSortedRows, cityName);
|
const matchedRow = findRowForCity(timeSortedRows, cityName);
|
||||||
|
if (matchedRow) store.preloadCityFromRow(matchedRow);
|
||||||
setSelectedRowId(matchedRow?.id || null);
|
setSelectedRowId(matchedRow?.id || null);
|
||||||
addAiPinnedCity(cityName);
|
addAiPinnedCity(cityName);
|
||||||
setActiveView("analysis");
|
setActiveView("analysis");
|
||||||
}, [addAiPinnedCity, timeSortedRows]);
|
}, [addAiPinnedCity, store, timeSortedRows]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeView !== "map") return;
|
if (activeView !== "map") return;
|
||||||
@@ -277,6 +278,7 @@ function ScanTerminalScreen() {
|
|||||||
const cityName = row.city || row.city_display_name || row.display_name || "";
|
const cityName = row.city || row.city_display_name || row.display_name || "";
|
||||||
if (!cityName) return;
|
if (!cityName) return;
|
||||||
setSelectedRowId(row.id);
|
setSelectedRowId(row.id);
|
||||||
|
store.preloadCityFromRow(row);
|
||||||
const selectedCityKey = normalizeCityKey(store.selectedCity);
|
const selectedCityKey = normalizeCityKey(store.selectedCity);
|
||||||
const rowCityKey = normalizeCityKey(cityName);
|
const rowCityKey = normalizeCityKey(cityName);
|
||||||
const hasCachedDetail =
|
const hasCachedDetail =
|
||||||
@@ -297,6 +299,7 @@ function ScanTerminalScreen() {
|
|||||||
const cityName = row.city || row.city_display_name || row.display_name || "";
|
const cityName = row.city || row.city_display_name || row.display_name || "";
|
||||||
if (!cityName) return;
|
if (!cityName) return;
|
||||||
setSelectedRowId(row.id);
|
setSelectedRowId(row.id);
|
||||||
|
store.preloadCityFromRow(row);
|
||||||
addAiPinnedCity(cityName);
|
addAiPinnedCity(cityName);
|
||||||
setActiveView("analysis");
|
setActiveView("analysis");
|
||||||
void store.selectCity(cityName);
|
void store.selectCity(cityName);
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ interface DashboardStoreValue extends DashboardState {
|
|||||||
forecastModalMode: ForecastModalMode | null;
|
forecastModalMode: ForecastModalMode | null;
|
||||||
futureModalDate: string | null;
|
futureModalDate: string | null;
|
||||||
loadCities: () => Promise<void>;
|
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>;
|
openFutureModal: (dateStr: string, forceRefresh?: boolean) => Promise<void>;
|
||||||
openHistory: () => Promise<void>;
|
openHistory: () => Promise<void>;
|
||||||
openTodayModal: (forceRefresh?: boolean) => Promise<void>;
|
openTodayModal: (forceRefresh?: boolean) => Promise<void>;
|
||||||
@@ -682,6 +683,23 @@ export function DashboardStoreProvider({
|
|||||||
dashboardClient.clearCityDetailCache();
|
dashboardClient.clearCityDetailCache();
|
||||||
}, [proAccess]);
|
}, [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 (
|
const ensureCityDetail = async (
|
||||||
cityName: string,
|
cityName: string,
|
||||||
force = false,
|
force = false,
|
||||||
@@ -706,31 +724,32 @@ export function DashboardStoreProvider({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!force && cached && hasRequestedDepth) {
|
if (!force && cached && hasRequestedDepth) {
|
||||||
try {
|
// stale-while-revalidate: return cached immediately, refresh in background
|
||||||
const latestDetail = await dashboardClient.getCityDetail(cityName, {
|
void (async () => {
|
||||||
force: true,
|
try {
|
||||||
depth,
|
const latestDetail = await dashboardClient.getCityDetail(cityName, {
|
||||||
});
|
force: true,
|
||||||
const detail = latestDetail;
|
depth,
|
||||||
setCityDetailsByName((current) => ({
|
});
|
||||||
...current,
|
const detail = latestDetail;
|
||||||
[cityName]: mergeCityDetail(current[cityName], detail),
|
setCityDetailsByName((current) => ({
|
||||||
}));
|
...current,
|
||||||
setCitySummariesByName((current) => ({
|
[cityName]: mergeCityDetail(current[cityName], detail),
|
||||||
...current,
|
}));
|
||||||
[cityName]: toCitySummary(detail),
|
setCitySummariesByName((current) => ({
|
||||||
}));
|
...current,
|
||||||
setCityDetailMetaByName((current) => ({
|
[cityName]: toCitySummary(detail),
|
||||||
...current,
|
}));
|
||||||
[cityName]: {
|
setCityDetailMetaByName((current) => ({
|
||||||
cachedAt: Date.now(),
|
...current,
|
||||||
revision: getCityRevision(detail),
|
[cityName]: {
|
||||||
},
|
cachedAt: Date.now(),
|
||||||
}));
|
revision: getCityRevision(detail),
|
||||||
return detail;
|
},
|
||||||
} catch {
|
}));
|
||||||
return cached;
|
} catch { /* keep cached data on failure */ }
|
||||||
}
|
})();
|
||||||
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestDetail = await dashboardClient.getCityDetail(cityName, {
|
const latestDetail = await dashboardClient.getCityDetail(cityName, {
|
||||||
@@ -1371,6 +1390,7 @@ export function DashboardStoreProvider({
|
|||||||
historyState,
|
historyState,
|
||||||
isPanelOpen,
|
isPanelOpen,
|
||||||
loadCities,
|
loadCities,
|
||||||
|
preloadCityFromRow,
|
||||||
loadingState,
|
loadingState,
|
||||||
proAccess,
|
proAccess,
|
||||||
openFutureModal: async (dateStr: string, forceRefresh = false) => {
|
openFutureModal: async (dateStr: string, forceRefresh = false) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user