Files
PolyWeather/frontend/components/dashboard/scan-terminal/use-scan-terminal-query.ts
T
2569718930@qq.com 23882ac1ef SSE Patch 增量推送架构全链路实现
- web/sse_manager.py: asyncio.Queue 连接池 + broadcast + event_stream
- web/routers/sse_router.py: GET /api/events + POST /api/internal/collector-patch
- weather_sources.py: 采集后 POST patch 给 web
- Nginx polyweather.conf: /api/events proxy_buffering off
- frontend/api/events/route.ts: Next.js SSE 代理
- frontend/hooks/use-sse-patches.ts: EventSource + useLatestPatch + 重连
- LiveTemperatureThresholdChart: mergePatchIntoHourly 增量合并
- scan-terminal-query: 接入 SSE patch 更新行数据
- 新增 ssePatchArchitecture.test.ts 架构测试
2026-05-26 10:39:17 +08:00

201 lines
6.3 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
scanTerminalClient,
shouldSkipManualTerminalRefresh,
} from "@/components/dashboard/scan-terminal/scan-terminal-client";
import { useRemoteDataQuery } from "@/components/dashboard/scan-terminal/use-remote-data-query";
import { REGIONS } from "@/components/dashboard/scan-terminal/continent-grouping";
import { DASHBOARD_REFRESH_POLICY_MS } from "@/lib/refresh-policy";
import {
getLatestPatchesSnapshot,
useSsePatchVersion,
type CityPatch,
} from "@/hooks/use-sse-patches";
import type { ScanTerminalResponse } from "@/lib/dashboard-types";
const SCAN_CACHE_PREFIX = "polyweather_scan_v2";
const SCAN_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.scanRows;
function scanCacheKey(tradingRegion: string): string {
return `${SCAN_CACHE_PREFIX}:${tradingRegion || "all"}`;
}
function readScanCache(tradingRegion: string): ScanTerminalResponse | null {
try {
const raw = localStorage.getItem(scanCacheKey(tradingRegion));
if (!raw) return null;
const cached = JSON.parse(raw);
if (cached.ts && Date.now() - cached.ts < SCAN_CACHE_TTL_MS && cached.data?.rows) {
return cached.data;
}
} catch { /* ignore */ }
return null;
}
function writeScanCache(data: ScanTerminalResponse, tradingRegion: string) {
try { localStorage.setItem(scanCacheKey(tradingRegion), JSON.stringify({ ts: Date.now(), data })); } catch { /* ignore */ }
}
function normalizeCityKey(city: string | null | undefined) {
return String(city || "").trim().toLowerCase();
}
function applyPatchToScanRow(row: any, patch: CityPatch) {
const temp = typeof patch.changes.temp === "number" && Number.isFinite(patch.changes.temp)
? patch.changes.temp
: null;
if (temp === null) return row;
return {
...row,
current_temp: temp,
current_max_so_far: Math.max(
temp,
typeof row.current_max_so_far === "number" && Number.isFinite(row.current_max_so_far)
? row.current_max_so_far
: temp,
),
local_time: typeof patch.changes.obs_time === "string" ? patch.changes.obs_time : row.local_time,
sse_revision: patch.revision,
};
}
function applyTerminalPatches(
data: ScanTerminalResponse | null | undefined,
patches: Map<string, CityPatch>,
): ScanTerminalResponse | null | undefined {
if (!data?.rows?.length || !patches.size) return data;
let changed = false;
const rows = data.rows.map((row: any) => {
const patch = patches.get(normalizeCityKey(row.city));
if (!patch) return row;
changed = true;
return applyPatchToScanRow(row, patch);
});
return changed ? { ...data, rows } : data;
}
export function useScanTerminalQuery({
isPro,
proAccessLoading,
timezoneOffsetSeconds,
tradingRegion,
}: {
isPro: boolean;
proAccessLoading: boolean;
timezoneOffsetSeconds?: number | null;
tradingRegion?: string;
}) {
const {
data: terminalData,
error: scanError,
loading: scanLoading,
remote: scanRemote,
reset,
run,
} = useRemoteDataQuery<ScanTerminalResponse>();
const lastForcedScanRefreshAtRef = useRef(0);
const patchVersion = useSsePatchVersion();
const [cachedRows, setCachedRows] = useState<ScanTerminalResponse | null>(() => {
if (typeof window !== "undefined") return readScanCache(tradingRegion || "");
return null;
});
const fetchScanTerminal = useCallback(
async ({
forceRefresh = false,
showLoading = false,
}: {
forceRefresh?: boolean;
showLoading?: boolean;
} = {}) => {
if (proAccessLoading || !isPro) return;
if (typeof fetch !== "function" || typeof AbortController === "undefined") {
return;
}
if (forceRefresh) {
lastForcedScanRefreshAtRef.current = Date.now();
}
await run({
request: (signal) =>
scanTerminalClient.getTerminal({
forceRefresh,
signal,
timezoneOffsetSeconds,
tradingRegion,
}),
showLoading,
onSuccess: (data) => { writeScanCache(data, tradingRegion || ""); setCachedRows(data); },
});
},
[isPro, proAccessLoading, run, timezoneOffsetSeconds, tradingRegion],
);
useEffect(() => {
if (proAccessLoading) return;
if (!isPro) {
reset();
return;
}
void fetchScanTerminal({ forceRefresh: false, showLoading: true });
}, [fetchScanTerminal, isPro, proAccessLoading, reset, timezoneOffsetSeconds, tradingRegion]);
const effectiveData = useMemo(
() => applyTerminalPatches(terminalData || cachedRows, getLatestPatchesSnapshot()),
[terminalData, cachedRows, patchVersion],
);
const refreshScanTerminalManually = useCallback(() => {
if (
shouldSkipManualTerminalRefresh({
hasCurrentData: Boolean(terminalData),
lastForcedRefreshAt: lastForcedScanRefreshAtRef.current,
})
) {
return;
}
void fetchScanTerminal({ forceRefresh: true, showLoading: true });
}, [fetchScanTerminal, terminalData]);
// Preload adjacent regions in idle time for instant tab switches
useEffect(() => {
if (typeof window === "undefined" || !tradingRegion || !isPro) return;
const sorted = [...REGIONS].sort((a, b) => a.sort - b.sort);
const idx = sorted.findIndex((r) => r.key === tradingRegion);
if (idx < 0) return;
const neighbors = [idx - 1, idx + 1]
.filter((i) => i >= 0 && i < sorted.length)
.map((i) => sorted[i].key);
if (!neighbors.length) return;
const preloadOne = (region: string) => {
if (readScanCache(region)) return; // already cached
const idleFn = (window as any).requestIdleCallback || ((cb: () => void) => setTimeout(cb, 5000));
const handle = idleFn(() => {
void scanTerminalClient.getTerminal({
forceRefresh: false,
signal: new AbortController().signal,
tradingRegion: region,
}).then((data) => { writeScanCache(data, region); });
});
return handle;
};
const handles = neighbors.map(preloadOne).filter(Boolean);
return () => {
const cancelFn = (window as any).cancelIdleCallback || clearTimeout;
handles.forEach((h) => { try { cancelFn(h); } catch { /* */ } });
};
}, [tradingRegion, isPro]);
return {
refreshScanTerminalManually,
scanError,
scanLoading,
scanRemote,
terminalData: effectiveData,
};
}