From 82a736850b52c9e9838315fdc3919e028d6ef1a3 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 28 Apr 2026 10:50:57 +0800 Subject: [PATCH] Separate scan terminal storage cache helpers The city-card data hook still contained localStorage serialization and TTL eviction helpers alongside AI fallback and UI state transitions. Moving those helpers into scan-terminal-cache creates a reusable cache boundary for the scan terminal request layer without changing cache keys, TTLs, or payload shapes. Constraint: Preserve existing localStorage keys and expiry behavior. Rejected: Migrate all caches to RemoteData in this commit | cache-helper extraction is a safer intermediate step before query policy changes. Confidence: high Scope-risk: narrow Reversibility: clean Tested: npm run build Not-tested: Browser private-mode quota edge cases beyond existing guarded behavior. --- .../scan-terminal/scan-terminal-cache.ts | 57 ++++++++++++++++++ .../scan-terminal/use-ai-city-card-data.ts | 59 ++----------------- 2 files changed, 63 insertions(+), 53 deletions(-) create mode 100644 frontend/components/dashboard/scan-terminal/scan-terminal-cache.ts diff --git a/frontend/components/dashboard/scan-terminal/scan-terminal-cache.ts b/frontend/components/dashboard/scan-terminal/scan-terminal-cache.ts new file mode 100644 index 00000000..17c2bda8 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/scan-terminal-cache.ts @@ -0,0 +1,57 @@ +"use client"; + +function getStorage() { + if (typeof window === "undefined") return null; + try { + return window.localStorage; + } catch { + return null; + } +} + +export function buildStorageKey( + prefix: string, + parts: Array, +) { + return `${prefix}:${parts + .map((part) => encodeURIComponent(String(part || "").trim())) + .join(":")}`; +} + +export function readCachedPayload(key: string, ttlMs: number): T | null { + const storage = getStorage(); + if (!storage) return null; + try { + const raw = storage.getItem(key); + if (!raw) return null; + const parsed = JSON.parse(raw) as { cachedAt?: number; payload?: T }; + if (!parsed?.payload) return null; + if (Date.now() - Number(parsed.cachedAt || 0) > ttlMs) { + storage.removeItem(key); + return null; + } + return parsed.payload; + } catch { + return null; + } +} + +export function writeCachedPayload(key: string, payload: T) { + const storage = getStorage(); + if (!storage) return; + try { + storage.setItem(key, JSON.stringify({ cachedAt: Date.now(), payload })); + } catch { + // Ignore quota/privacy-mode failures; network fallbacks still work. + } +} + +export function removeCachedPayload(key: string) { + const storage = getStorage(); + if (!storage) return; + try { + storage.removeItem(key); + } catch { + // Ignore privacy-mode failures; the next network request can still proceed. + } +} diff --git a/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts b/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts index 2a4c8d31..1775c615 100644 --- a/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts +++ b/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts @@ -9,6 +9,12 @@ import { scanTerminalClient, type AiCityStreamProgress, } from "@/components/dashboard/scan-terminal/scan-terminal-client"; +import { + buildStorageKey, + readCachedPayload, + removeCachedPayload, + writeCachedPayload, +} from "@/components/dashboard/scan-terminal/scan-terminal-cache"; import type { CityDetail, MarketScan } from "@/lib/dashboard-types"; import { normalizeCityKey } from "./decision-utils"; @@ -21,21 +27,6 @@ const aiCityForecastStateCache = new Map< { state: AiCityForecastState; updatedAt: number } >(); -function getStorage() { - if (typeof window === "undefined") return null; - try { - return window.localStorage; - } catch { - return null; - } -} - -function buildStorageKey(prefix: string, parts: Array) { - return `${prefix}:${parts - .map((part) => encodeURIComponent(String(part || "").trim())) - .join(":")}`; -} - function isHkoObservationCity(detail?: CityDetail | null) { const source = String( detail?.current?.settlement_source || @@ -47,44 +38,6 @@ function isHkoObservationCity(detail?: CityDetail | null) { return source === "hko"; } -function readCachedPayload(key: string, ttlMs: number): T | null { - const storage = getStorage(); - if (!storage) return null; - try { - const raw = storage.getItem(key); - if (!raw) return null; - const parsed = JSON.parse(raw) as { cachedAt?: number; payload?: T }; - if (!parsed?.payload) return null; - if (Date.now() - Number(parsed.cachedAt || 0) > ttlMs) { - storage.removeItem(key); - return null; - } - return parsed.payload; - } catch { - return null; - } -} - -function writeCachedPayload(key: string, payload: T) { - const storage = getStorage(); - if (!storage) return; - try { - storage.setItem(key, JSON.stringify({ cachedAt: Date.now(), payload })); - } catch { - // Ignore quota/privacy-mode failures; network fallbacks still work. - } -} - -function removeCachedPayload(key: string) { - const storage = getStorage(); - if (!storage) return; - try { - storage.removeItem(key); - } catch { - // Ignore privacy-mode failures; the next network request can still proceed. - } -} - function readCachedAiForecastState(key: string, ttlMs: number) { const cached = aiCityForecastStateCache.get(key); if (!cached) return null;