Stabilize the decision workspace data boundaries
The scan terminal had grown into overlapping CSS, request-state, AI-provider, and city-card data responsibilities. This refactor separates those boundaries without changing product behavior: CSS modules are split by surface, city AI prompt/provider/fallback logic is isolated, and scan terminal request state now has reusable RemoteData adapters plus business-state tests. Constraint: Preserve existing global scan-terminal class names and API responses during the refactor Constraint: No new dependencies; keep this as a file-boundary cleanup Rejected: Introduce React Query now | higher migration risk than the requested lightweight query-client path Rejected: Rewrite AI stream behavior | progressive/fallback states are product-sensitive and were only adapter-split Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep AI stream state changes covered by business snapshots before changing fallback/cache wording Tested: npm run test:business; npx tsc --noEmit; npm run build; python pytest -q; ruff check; py_compile targeted city AI modules Not-tested: Live DeepSeek provider network replay and browser visual QA
This commit is contained in:
+155
@@ -0,0 +1,155 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
buildAiCityErrorForecastState,
|
||||
buildAiCityForecastCacheKey,
|
||||
buildAiCityForecastKey,
|
||||
buildAiCityProgressForecastState,
|
||||
buildAiCityReadyForecastState,
|
||||
readReadyCachedAiForecastState,
|
||||
} from "@/components/dashboard/scan-terminal/ai-city-forecast-stream-state";
|
||||
import { readCachedPayload, writeCachedPayload } from "@/components/dashboard/scan-terminal/scan-terminal-cache";
|
||||
import type { AiCityForecastPayload, AiCityForecastState } from "@/components/dashboard/scan-terminal/types";
|
||||
import type { CityDetail } from "@/lib/dashboard-types";
|
||||
|
||||
function installLocalStorageMock() {
|
||||
const store = new Map<string, string>();
|
||||
const localStorage = {
|
||||
clear: () => store.clear(),
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { localStorage },
|
||||
});
|
||||
return localStorage;
|
||||
}
|
||||
|
||||
function cityDetail(extra: Partial<CityDetail> = {}): CityDetail {
|
||||
return {
|
||||
airport_current: {
|
||||
raw_metar: "METAR TEST 010000Z 34004KT CAVOK 21/10 Q1012",
|
||||
temp: 21,
|
||||
},
|
||||
current: {
|
||||
temp: 21,
|
||||
},
|
||||
local_date: "2026-04-28",
|
||||
metar_status: {
|
||||
last_observation_time: "2026-04-28T00:00:00Z",
|
||||
stale_for_today: false,
|
||||
},
|
||||
name: "Test City",
|
||||
temp_symbol: "°C",
|
||||
...extra,
|
||||
} as CityDetail;
|
||||
}
|
||||
|
||||
function readyPayload(extra: Partial<AiCityForecastPayload> = {}): AiCityForecastPayload {
|
||||
return {
|
||||
city_forecast: {
|
||||
confidence: "medium",
|
||||
final_judgment_en: "Centered near 25°C.",
|
||||
final_judgment_zh: "预计最高温以 25°C 为中枢。",
|
||||
metar_read_en: "Latest METAR supports the path.",
|
||||
metar_read_zh: "最新 METAR 支撑当前路径。",
|
||||
model_cluster_note_en: "Models are clustered.",
|
||||
model_cluster_note_zh: "模型较集中。",
|
||||
predicted_max: 25,
|
||||
range_high: 26,
|
||||
range_low: 24,
|
||||
reasoning_en: "Evidence is aligned.",
|
||||
reasoning_zh: "证据一致。",
|
||||
risks_en: [],
|
||||
risks_zh: [],
|
||||
unit: "°C",
|
||||
},
|
||||
status: "ready",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const storage = installLocalStorageMock();
|
||||
storage.clear();
|
||||
|
||||
const forecastKey = buildAiCityForecastKey({
|
||||
detail: cityDetail(),
|
||||
detailCityName: "Test City",
|
||||
locale: "zh-CN",
|
||||
report: "METAR TEST 010000Z 34004KT CAVOK 21/10 Q1012",
|
||||
});
|
||||
const cacheKey = buildAiCityForecastCacheKey(forecastKey);
|
||||
const payload = readyPayload();
|
||||
writeCachedPayload(cacheKey, payload);
|
||||
|
||||
const cachedReady = readReadyCachedAiForecastState(cacheKey, 0);
|
||||
assert.equal(cachedReady?.status, "ready");
|
||||
assert.equal(cachedReady?.payload?.city_forecast?.predicted_max, 25);
|
||||
|
||||
const degradedCacheKey = `${cacheKey}:degraded`;
|
||||
writeCachedPayload(degradedCacheKey, readyPayload({ degraded: true }));
|
||||
assert.equal(readReadyCachedAiForecastState(degradedCacheKey, 0), null);
|
||||
assert.equal(readCachedPayload(degradedCacheKey, 60 * 60 * 1000), null);
|
||||
|
||||
const currentLoading: AiCityForecastState = {
|
||||
status: "loading",
|
||||
streamText: "已有快速判断",
|
||||
};
|
||||
const callingAiProgress = buildAiCityProgressForecastState({
|
||||
cacheKey: `${cacheKey}:progress`,
|
||||
current: currentLoading,
|
||||
isEn: false,
|
||||
progress: {
|
||||
message_zh: "DeepSeek 正在补充机场报文细节",
|
||||
stage: "calling_ai",
|
||||
},
|
||||
});
|
||||
assert.equal(callingAiProgress?.streamText, "已有快速判断");
|
||||
|
||||
const errorState = buildAiCityErrorForecastState({
|
||||
cacheKey: `${cacheKey}:error`,
|
||||
detail: cityDetail(),
|
||||
error: new Error("timeout"),
|
||||
isEn: false,
|
||||
report: "METAR TEST 010000Z 34004KT CAVOK 21/10 Q1012",
|
||||
});
|
||||
assert.equal(errorState.status, "ready");
|
||||
assert.equal(errorState.payload?.status, "timeout_fallback");
|
||||
assert.match(errorState.payload?.reason_zh || "", /DeepSeek|DEB|METAR/);
|
||||
|
||||
const hkoState = buildAiCityErrorForecastState({
|
||||
cacheKey: `${cacheKey}:hko`,
|
||||
detail: cityDetail({
|
||||
airport_current: null,
|
||||
current: {
|
||||
settlement_source: "hko",
|
||||
temp: 30,
|
||||
},
|
||||
settlement_station: {
|
||||
settlement_source: "hko",
|
||||
},
|
||||
} as unknown as Partial<CityDetail>),
|
||||
error: new Error("timeout"),
|
||||
isEn: false,
|
||||
report: "",
|
||||
});
|
||||
const hkoRead = hkoState.payload?.city_forecast?.metar_read_zh || "";
|
||||
assert.doesNotMatch(hkoRead, /METAR|机场报文/);
|
||||
assert.match(hkoRead, /官方观测|30\.0°C/);
|
||||
|
||||
const degradedReadyState = buildAiCityReadyForecastState({
|
||||
cacheKey: `${cacheKey}:ready-degraded`,
|
||||
detail: cityDetail(),
|
||||
isEn: false,
|
||||
payload: readyPayload({ degraded: true }),
|
||||
report: "METAR TEST 010000Z 34004KT CAVOK 21/10 Q1012",
|
||||
});
|
||||
assert.equal(degradedReadyState.status, "ready");
|
||||
assert.equal(readCachedPayload(`${cacheKey}:ready-degraded`, 60 * 60 * 1000), null);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
buildCityMarketScanCacheKey,
|
||||
deriveCityMarketScanView,
|
||||
resolveCityMarketScanSnapshot,
|
||||
writeCachedCityMarketScan,
|
||||
} from "@/components/dashboard/scan-terminal/market-scan-state";
|
||||
import type { RemoteData } from "@/components/dashboard/scan-terminal/scan-terminal-client";
|
||||
import type { CityDetail, MarketScan } from "@/lib/dashboard-types";
|
||||
|
||||
function installLocalStorageMock() {
|
||||
const store = new Map<string, string>();
|
||||
const localStorage = {
|
||||
clear: () => store.clear(),
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { localStorage },
|
||||
});
|
||||
return localStorage;
|
||||
}
|
||||
|
||||
function marketScan(label = "cached"): MarketScan {
|
||||
return {
|
||||
generated_at: "2026-04-28T00:00:00Z",
|
||||
label,
|
||||
} as unknown as MarketScan;
|
||||
}
|
||||
|
||||
function cityDetail(extra: Partial<CityDetail> = {}): CityDetail {
|
||||
return {
|
||||
local_date: "2026-04-28",
|
||||
name: "Test City",
|
||||
...extra,
|
||||
} as CityDetail;
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const storage = installLocalStorageMock();
|
||||
storage.clear();
|
||||
|
||||
const embeddedScan = marketScan("embedded");
|
||||
const embeddedSnapshot = resolveCityMarketScanSnapshot({
|
||||
detail: cityDetail({ market_scan: embeddedScan }),
|
||||
detailCityName: "Test City",
|
||||
enabled: true,
|
||||
});
|
||||
assert.equal(embeddedSnapshot.action, "success");
|
||||
if (embeddedSnapshot.action === "success") {
|
||||
assert.equal(embeddedSnapshot.payload, embeddedScan);
|
||||
assert.equal(embeddedSnapshot.shouldWriteCache, true);
|
||||
}
|
||||
|
||||
const cacheKey = buildCityMarketScanCacheKey({
|
||||
detailCityName: "Test City",
|
||||
localDate: "2026-04-28",
|
||||
});
|
||||
const cachedScan = marketScan("cached");
|
||||
writeCachedCityMarketScan(cacheKey, cachedScan);
|
||||
const cachedSnapshot = resolveCityMarketScanSnapshot({
|
||||
detail: cityDetail(),
|
||||
detailCityName: "Test City",
|
||||
enabled: false,
|
||||
});
|
||||
assert.equal(cachedSnapshot.action, "success");
|
||||
if (cachedSnapshot.action === "success") {
|
||||
assert.equal((cachedSnapshot.payload as unknown as { label: string }).label, "cached");
|
||||
}
|
||||
|
||||
storage.clear();
|
||||
assert.equal(
|
||||
resolveCityMarketScanSnapshot({
|
||||
detail: cityDetail(),
|
||||
detailCityName: "Test City",
|
||||
enabled: false,
|
||||
}).action,
|
||||
"reset",
|
||||
);
|
||||
assert.equal(
|
||||
resolveCityMarketScanSnapshot({
|
||||
detail: cityDetail(),
|
||||
detailCityName: "Test City",
|
||||
enabled: true,
|
||||
}).action,
|
||||
"fetch",
|
||||
);
|
||||
|
||||
const previous = marketScan("previous");
|
||||
const loadingRemote: RemoteData<MarketScan> = {
|
||||
previous,
|
||||
status: "loading",
|
||||
};
|
||||
const loadingView = deriveCityMarketScanView({
|
||||
detailMarketScan: null,
|
||||
marketRemote: loadingRemote,
|
||||
});
|
||||
assert.equal(loadingView.marketStatus, "loading");
|
||||
assert.equal(loadingView.marketScan, previous);
|
||||
|
||||
const errorWithPrevious = deriveCityMarketScanView({
|
||||
detailMarketScan: null,
|
||||
marketRemote: {
|
||||
error: "network",
|
||||
previous,
|
||||
status: "error",
|
||||
},
|
||||
});
|
||||
assert.equal(errorWithPrevious.marketStatus, "ready");
|
||||
assert.equal(errorWithPrevious.marketScan, previous);
|
||||
|
||||
const errorWithoutPrevious = deriveCityMarketScanView({
|
||||
detailMarketScan: null,
|
||||
marketRemote: {
|
||||
error: "network",
|
||||
status: "error",
|
||||
},
|
||||
});
|
||||
assert.equal(errorWithoutPrevious.marketStatus, "failed");
|
||||
assert.equal(errorWithoutPrevious.marketScan, null);
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import 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 {
|
||||
AiCityForecastPayload,
|
||||
AiCityForecastState,
|
||||
} from "@/components/dashboard/scan-terminal/types";
|
||||
import type { CityDetail } from "@/lib/dashboard-types";
|
||||
import { normalizeCityKey } from "./decision-utils";
|
||||
|
||||
const AI_CITY_FORECAST_CACHE_PREFIX = "polyWeather_aiCityForecast_v6";
|
||||
const AI_CITY_FORECAST_CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
const aiCityForecastStateCache = new Map<
|
||||
string,
|
||||
{ state: AiCityForecastState; updatedAt: number }
|
||||
>();
|
||||
|
||||
function isHkoObservationCity(detail?: CityDetail | null) {
|
||||
const source = String(
|
||||
detail?.current?.settlement_source ||
|
||||
detail?.settlement_station?.settlement_source ||
|
||||
"",
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return source === "hko";
|
||||
}
|
||||
|
||||
export function buildAiCityForecastKey({
|
||||
detail,
|
||||
detailCityName,
|
||||
locale,
|
||||
report,
|
||||
}: {
|
||||
detail: CityDetail | null;
|
||||
detailCityName: string;
|
||||
locale: string;
|
||||
report: string;
|
||||
}) {
|
||||
if (!detail) return "";
|
||||
const isHkoObservation = isHkoObservationCity(detail);
|
||||
const observationSource = isHkoObservation ? "hko" : "metar";
|
||||
const observationCurrent = isHkoObservation
|
||||
? detail.current || {}
|
||||
: detail.airport_current || detail.current || {};
|
||||
const observationSignature =
|
||||
(!isHkoObservation ? String(report || "").trim() : "") ||
|
||||
[
|
||||
observationSource,
|
||||
observationCurrent.report_time,
|
||||
observationCurrent.obs_time_epoch,
|
||||
observationCurrent.obs_time,
|
||||
observationCurrent.receipt_time,
|
||||
observationCurrent.temp,
|
||||
observationCurrent.max_so_far,
|
||||
observationCurrent.station_code,
|
||||
detail.metar_status?.stale_for_today,
|
||||
detail.metar_status?.last_observation_time,
|
||||
]
|
||||
.filter((part) => part != null && part !== "")
|
||||
.join("|");
|
||||
return [
|
||||
normalizeCityKey(detailCityName),
|
||||
detail.local_date || "",
|
||||
locale,
|
||||
observationSignature,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
export function buildAiCityForecastCacheKey(aiForecastKey: string) {
|
||||
return buildStorageKey(AI_CITY_FORECAST_CACHE_PREFIX, [aiForecastKey]);
|
||||
}
|
||||
|
||||
export function buildAiCityForecastRequestKey(cacheKey: string, refreshToken: number) {
|
||||
return `${cacheKey}:${refreshToken > 0 ? `refresh:${refreshToken}` : "normal"}`;
|
||||
}
|
||||
|
||||
export function readCachedAiForecastState(key: string) {
|
||||
const cached = aiCityForecastStateCache.get(key);
|
||||
if (!cached) return null;
|
||||
if (Date.now() - cached.updatedAt > AI_CITY_FORECAST_CACHE_TTL_MS) {
|
||||
aiCityForecastStateCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return cached.state;
|
||||
}
|
||||
|
||||
export function writeCachedAiForecastState(
|
||||
key: string,
|
||||
state: AiCityForecastState,
|
||||
) {
|
||||
if (!key || state.status === "idle") return;
|
||||
aiCityForecastStateCache.set(key, {
|
||||
state,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export function readReadyCachedAiForecastState(cacheKey: string, refreshToken: number) {
|
||||
if (refreshToken > 0) return null;
|
||||
const cachedPayload = readCachedPayload<AiCityForecastPayload>(
|
||||
cacheKey,
|
||||
AI_CITY_FORECAST_CACHE_TTL_MS,
|
||||
);
|
||||
if (cachedPayload) {
|
||||
if (
|
||||
cachedPayload.status === "ready" &&
|
||||
!cachedPayload.degraded &&
|
||||
cachedPayload.city_forecast
|
||||
) {
|
||||
const readyState: AiCityForecastState = {
|
||||
payload: cachedPayload,
|
||||
status: "ready",
|
||||
};
|
||||
writeCachedAiForecastState(cacheKey, readyState);
|
||||
return readyState;
|
||||
}
|
||||
removeCachedPayload(cacheKey);
|
||||
}
|
||||
const cachedState = readCachedAiForecastState(cacheKey);
|
||||
return cachedState?.status === "ready" ? cachedState : null;
|
||||
}
|
||||
|
||||
function getAiCityStreamProgressText(
|
||||
progress: AiCityStreamProgress,
|
||||
isEn: boolean,
|
||||
) {
|
||||
const localizedMessage = String(
|
||||
(isEn ? progress.message_en : progress.message_zh) ||
|
||||
(isEn ? progress.final_judgment_en : progress.final_judgment_zh) ||
|
||||
(isEn ? progress.metar_read_en : progress.metar_read_zh) ||
|
||||
"",
|
||||
).trim();
|
||||
if (localizedMessage) return localizedMessage;
|
||||
const rawLength = Number(progress.raw_length);
|
||||
if (Number.isFinite(rawLength) && rawLength > 0) {
|
||||
return isEn
|
||||
? `DeepSeek is streaming the observation enhancement... ${Math.round(rawLength)} chars received.`
|
||||
: `DeepSeek 正在流式增强观测解读... 已收到 ${Math.round(rawLength)} 字符。`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function buildAiCityFallbackPayload({
|
||||
detail,
|
||||
error,
|
||||
isEn,
|
||||
report,
|
||||
}: {
|
||||
detail: CityDetail | null;
|
||||
error?: unknown;
|
||||
isEn: boolean;
|
||||
report: string;
|
||||
}): AiCityForecastPayload {
|
||||
const tempSymbol = detail?.temp_symbol || "°C";
|
||||
const isHkoObservation = isHkoObservationCity(detail);
|
||||
const currentTemp =
|
||||
(isHkoObservation
|
||||
? detail?.current?.temp
|
||||
: detail?.airport_current?.temp ??
|
||||
detail?.airport_primary?.temp ??
|
||||
detail?.current?.temp) ?? null;
|
||||
const currentText =
|
||||
currentTemp != null && Number.isFinite(Number(currentTemp))
|
||||
? `${Number(currentTemp).toFixed(1)}${tempSymbol}`
|
||||
: isEn
|
||||
? "the latest observed temperature"
|
||||
: "最新实测温度";
|
||||
const timeoutLike = /timeout|timed out|504|aborted|超时/i.test(String(error || ""));
|
||||
const rawMetar = isHkoObservation
|
||||
? ""
|
||||
: String(report || detail?.airport_current?.raw_metar || detail?.current?.raw_metar || "").trim();
|
||||
const sourceZh = isHkoObservation ? "香港天文台观测" : "METAR";
|
||||
const sourceEn = isHkoObservation ? "Hong Kong Observatory observation" : "METAR";
|
||||
const bulletinZh = isHkoObservation ? "官方观测" : "机场报文";
|
||||
const bulletinEn = isHkoObservation ? "official observation" : "airport bulletin";
|
||||
|
||||
const finalZh = timeoutLike
|
||||
? `DeepSeek 增强暂未返回;当前先以多模型集中度和最新${sourceZh}快速判断。`
|
||||
: `当前先以多模型集中度和最新${sourceZh}快速判断。`;
|
||||
const finalEn = timeoutLike
|
||||
? `DeepSeek enhancement is not back yet; use the model cluster and latest ${sourceEn} as the fast working read.`
|
||||
: `Use the model cluster and latest ${sourceEn} as the fast working read.`;
|
||||
const metarZh = rawMetar
|
||||
? `最新 METAR 显示 ${currentText};当前先作为实况锚点,并结合后续报文确认温度路径。`
|
||||
: `当前可先参考 ${currentText} 与多模型路径,等待下一次${bulletinZh}更新。`;
|
||||
const metarEn = rawMetar
|
||||
? `Latest METAR shows ${currentText}; use it as the live anchor while later reports confirm the path.`
|
||||
: `Use ${currentText} and the model path for now while waiting for the next ${bulletinEn}.`;
|
||||
const reasonZh = `DEB、多模型集合和最新${sourceZh}已足够给出当前方向判断;页面会在 DeepSeek 返回后合并完整机场报文解读。`;
|
||||
const reasonEn = `DEB, the model cluster and latest ${sourceEn} are enough for the current directional read; the page will merge the full airport-bulletin read when DeepSeek returns.`;
|
||||
|
||||
return {
|
||||
city_forecast: {
|
||||
confidence: "low",
|
||||
final_judgment_en: finalEn,
|
||||
final_judgment_zh: finalZh,
|
||||
metar_read_en: metarEn,
|
||||
metar_read_zh: metarZh,
|
||||
model_cluster_note_en: "",
|
||||
model_cluster_note_zh: "",
|
||||
predicted_max: null,
|
||||
range_high: null,
|
||||
range_low: null,
|
||||
reasoning_en: reasonEn,
|
||||
reasoning_zh: reasonZh,
|
||||
risks_en: [],
|
||||
risks_zh: [],
|
||||
unit: tempSymbol,
|
||||
},
|
||||
raw_reason: timeoutLike ? "ai_timeout_fallback" : "ai_unavailable_fallback",
|
||||
reason: isEn ? reasonEn : reasonZh,
|
||||
reason_en: reasonEn,
|
||||
reason_zh: reasonZh,
|
||||
status: timeoutLike ? "timeout_fallback" : "fallback",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAiCityLoadingForecastState({
|
||||
cacheKey,
|
||||
detail,
|
||||
isEn,
|
||||
report,
|
||||
}: {
|
||||
cacheKey: string;
|
||||
detail: CityDetail | null;
|
||||
isEn: boolean;
|
||||
report: string;
|
||||
}) {
|
||||
const cachedState = readCachedAiForecastState(cacheKey);
|
||||
const initialFallback = buildAiCityFallbackPayload({ detail, isEn, report });
|
||||
const loadingState: AiCityForecastState =
|
||||
cachedState?.status === "loading"
|
||||
? cachedState
|
||||
: {
|
||||
status: "loading",
|
||||
streamText:
|
||||
(isEn
|
||||
? initialFallback.city_forecast?.metar_read_en
|
||||
: initialFallback.city_forecast?.metar_read_zh) ||
|
||||
(isEn
|
||||
? "Reading the latest observation with model fallback ready..."
|
||||
: "已先用最新观测给出兜底解读,正在等待 DeepSeek 补充…"),
|
||||
};
|
||||
writeCachedAiForecastState(cacheKey, loadingState);
|
||||
return loadingState;
|
||||
}
|
||||
|
||||
export function buildAiCityProgressForecastState({
|
||||
cacheKey,
|
||||
current,
|
||||
isEn,
|
||||
progress,
|
||||
}: {
|
||||
cacheKey: string;
|
||||
current: AiCityForecastState;
|
||||
isEn: boolean;
|
||||
progress: AiCityStreamProgress;
|
||||
}) {
|
||||
const progressText = getAiCityStreamProgressText(progress, isEn);
|
||||
if (!progressText) return null;
|
||||
const cachedProgressState = readCachedAiForecastState(cacheKey);
|
||||
const nextStreamText =
|
||||
progress.stage === "calling_ai" && cachedProgressState?.streamText
|
||||
? cachedProgressState.streamText
|
||||
: progressText;
|
||||
const cachedNextState: AiCityForecastState = {
|
||||
...cachedProgressState,
|
||||
status: "loading",
|
||||
streamText: nextStreamText,
|
||||
};
|
||||
writeCachedAiForecastState(cacheKey, cachedNextState);
|
||||
return {
|
||||
...current,
|
||||
status: "loading",
|
||||
streamText:
|
||||
progress.stage === "calling_ai" && current.streamText
|
||||
? current.streamText
|
||||
: progressText,
|
||||
} satisfies AiCityForecastState;
|
||||
}
|
||||
|
||||
export function buildAiCityReadyForecastState({
|
||||
cacheKey,
|
||||
detail,
|
||||
isEn,
|
||||
payload,
|
||||
report,
|
||||
}: {
|
||||
cacheKey: string;
|
||||
detail: CityDetail | null;
|
||||
isEn: boolean;
|
||||
payload: AiCityForecastPayload;
|
||||
report: string;
|
||||
}) {
|
||||
const usablePayload =
|
||||
payload?.city_forecast
|
||||
? payload
|
||||
: buildAiCityFallbackPayload({
|
||||
detail,
|
||||
error: payload?.reason || payload?.raw_reason || payload?.status,
|
||||
isEn,
|
||||
report,
|
||||
});
|
||||
if (usablePayload.status === "ready" && !usablePayload.degraded) {
|
||||
writeCachedPayload(cacheKey, usablePayload);
|
||||
}
|
||||
const readyState: AiCityForecastState = {
|
||||
payload: usablePayload,
|
||||
status: "ready",
|
||||
};
|
||||
writeCachedAiForecastState(cacheKey, readyState);
|
||||
return readyState;
|
||||
}
|
||||
|
||||
export function buildAiCityErrorForecastState({
|
||||
cacheKey,
|
||||
detail,
|
||||
error,
|
||||
isEn,
|
||||
report,
|
||||
}: {
|
||||
cacheKey: string;
|
||||
detail: CityDetail | null;
|
||||
error: unknown;
|
||||
isEn: boolean;
|
||||
report: string;
|
||||
}) {
|
||||
const fallbackPayload = buildAiCityFallbackPayload({
|
||||
detail,
|
||||
error,
|
||||
isEn,
|
||||
report,
|
||||
});
|
||||
const readyState: AiCityForecastState = {
|
||||
payload: fallbackPayload,
|
||||
status: "ready",
|
||||
};
|
||||
writeCachedAiForecastState(cacheKey, readyState);
|
||||
return readyState;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
buildStorageKey,
|
||||
readCachedPayload,
|
||||
writeCachedPayload,
|
||||
} from "@/components/dashboard/scan-terminal/scan-terminal-cache";
|
||||
import type { RemoteData } from "@/components/dashboard/scan-terminal/scan-terminal-client";
|
||||
import type { CityDetail, MarketScan } from "@/lib/dashboard-types";
|
||||
import { normalizeCityKey } from "./decision-utils";
|
||||
|
||||
const CITY_MARKET_SCAN_CACHE_PREFIX = "polyWeather_cityMarketScan_v3";
|
||||
const CITY_MARKET_SCAN_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
export type CityMarketScanStatus = "idle" | "loading" | "ready" | "failed";
|
||||
|
||||
export type CityMarketScanSnapshot =
|
||||
| { action: "reset"; cacheKey?: string }
|
||||
| { action: "success"; cacheKey: string; payload: MarketScan; shouldWriteCache?: boolean }
|
||||
| { action: "fetch"; cacheKey: string };
|
||||
|
||||
export function buildCityMarketScanCacheKey({
|
||||
detailCityName,
|
||||
localDate,
|
||||
}: {
|
||||
detailCityName: string;
|
||||
localDate?: string | null;
|
||||
}) {
|
||||
return buildStorageKey(CITY_MARKET_SCAN_CACHE_PREFIX, [
|
||||
normalizeCityKey(detailCityName),
|
||||
localDate || "",
|
||||
"full",
|
||||
]);
|
||||
}
|
||||
|
||||
export function readCachedCityMarketScan(cacheKey: string) {
|
||||
return readCachedPayload<MarketScan>(cacheKey, CITY_MARKET_SCAN_CACHE_TTL_MS);
|
||||
}
|
||||
|
||||
export function writeCachedCityMarketScan(cacheKey: string, payload: MarketScan) {
|
||||
writeCachedPayload(cacheKey, payload);
|
||||
}
|
||||
|
||||
export function resolveCityMarketScanSnapshot({
|
||||
detail,
|
||||
detailCityName,
|
||||
enabled,
|
||||
}: {
|
||||
detail: CityDetail | null;
|
||||
detailCityName: string;
|
||||
enabled: boolean;
|
||||
}): CityMarketScanSnapshot {
|
||||
if (!detail) return { action: "reset" };
|
||||
const cacheKey = buildCityMarketScanCacheKey({
|
||||
detailCityName,
|
||||
localDate: detail.local_date || "",
|
||||
});
|
||||
if (detail.market_scan) {
|
||||
return {
|
||||
action: "success",
|
||||
cacheKey,
|
||||
payload: detail.market_scan,
|
||||
shouldWriteCache: true,
|
||||
};
|
||||
}
|
||||
const cached = readCachedCityMarketScan(cacheKey);
|
||||
if (cached) {
|
||||
return {
|
||||
action: "success",
|
||||
cacheKey,
|
||||
payload: cached,
|
||||
};
|
||||
}
|
||||
return enabled ? { action: "fetch", cacheKey } : { action: "reset", cacheKey };
|
||||
}
|
||||
|
||||
export function deriveCityMarketScanView({
|
||||
detailMarketScan,
|
||||
marketRemote,
|
||||
}: {
|
||||
detailMarketScan?: MarketScan | null;
|
||||
marketRemote: RemoteData<MarketScan>;
|
||||
}) {
|
||||
const previousMarketScan =
|
||||
marketRemote.status === "loading" || marketRemote.status === "error"
|
||||
? marketRemote.previous ?? null
|
||||
: null;
|
||||
const marketScan =
|
||||
marketRemote.status === "success"
|
||||
? marketRemote.data
|
||||
: previousMarketScan ?? detailMarketScan ?? null;
|
||||
const marketStatus: CityMarketScanStatus =
|
||||
marketRemote.status === "success"
|
||||
? "ready"
|
||||
: marketRemote.status === "loading"
|
||||
? "loading"
|
||||
: marketRemote.status === "error"
|
||||
? marketScan
|
||||
? "ready"
|
||||
: "failed"
|
||||
: "idle";
|
||||
|
||||
return { marketScan, marketStatus };
|
||||
}
|
||||
@@ -29,6 +29,11 @@ export type AiCityStreamProgress = {
|
||||
raw_length?: number | null;
|
||||
};
|
||||
|
||||
export const scanTerminalQueryPolicy = {
|
||||
autoRefreshMs: 10 * 60_000,
|
||||
manualForceRefreshCooldownMs: 2 * 60_000,
|
||||
} as const;
|
||||
|
||||
type AiCityStreamEvent = {
|
||||
data: Record<string, unknown>;
|
||||
event: string;
|
||||
@@ -100,6 +105,32 @@ export function toRemoteError<T>(
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldSkipManualTerminalRefresh({
|
||||
hasCurrentData,
|
||||
lastForcedRefreshAt,
|
||||
now = Date.now(),
|
||||
}: {
|
||||
hasCurrentData: boolean;
|
||||
lastForcedRefreshAt: number;
|
||||
now?: number;
|
||||
}) {
|
||||
return (
|
||||
hasCurrentData &&
|
||||
lastForcedRefreshAt > 0 &&
|
||||
now - lastForcedRefreshAt < scanTerminalQueryPolicy.manualForceRefreshCooldownMs
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldRunAutoTerminalRefresh({
|
||||
documentHidden,
|
||||
isLoading,
|
||||
}: {
|
||||
documentHidden: boolean;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
return !documentHidden && !isLoading;
|
||||
}
|
||||
|
||||
async function readJsonOrThrow<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetchBackendApi(path, init);
|
||||
if (response.ok) return response.json() as Promise<T>;
|
||||
|
||||
@@ -1,457 +1,4 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type {
|
||||
AiCityForecastPayload,
|
||||
AiCityForecastState,
|
||||
} from "@/components/dashboard/scan-terminal/types";
|
||||
import {
|
||||
scanTerminalClient,
|
||||
type AiCityStreamProgress,
|
||||
type RemoteData,
|
||||
toRemoteError,
|
||||
toRemoteLoading,
|
||||
toRemoteSuccess,
|
||||
} 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";
|
||||
|
||||
const AI_CITY_FORECAST_CACHE_PREFIX = "polyWeather_aiCityForecast_v6";
|
||||
const AI_CITY_FORECAST_CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
const CITY_MARKET_SCAN_CACHE_PREFIX = "polyWeather_cityMarketScan_v3";
|
||||
const CITY_MARKET_SCAN_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
type CityMarketScanStatus = "idle" | "loading" | "ready" | "failed";
|
||||
const aiCityForecastStateCache = new Map<
|
||||
string,
|
||||
{ state: AiCityForecastState; updatedAt: number }
|
||||
>();
|
||||
|
||||
function isHkoObservationCity(detail?: CityDetail | null) {
|
||||
const source = String(
|
||||
detail?.current?.settlement_source ||
|
||||
detail?.settlement_station?.settlement_source ||
|
||||
"",
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return source === "hko";
|
||||
}
|
||||
|
||||
function readCachedAiForecastState(key: string, ttlMs: number) {
|
||||
const cached = aiCityForecastStateCache.get(key);
|
||||
if (!cached) return null;
|
||||
if (Date.now() - cached.updatedAt > ttlMs) {
|
||||
aiCityForecastStateCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return cached.state;
|
||||
}
|
||||
|
||||
function writeCachedAiForecastState(key: string, state: AiCityForecastState) {
|
||||
if (!key || state.status === "idle") return;
|
||||
aiCityForecastStateCache.set(key, {
|
||||
state,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
function getAiCityStreamProgressText(progress: AiCityStreamProgress, isEn: boolean) {
|
||||
const localizedMessage = String(
|
||||
(isEn ? progress.message_en : progress.message_zh) ||
|
||||
(isEn ? progress.final_judgment_en : progress.final_judgment_zh) ||
|
||||
(isEn ? progress.metar_read_en : progress.metar_read_zh) ||
|
||||
"",
|
||||
).trim();
|
||||
if (localizedMessage) return localizedMessage;
|
||||
const rawLength = Number(progress.raw_length);
|
||||
if (Number.isFinite(rawLength) && rawLength > 0) {
|
||||
return isEn
|
||||
? `DeepSeek is streaming the observation enhancement... ${Math.round(rawLength)} chars received.`
|
||||
: `DeepSeek 正在流式增强观测解读... 已收到 ${Math.round(rawLength)} 字符。`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function buildAiCityFallbackPayload({
|
||||
detail,
|
||||
error,
|
||||
isEn,
|
||||
report,
|
||||
}: {
|
||||
detail: CityDetail | null;
|
||||
error?: unknown;
|
||||
isEn: boolean;
|
||||
report: string;
|
||||
}): AiCityForecastPayload {
|
||||
const tempSymbol = detail?.temp_symbol || "°C";
|
||||
const isHkoObservation = isHkoObservationCity(detail);
|
||||
const currentTemp =
|
||||
(isHkoObservation
|
||||
? detail?.current?.temp
|
||||
: detail?.airport_current?.temp ??
|
||||
detail?.airport_primary?.temp ??
|
||||
detail?.current?.temp) ?? null;
|
||||
const currentText =
|
||||
currentTemp != null && Number.isFinite(Number(currentTemp))
|
||||
? `${Number(currentTemp).toFixed(1)}${tempSymbol}`
|
||||
: isEn
|
||||
? "the latest observed temperature"
|
||||
: "最新实测温度";
|
||||
const timeoutLike = /timeout|timed out|504|aborted|超时/i.test(String(error || ""));
|
||||
const rawMetar = isHkoObservation
|
||||
? ""
|
||||
: String(report || detail?.airport_current?.raw_metar || detail?.current?.raw_metar || "").trim();
|
||||
const sourceZh = isHkoObservation ? "香港天文台观测" : "METAR";
|
||||
const sourceEn = isHkoObservation ? "Hong Kong Observatory observation" : "METAR";
|
||||
const bulletinZh = isHkoObservation ? "官方观测" : "机场报文";
|
||||
const bulletinEn = isHkoObservation ? "official observation" : "airport bulletin";
|
||||
|
||||
const finalZh = timeoutLike
|
||||
? `DeepSeek 增强暂未返回;当前先以多模型集中度和最新${sourceZh}快速判断。`
|
||||
: `当前先以多模型集中度和最新${sourceZh}快速判断。`;
|
||||
const finalEn = timeoutLike
|
||||
? `DeepSeek enhancement is not back yet; use the model cluster and latest ${sourceEn} as the fast working read.`
|
||||
: `Use the model cluster and latest ${sourceEn} as the fast working read.`;
|
||||
const metarZh = rawMetar
|
||||
? `最新 METAR 显示 ${currentText};当前先作为实况锚点,并结合后续报文确认温度路径。`
|
||||
: `当前可先参考 ${currentText} 与多模型路径,等待下一次${bulletinZh}更新。`;
|
||||
const metarEn = rawMetar
|
||||
? `Latest METAR shows ${currentText}; use it as the live anchor while later reports confirm the path.`
|
||||
: `Use ${currentText} and the model path for now while waiting for the next ${bulletinEn}.`;
|
||||
const reasonZh = `DEB、多模型集合和最新${sourceZh}已足够给出当前方向判断;页面会在 DeepSeek 返回后合并完整机场报文解读。`;
|
||||
const reasonEn = `DEB, the model cluster and latest ${sourceEn} are enough for the current directional read; the page will merge the full airport-bulletin read when DeepSeek returns.`;
|
||||
|
||||
return {
|
||||
city_forecast: {
|
||||
confidence: "low",
|
||||
final_judgment_en: finalEn,
|
||||
final_judgment_zh: finalZh,
|
||||
metar_read_en: metarEn,
|
||||
metar_read_zh: metarZh,
|
||||
model_cluster_note_en: "",
|
||||
model_cluster_note_zh: "",
|
||||
predicted_max: null,
|
||||
range_high: null,
|
||||
range_low: null,
|
||||
reasoning_en: reasonEn,
|
||||
reasoning_zh: reasonZh,
|
||||
risks_en: [],
|
||||
risks_zh: [],
|
||||
unit: tempSymbol,
|
||||
},
|
||||
raw_reason: timeoutLike ? "ai_timeout_fallback" : "ai_unavailable_fallback",
|
||||
reason: isEn ? reasonEn : reasonZh,
|
||||
reason_en: reasonEn,
|
||||
reason_zh: reasonZh,
|
||||
status: timeoutLike ? "timeout_fallback" : "fallback",
|
||||
};
|
||||
}
|
||||
|
||||
export function useAiCityForecast({
|
||||
detail,
|
||||
detailCityName,
|
||||
isEn,
|
||||
locale,
|
||||
report,
|
||||
enabled = true,
|
||||
}: {
|
||||
detail: CityDetail | null;
|
||||
detailCityName: string;
|
||||
enabled?: boolean;
|
||||
isEn: boolean;
|
||||
locale: string;
|
||||
report: string;
|
||||
}) {
|
||||
const [aiForecast, setAiForecast] = useState<AiCityForecastState>({
|
||||
status: "idle",
|
||||
});
|
||||
const [aiRefreshToken, setAiRefreshToken] = useState(0);
|
||||
const aiForecastKey = useMemo(
|
||||
() => {
|
||||
if (!detail) return "";
|
||||
const isHkoObservation = isHkoObservationCity(detail);
|
||||
const observationSource = isHkoObservation ? "hko" : "metar";
|
||||
const observationCurrent = isHkoObservation
|
||||
? detail.current || {}
|
||||
: detail.airport_current || detail.current || {};
|
||||
const observationSignature =
|
||||
(!isHkoObservation ? String(report || "").trim() : "") ||
|
||||
[
|
||||
observationSource,
|
||||
observationCurrent.report_time,
|
||||
observationCurrent.obs_time_epoch,
|
||||
observationCurrent.obs_time,
|
||||
observationCurrent.receipt_time,
|
||||
observationCurrent.temp,
|
||||
observationCurrent.max_so_far,
|
||||
observationCurrent.station_code,
|
||||
detail.metar_status?.stale_for_today,
|
||||
detail.metar_status?.last_observation_time,
|
||||
]
|
||||
.filter((part) => part != null && part !== "")
|
||||
.join("|");
|
||||
return [
|
||||
normalizeCityKey(detailCityName),
|
||||
detail.local_date || "",
|
||||
locale,
|
||||
observationSignature,
|
||||
].join(":");
|
||||
},
|
||||
[detail, detailCityName, locale, report],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!enabled || !aiForecastKey) {
|
||||
setAiForecast({ status: "idle" });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const cacheKey = buildStorageKey(AI_CITY_FORECAST_CACHE_PREFIX, [aiForecastKey]);
|
||||
const requestKey = `${cacheKey}:${aiRefreshToken > 0 ? `refresh:${aiRefreshToken}` : "normal"}`;
|
||||
const cachedPayload =
|
||||
aiRefreshToken <= 0
|
||||
? readCachedPayload<AiCityForecastPayload>(
|
||||
cacheKey,
|
||||
AI_CITY_FORECAST_CACHE_TTL_MS,
|
||||
)
|
||||
: null;
|
||||
if (cachedPayload) {
|
||||
if (
|
||||
cachedPayload.status === "ready" &&
|
||||
!cachedPayload.degraded &&
|
||||
cachedPayload.city_forecast
|
||||
) {
|
||||
const readyState: AiCityForecastState = {
|
||||
payload: cachedPayload,
|
||||
status: "ready",
|
||||
};
|
||||
writeCachedAiForecastState(cacheKey, readyState);
|
||||
setAiForecast(readyState);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
removeCachedPayload(cacheKey);
|
||||
}
|
||||
const cachedState =
|
||||
aiRefreshToken <= 0
|
||||
? readCachedAiForecastState(cacheKey, AI_CITY_FORECAST_CACHE_TTL_MS)
|
||||
: null;
|
||||
if (cachedState?.status === "ready") {
|
||||
setAiForecast(cachedState);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
const initialFallback = buildAiCityFallbackPayload({ detail, isEn, report });
|
||||
const loadingState: AiCityForecastState =
|
||||
cachedState?.status === "loading"
|
||||
? cachedState
|
||||
: {
|
||||
status: "loading",
|
||||
streamText:
|
||||
(isEn
|
||||
? initialFallback.city_forecast?.metar_read_en
|
||||
: initialFallback.city_forecast?.metar_read_zh) ||
|
||||
(isEn
|
||||
? "Reading the latest observation with model fallback ready..."
|
||||
: "已先用最新观测给出兜底解读,正在等待 DeepSeek 补充…"),
|
||||
};
|
||||
writeCachedAiForecastState(cacheKey, loadingState);
|
||||
setAiForecast(loadingState);
|
||||
void scanTerminalClient.streamAiCityRead({
|
||||
city: detailCityName,
|
||||
forceRefresh: aiRefreshToken > 0,
|
||||
locale,
|
||||
onProgress: (progress) => {
|
||||
const progressText = getAiCityStreamProgressText(progress, isEn);
|
||||
if (!progressText) return;
|
||||
const cachedProgressState = readCachedAiForecastState(
|
||||
cacheKey,
|
||||
AI_CITY_FORECAST_CACHE_TTL_MS,
|
||||
);
|
||||
const nextStreamText =
|
||||
progress.stage === "calling_ai" && cachedProgressState?.streamText
|
||||
? cachedProgressState.streamText
|
||||
: progressText;
|
||||
writeCachedAiForecastState(cacheKey, {
|
||||
...cachedProgressState,
|
||||
status: "loading",
|
||||
streamText: nextStreamText,
|
||||
});
|
||||
if (cancelled) return;
|
||||
setAiForecast((current) => ({
|
||||
...current,
|
||||
status: "loading",
|
||||
streamText:
|
||||
progress.stage === "calling_ai" && current.streamText
|
||||
? current.streamText
|
||||
: progressText,
|
||||
}));
|
||||
},
|
||||
requestKey,
|
||||
})
|
||||
.then((payload) => {
|
||||
if (!payload) return;
|
||||
const usablePayload =
|
||||
payload?.city_forecast
|
||||
? payload
|
||||
: buildAiCityFallbackPayload({
|
||||
detail,
|
||||
error: payload?.reason || payload?.raw_reason || payload?.status,
|
||||
isEn,
|
||||
report,
|
||||
});
|
||||
if (usablePayload.status === "ready" && !usablePayload.degraded) {
|
||||
writeCachedPayload(cacheKey, usablePayload);
|
||||
}
|
||||
writeCachedAiForecastState(cacheKey, {
|
||||
payload: usablePayload,
|
||||
status: "ready",
|
||||
});
|
||||
if (!cancelled) {
|
||||
setAiForecast({ payload: usablePayload, status: "ready" });
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const fallbackPayload = buildAiCityFallbackPayload({
|
||||
detail,
|
||||
error,
|
||||
isEn,
|
||||
report,
|
||||
});
|
||||
writeCachedAiForecastState(cacheKey, {
|
||||
payload: fallbackPayload,
|
||||
status: "ready",
|
||||
});
|
||||
if (!cancelled) {
|
||||
setAiForecast({ payload: fallbackPayload, status: "ready" });
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [aiForecastKey, aiRefreshToken, detail, detailCityName, enabled, isEn, locale, report]);
|
||||
|
||||
const refreshAiForecast = useCallback(() => {
|
||||
setAiRefreshToken((current) => current + 1);
|
||||
}, []);
|
||||
|
||||
return { aiForecast, refreshAiForecast };
|
||||
}
|
||||
|
||||
export function useCityMarketScan({
|
||||
detail,
|
||||
detailCityName,
|
||||
enabled = true,
|
||||
}: {
|
||||
detail: CityDetail | null;
|
||||
detailCityName: string;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const [marketRemote, setMarketRemote] = useState<RemoteData<MarketScan>>(
|
||||
detail?.market_scan ? toRemoteSuccess(detail.market_scan) : { status: "idle" },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!detail) {
|
||||
setMarketRemote({ status: "idle" });
|
||||
return;
|
||||
}
|
||||
const cacheKey = buildStorageKey(CITY_MARKET_SCAN_CACHE_PREFIX, [
|
||||
normalizeCityKey(detailCityName),
|
||||
detail.local_date || "",
|
||||
"full",
|
||||
]);
|
||||
let cancelled = false;
|
||||
if (detail.market_scan) {
|
||||
setMarketRemote(toRemoteSuccess(detail.market_scan));
|
||||
writeCachedPayload(cacheKey, detail.market_scan);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
if (!enabled) {
|
||||
const cached = readCachedPayload<MarketScan>(
|
||||
cacheKey,
|
||||
CITY_MARKET_SCAN_CACHE_TTL_MS,
|
||||
);
|
||||
if (cached) {
|
||||
setMarketRemote(toRemoteSuccess(cached));
|
||||
} else {
|
||||
setMarketRemote({ status: "idle" });
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
const cached = readCachedPayload<MarketScan>(
|
||||
cacheKey,
|
||||
CITY_MARKET_SCAN_CACHE_TTL_MS,
|
||||
);
|
||||
if (cached) {
|
||||
setMarketRemote(toRemoteSuccess(cached));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
} else {
|
||||
setMarketRemote((current) => toRemoteLoading(current));
|
||||
}
|
||||
const controller = new AbortController();
|
||||
void scanTerminalClient.getMarketScan(detailCityName, {
|
||||
lite: false,
|
||||
signal: controller.signal,
|
||||
targetDate: detail.local_date || null,
|
||||
})
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
if (payload) {
|
||||
writeCachedPayload(cacheKey, payload);
|
||||
}
|
||||
const nextPayload = payload || detail.market_scan || null;
|
||||
if (nextPayload) {
|
||||
setMarketRemote(toRemoteSuccess(nextPayload));
|
||||
} else {
|
||||
setMarketRemote({ status: "idle" });
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
if (detail.market_scan) {
|
||||
setMarketRemote(toRemoteSuccess(detail.market_scan));
|
||||
} else {
|
||||
setMarketRemote((current) => toRemoteError(error, current));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [detail, detailCityName, enabled]);
|
||||
|
||||
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 };
|
||||
}
|
||||
export { useAiCityForecast } from "./use-ai-city-forecast";
|
||||
export { useCityMarketScan } from "./use-city-market-scan";
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { scanTerminalClient } from "@/components/dashboard/scan-terminal/scan-terminal-client";
|
||||
import type { AiCityForecastState } from "@/components/dashboard/scan-terminal/types";
|
||||
import type { CityDetail } from "@/lib/dashboard-types";
|
||||
import {
|
||||
buildAiCityErrorForecastState,
|
||||
buildAiCityForecastCacheKey,
|
||||
buildAiCityForecastKey,
|
||||
buildAiCityForecastRequestKey,
|
||||
buildAiCityLoadingForecastState,
|
||||
buildAiCityProgressForecastState,
|
||||
buildAiCityReadyForecastState,
|
||||
readReadyCachedAiForecastState,
|
||||
} from "./ai-city-forecast-stream-state";
|
||||
|
||||
export function useAiCityForecast({
|
||||
detail,
|
||||
detailCityName,
|
||||
isEn,
|
||||
locale,
|
||||
report,
|
||||
enabled = true,
|
||||
}: {
|
||||
detail: CityDetail | null;
|
||||
detailCityName: string;
|
||||
enabled?: boolean;
|
||||
isEn: boolean;
|
||||
locale: string;
|
||||
report: string;
|
||||
}) {
|
||||
const [aiForecast, setAiForecast] = useState<AiCityForecastState>({
|
||||
status: "idle",
|
||||
});
|
||||
const [aiRefreshToken, setAiRefreshToken] = useState(0);
|
||||
const aiForecastKey = useMemo(
|
||||
() => buildAiCityForecastKey({ detail, detailCityName, locale, report }),
|
||||
[detail, detailCityName, locale, report],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !aiForecastKey) {
|
||||
setAiForecast({ status: "idle" });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const cacheKey = buildAiCityForecastCacheKey(aiForecastKey);
|
||||
const requestKey = buildAiCityForecastRequestKey(cacheKey, aiRefreshToken);
|
||||
const readyCachedState = readReadyCachedAiForecastState(
|
||||
cacheKey,
|
||||
aiRefreshToken,
|
||||
);
|
||||
if (readyCachedState) {
|
||||
setAiForecast(readyCachedState);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
const loadingState = buildAiCityLoadingForecastState({
|
||||
cacheKey,
|
||||
detail,
|
||||
isEn,
|
||||
report,
|
||||
});
|
||||
setAiForecast(loadingState);
|
||||
void scanTerminalClient.streamAiCityRead({
|
||||
city: detailCityName,
|
||||
forceRefresh: aiRefreshToken > 0,
|
||||
locale,
|
||||
onProgress: (progress) => {
|
||||
if (cancelled) return;
|
||||
setAiForecast((current) =>
|
||||
buildAiCityProgressForecastState({
|
||||
cacheKey,
|
||||
current,
|
||||
isEn,
|
||||
progress,
|
||||
}) ?? current,
|
||||
);
|
||||
},
|
||||
requestKey,
|
||||
})
|
||||
.then((payload) => {
|
||||
if (!payload) return;
|
||||
const readyState = buildAiCityReadyForecastState({
|
||||
cacheKey,
|
||||
detail,
|
||||
isEn,
|
||||
payload,
|
||||
report,
|
||||
});
|
||||
if (!cancelled) {
|
||||
setAiForecast(readyState);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorState = buildAiCityErrorForecastState({
|
||||
cacheKey,
|
||||
detail,
|
||||
error,
|
||||
isEn,
|
||||
report,
|
||||
});
|
||||
if (!cancelled) {
|
||||
setAiForecast(errorState);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
aiForecastKey,
|
||||
aiRefreshToken,
|
||||
detail,
|
||||
detailCityName,
|
||||
enabled,
|
||||
isEn,
|
||||
locale,
|
||||
report,
|
||||
]);
|
||||
|
||||
const refreshAiForecast = useCallback(() => {
|
||||
setAiRefreshToken((current) => current + 1);
|
||||
}, []);
|
||||
|
||||
return { aiForecast, refreshAiForecast };
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { scanTerminalClient } from "@/components/dashboard/scan-terminal/scan-terminal-client";
|
||||
import { useRemoteDataQuery } from "@/components/dashboard/scan-terminal/use-remote-data-query";
|
||||
import type { CityDetail, MarketScan } from "@/lib/dashboard-types";
|
||||
import {
|
||||
deriveCityMarketScanView,
|
||||
resolveCityMarketScanSnapshot,
|
||||
writeCachedCityMarketScan,
|
||||
} from "./market-scan-state";
|
||||
|
||||
export function useCityMarketScan({
|
||||
detail,
|
||||
detailCityName,
|
||||
enabled = true,
|
||||
}: {
|
||||
detail: CityDetail | null;
|
||||
detailCityName: string;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const {
|
||||
remote: marketRemote,
|
||||
reset: resetMarketRemote,
|
||||
run: runMarketScanQuery,
|
||||
setSuccess: setMarketScanSuccess,
|
||||
} = useRemoteDataQuery<MarketScan>();
|
||||
|
||||
useEffect(() => {
|
||||
const snapshot = resolveCityMarketScanSnapshot({
|
||||
detail,
|
||||
detailCityName,
|
||||
enabled,
|
||||
});
|
||||
if (snapshot.action === "reset") {
|
||||
resetMarketRemote();
|
||||
return;
|
||||
}
|
||||
if (snapshot.action === "success") {
|
||||
setMarketScanSuccess(snapshot.payload);
|
||||
if (snapshot.shouldWriteCache) {
|
||||
writeCachedCityMarketScan(snapshot.cacheKey, snapshot.payload);
|
||||
}
|
||||
return;
|
||||
}
|
||||
void runMarketScanQuery({
|
||||
request: (signal) =>
|
||||
scanTerminalClient.getMarketScan(detailCityName, {
|
||||
lite: false,
|
||||
signal,
|
||||
targetDate: detail?.local_date || null,
|
||||
}),
|
||||
showLoading: true,
|
||||
onSuccess: (payload) => {
|
||||
if (payload) {
|
||||
writeCachedCityMarketScan(snapshot.cacheKey, payload);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [
|
||||
detail,
|
||||
detailCityName,
|
||||
enabled,
|
||||
resetMarketRemote,
|
||||
runMarketScanQuery,
|
||||
setMarketScanSuccess,
|
||||
]);
|
||||
|
||||
const { marketScan, marketStatus } = deriveCityMarketScanView({
|
||||
detailMarketScan: detail?.market_scan,
|
||||
marketRemote,
|
||||
});
|
||||
|
||||
return { marketRemote, marketScan, marketStatus };
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
toRemoteError,
|
||||
toRemoteLoading,
|
||||
toRemoteSuccess,
|
||||
type RemoteData,
|
||||
} from "@/components/dashboard/scan-terminal/scan-terminal-client";
|
||||
|
||||
type RunRemoteQueryOptions<T> = {
|
||||
onSuccess?: (data: T) => void;
|
||||
request: (signal: AbortSignal) => Promise<T>;
|
||||
showLoading?: boolean;
|
||||
};
|
||||
|
||||
export function useRemoteDataQuery<T>() {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [remote, setRemote] = useState<RemoteData<T>>({ status: "idle" });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const requestSeqRef = useRef(0);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const abort = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
requestSeqRef.current += 1;
|
||||
abort();
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setData(null);
|
||||
setRemote({ status: "idle" });
|
||||
}, [abort]);
|
||||
|
||||
const setSuccess = useCallback(
|
||||
(nextData: T) => {
|
||||
requestSeqRef.current += 1;
|
||||
abort();
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setData(nextData);
|
||||
setRemote(toRemoteSuccess(nextData));
|
||||
},
|
||||
[abort],
|
||||
);
|
||||
|
||||
const run = useCallback(
|
||||
async ({
|
||||
onSuccess,
|
||||
request,
|
||||
showLoading = false,
|
||||
}: RunRemoteQueryOptions<T>) => {
|
||||
const requestSeq = ++requestSeqRef.current;
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
if (showLoading) {
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
setRemote((current) => toRemoteLoading(current));
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
const payload = await request(controller.signal);
|
||||
if (requestSeq !== requestSeqRef.current) return null;
|
||||
setData(payload);
|
||||
setRemote(toRemoteSuccess(payload));
|
||||
setError(null);
|
||||
onSuccess?.(payload);
|
||||
return payload;
|
||||
} catch (caught) {
|
||||
if (controller.signal.aborted || requestSeq !== requestSeqRef.current) {
|
||||
return null;
|
||||
}
|
||||
const message = caught instanceof Error ? caught.message : String(caught);
|
||||
setError(message);
|
||||
setRemote((current) => toRemoteError(caught, current));
|
||||
return null;
|
||||
} finally {
|
||||
if (abortRef.current === controller) {
|
||||
abortRef.current = null;
|
||||
}
|
||||
if (showLoading) {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const isLoading = useCallback(() => loadingRef.current, []);
|
||||
|
||||
useEffect(() => abort, [abort]);
|
||||
|
||||
return {
|
||||
abort,
|
||||
data,
|
||||
error,
|
||||
isLoading,
|
||||
loading,
|
||||
remote,
|
||||
reset,
|
||||
run,
|
||||
setSuccess,
|
||||
};
|
||||
}
|
||||
@@ -1,18 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import {
|
||||
scanTerminalQueryPolicy,
|
||||
scanTerminalClient,
|
||||
toRemoteError,
|
||||
toRemoteLoading,
|
||||
toRemoteSuccess,
|
||||
type RemoteData,
|
||||
shouldRunAutoTerminalRefresh,
|
||||
shouldSkipManualTerminalRefresh,
|
||||
} from "@/components/dashboard/scan-terminal/scan-terminal-client";
|
||||
import { useRemoteDataQuery } from "@/components/dashboard/scan-terminal/use-remote-data-query";
|
||||
import type { ScanTerminalResponse } from "@/lib/dashboard-types";
|
||||
|
||||
const SCAN_TERMINAL_AUTO_REFRESH_MS = 10 * 60_000;
|
||||
const SCAN_TERMINAL_MANUAL_REFRESH_COOLDOWN_MS = 2 * 60_000;
|
||||
|
||||
export function useScanTerminalQuery({
|
||||
isPro,
|
||||
proAccessLoading,
|
||||
@@ -20,15 +17,15 @@ export function useScanTerminalQuery({
|
||||
isPro: boolean;
|
||||
proAccessLoading: boolean;
|
||||
}) {
|
||||
const [terminalData, setTerminalData] = useState<ScanTerminalResponse | null>(null);
|
||||
const [scanRemote, setScanRemote] = useState<RemoteData<ScanTerminalResponse>>({
|
||||
status: "idle",
|
||||
});
|
||||
const [scanLoading, setScanLoading] = useState(false);
|
||||
const [scanError, setScanError] = useState<string | null>(null);
|
||||
const scanAbortRef = useRef<AbortController | null>(null);
|
||||
const scanRequestSeqRef = useRef(0);
|
||||
const scanLoadingRef = useRef(false);
|
||||
const {
|
||||
data: terminalData,
|
||||
error: scanError,
|
||||
isLoading,
|
||||
loading: scanLoading,
|
||||
remote: scanRemote,
|
||||
reset,
|
||||
run,
|
||||
} = useRemoteDataQuery<ScanTerminalResponse>();
|
||||
const lastForcedScanRefreshAtRef = useRef(0);
|
||||
|
||||
const fetchScanTerminal = useCallback(
|
||||
@@ -40,74 +37,37 @@ export function useScanTerminalQuery({
|
||||
showLoading?: boolean;
|
||||
} = {}) => {
|
||||
if (proAccessLoading || !isPro) return;
|
||||
const requestSeq = ++scanRequestSeqRef.current;
|
||||
scanAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
scanAbortRef.current = controller;
|
||||
if (forceRefresh) {
|
||||
lastForcedScanRefreshAtRef.current = Date.now();
|
||||
}
|
||||
if (showLoading) {
|
||||
scanLoadingRef.current = true;
|
||||
setScanLoading(true);
|
||||
setScanRemote((current) => toRemoteLoading(current));
|
||||
}
|
||||
setScanError(null);
|
||||
try {
|
||||
const payload = await scanTerminalClient.getTerminal({
|
||||
forceRefresh,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (requestSeq !== scanRequestSeqRef.current) return;
|
||||
setTerminalData(payload);
|
||||
setScanRemote(toRemoteSuccess(payload));
|
||||
setScanError(null);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted || requestSeq !== scanRequestSeqRef.current) return;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setScanError(message);
|
||||
setScanRemote((current) => toRemoteError(error, current));
|
||||
} finally {
|
||||
if (scanAbortRef.current === controller) {
|
||||
scanAbortRef.current = null;
|
||||
}
|
||||
if (showLoading) {
|
||||
scanLoadingRef.current = false;
|
||||
setScanLoading(false);
|
||||
}
|
||||
}
|
||||
await run({
|
||||
request: (signal) =>
|
||||
scanTerminalClient.getTerminal({
|
||||
forceRefresh,
|
||||
signal,
|
||||
}),
|
||||
showLoading,
|
||||
});
|
||||
},
|
||||
[isPro, proAccessLoading],
|
||||
[isPro, proAccessLoading, run],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (proAccessLoading) return;
|
||||
if (!isPro) {
|
||||
scanLoadingRef.current = false;
|
||||
setScanLoading(false);
|
||||
setScanError(null);
|
||||
setTerminalData(null);
|
||||
setScanRemote({ status: "idle" });
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
void fetchScanTerminal({ forceRefresh: false, showLoading: true });
|
||||
}, [fetchScanTerminal, isPro, proAccessLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
scanAbortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
}, [fetchScanTerminal, isPro, proAccessLoading, reset]);
|
||||
|
||||
const refreshScanTerminalManually = useCallback(() => {
|
||||
const now = Date.now();
|
||||
const lastForced = lastForcedScanRefreshAtRef.current;
|
||||
const withinCooldown =
|
||||
lastForced > 0 &&
|
||||
now - lastForced < SCAN_TERMINAL_MANUAL_REFRESH_COOLDOWN_MS &&
|
||||
terminalData;
|
||||
if (withinCooldown) {
|
||||
setScanError(null);
|
||||
if (
|
||||
shouldSkipManualTerminalRefresh({
|
||||
hasCurrentData: Boolean(terminalData),
|
||||
lastForcedRefreshAt: lastForcedScanRefreshAtRef.current,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void fetchScanTerminal({ forceRefresh: true, showLoading: true });
|
||||
@@ -116,12 +76,18 @@ export function useScanTerminalQuery({
|
||||
useEffect(() => {
|
||||
if (proAccessLoading || !isPro) return;
|
||||
const intervalId = window.setInterval(() => {
|
||||
if (document.hidden) return;
|
||||
if (scanLoadingRef.current) return;
|
||||
if (
|
||||
!shouldRunAutoTerminalRefresh({
|
||||
documentHidden: document.hidden,
|
||||
isLoading: isLoading(),
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void fetchScanTerminal({ forceRefresh: true, showLoading: false });
|
||||
}, SCAN_TERMINAL_AUTO_REFRESH_MS);
|
||||
}, scanTerminalQueryPolicy.autoRefreshMs);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [fetchScanTerminal, isPro, proAccessLoading]);
|
||||
}, [fetchScanTerminal, isLoading, isPro, proAccessLoading]);
|
||||
|
||||
return {
|
||||
refreshScanTerminalManually,
|
||||
|
||||
Reference in New Issue
Block a user