Represent market scan loading with RemoteData

Market scan state was still tracked as separate nullable payload and status strings. This moves the hook internals to RemoteData<MarketScan> so loading and error paths can preserve previous data while keeping the existing marketScan and marketStatus return values for current card consumers.

Constraint: Preserve the existing useCityMarketScan public compatibility fields.

Rejected: Update all card UI to consume marketRemote immediately | keeping the compatibility bridge avoids a broad rendering diff while the request state model lands.

Confidence: high

Scope-risk: narrow

Reversibility: clean

Tested: npm run build

Not-tested: Forced live market-scan failure with stale previous quote.
This commit is contained in:
2569718930@qq.com
2026-04-28 11:00:22 +08:00
parent 82a736850b
commit d2699ce17a
@@ -8,6 +8,10 @@ import type {
import { import {
scanTerminalClient, scanTerminalClient,
type AiCityStreamProgress, type AiCityStreamProgress,
type RemoteData,
toRemoteError,
toRemoteLoading,
toRemoteSuccess,
} from "@/components/dashboard/scan-terminal/scan-terminal-client"; } from "@/components/dashboard/scan-terminal/scan-terminal-client";
import { import {
buildStorageKey, buildStorageKey,
@@ -22,6 +26,7 @@ const AI_CITY_FORECAST_CACHE_PREFIX = "polyWeather_aiCityForecast_v6";
const AI_CITY_FORECAST_CACHE_TTL_MS = 60 * 60 * 1000; const AI_CITY_FORECAST_CACHE_TTL_MS = 60 * 60 * 1000;
const CITY_MARKET_SCAN_CACHE_PREFIX = "polyWeather_cityMarketScan_v3"; const CITY_MARKET_SCAN_CACHE_PREFIX = "polyWeather_cityMarketScan_v3";
const CITY_MARKET_SCAN_CACHE_TTL_MS = 10 * 60 * 1000; const CITY_MARKET_SCAN_CACHE_TTL_MS = 10 * 60 * 1000;
type CityMarketScanStatus = "idle" | "loading" | "ready" | "failed";
const aiCityForecastStateCache = new Map< const aiCityForecastStateCache = new Map<
string, string,
{ state: AiCityForecastState; updatedAt: number } { state: AiCityForecastState; updatedAt: number }
@@ -349,17 +354,13 @@ export function useCityMarketScan({
detailCityName: string; detailCityName: string;
enabled?: boolean; enabled?: boolean;
}) { }) {
const [marketScan, setMarketScan] = useState<MarketScan | null>( const [marketRemote, setMarketRemote] = useState<RemoteData<MarketScan>>(
detail?.market_scan || null, detail?.market_scan ? toRemoteSuccess(detail.market_scan) : { status: "idle" },
); );
const [marketStatus, setMarketStatus] = useState<
"idle" | "loading" | "ready" | "failed"
>(detail?.market_scan ? "ready" : "idle");
useEffect(() => { useEffect(() => {
if (!detail) { if (!detail) {
setMarketScan(null); setMarketRemote({ status: "idle" });
setMarketStatus("idle");
return; return;
} }
const cacheKey = buildStorageKey(CITY_MARKET_SCAN_CACHE_PREFIX, [ const cacheKey = buildStorageKey(CITY_MARKET_SCAN_CACHE_PREFIX, [
@@ -369,8 +370,7 @@ export function useCityMarketScan({
]); ]);
let cancelled = false; let cancelled = false;
if (detail.market_scan) { if (detail.market_scan) {
setMarketScan(detail.market_scan); setMarketRemote(toRemoteSuccess(detail.market_scan));
setMarketStatus("ready");
writeCachedPayload(cacheKey, detail.market_scan); writeCachedPayload(cacheKey, detail.market_scan);
return () => { return () => {
cancelled = true; cancelled = true;
@@ -382,11 +382,9 @@ export function useCityMarketScan({
CITY_MARKET_SCAN_CACHE_TTL_MS, CITY_MARKET_SCAN_CACHE_TTL_MS,
); );
if (cached) { if (cached) {
setMarketScan(cached); setMarketRemote(toRemoteSuccess(cached));
setMarketStatus("ready");
} else { } else {
setMarketScan(null); setMarketRemote({ status: "idle" });
setMarketStatus("idle");
} }
return () => { return () => {
cancelled = true; cancelled = true;
@@ -397,13 +395,12 @@ export function useCityMarketScan({
CITY_MARKET_SCAN_CACHE_TTL_MS, CITY_MARKET_SCAN_CACHE_TTL_MS,
); );
if (cached) { if (cached) {
setMarketScan(cached); setMarketRemote(toRemoteSuccess(cached));
setMarketStatus("ready");
return () => { return () => {
cancelled = true; cancelled = true;
}; };
} else { } else {
setMarketStatus("loading"); setMarketRemote((current) => toRemoteLoading(current));
} }
const controller = new AbortController(); const controller = new AbortController();
void scanTerminalClient.getMarketScan(detailCityName, { void scanTerminalClient.getMarketScan(detailCityName, {
@@ -416,13 +413,20 @@ export function useCityMarketScan({
if (payload) { if (payload) {
writeCachedPayload(cacheKey, payload); writeCachedPayload(cacheKey, payload);
} }
setMarketScan(payload || detail.market_scan || null); const nextPayload = payload || detail.market_scan || null;
setMarketStatus("ready"); if (nextPayload) {
setMarketRemote(toRemoteSuccess(nextPayload));
} else {
setMarketRemote({ status: "idle" });
}
}) })
.catch(() => { .catch((error) => {
if (cancelled) return; if (cancelled) return;
setMarketScan(detail.market_scan || null); if (detail.market_scan) {
setMarketStatus(detail.market_scan ? "ready" : "failed"); setMarketRemote(toRemoteSuccess(detail.market_scan));
} else {
setMarketRemote((current) => toRemoteError(error, current));
}
}); });
return () => { return () => {
cancelled = true; cancelled = true;
@@ -430,5 +434,24 @@ export function useCityMarketScan({
}; };
}, [detail, detailCityName, enabled]); }, [detail, detailCityName, enabled]);
return { marketScan, marketStatus }; const previousMarketScan =
marketRemote.status === "loading" || marketRemote.status === "error"
? marketRemote.previous ?? null
: null;
const marketScan =
marketRemote.status === "success"
? marketRemote.data
: previousMarketScan ?? detail?.market_scan ?? null;
const marketStatus: CityMarketScanStatus =
marketRemote.status === "success"
? "ready"
: marketRemote.status === "loading"
? "loading"
: marketRemote.status === "error"
? marketScan
? "ready"
: "failed"
: "idle";
return { marketRemote, marketScan, marketStatus };
} }