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.
This commit is contained in:
2569718930@qq.com
2026-04-28 10:50:57 +08:00
parent 6fe0e5dfc5
commit 82a736850b
2 changed files with 63 additions and 53 deletions
@@ -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<string | null | undefined>,
) {
return `${prefix}:${parts
.map((part) => encodeURIComponent(String(part || "").trim()))
.join(":")}`;
}
export function readCachedPayload<T>(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<T>(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.
}
}
@@ -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<string | null | undefined>) {
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<T>(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<T>(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;