diff --git a/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx b/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx index b3b39cff..7465841f 100644 --- a/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx +++ b/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx @@ -375,7 +375,7 @@ function AiPinnedCityCard({ {isEn ? "YES" : "YES 买入"} {marketDecisionView.priceText} - {isEn ? "Edge" : "概率差"} {marketDecisionView.edgeText} + {isEn ? "Model-market" : "模型-市场差"} {marketDecisionView.edgeText} {marketDecisionView.marketUrl ? ( diff --git a/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts b/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts index 34583fd1..e10ad212 100644 --- a/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts +++ b/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts @@ -1,5 +1,9 @@ import type { MarketScan, MarketTopBucket } from "@/lib/dashboard-types"; -import { formatTemperatureValue, getTodayPaceView } from "@/lib/dashboard-utils"; +import { + formatTemperatureValue, + getTodayPaceView, + normalizeTemperatureLabel, +} from "@/lib/dashboard-utils"; import type { AiCityForecastPayload } from "@/components/dashboard/scan-terminal/types"; export type WeatherDecisionView = { @@ -126,7 +130,12 @@ export function normalizeMarketComparableTemp( export function getMarketBucketLabel(bucket?: MarketTopBucket | null, tempSymbol = "°C") { const direct = String(bucket?.label || "").trim(); if (direct && /[°]?[CF]\b|\d+\s*[+-]?$/i.test(direct) && !/[�。紊]/.test(direct)) { - return direct.replace(/\bC\b/g, "°C").replace(/\bF\b/g, "°F"); + const labelSymbol = /°?\s*F\b/i.test(direct) + ? "°F" + : /°?\s*C\b/i.test(direct) + ? "°C" + : tempSymbol; + return normalizeTemperatureLabel(direct, labelSymbol).replace(/\s+°([CF])\b/g, "°$1"); } const numeric = toFiniteMarketNumber(bucket?.temp ?? bucket?.value ?? bucket?.lower); if (numeric != null) { 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 7dd6dba8..391accb1 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 @@ -22,6 +22,10 @@ const pendingAiCityForecastRequests = new Map< string, Promise >(); +const aiCityForecastStateCache = new Map< + string, + { state: AiCityForecastState; updatedAt: number } +>(); type AiCityStreamProgress = { stage?: string | null; @@ -92,6 +96,24 @@ function removeCachedPayload(key: string) { } } +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 parseAiCityStreamBlock(block: string): AiCityStreamEvent | null { const eventLines = block .split(/\r?\n/) @@ -400,32 +422,65 @@ export function useAiCityForecast({ !cachedPayload.degraded && cachedPayload.city_forecast ) { - setAiForecast({ payload: cachedPayload, status: "ready" }); + 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 }); - setAiForecast({ - status: "loading", - streamText: - (isEn - ? initialFallback.city_forecast?.metar_read_en - : initialFallback.city_forecast?.metar_read_zh) || - (isEn - ? "Reading the latest airport bulletin with model/METAR fallback ready..." - : "已先用最新 METAR 给出兜底解读,正在等待 DeepSeek 补充…"), - }); + 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 airport bulletin with model/METAR fallback ready..." + : "已先用最新 METAR 给出兜底解读,正在等待 DeepSeek 补充…"), + }; + writeCachedAiForecastState(cacheKey, loadingState); + setAiForecast(loadingState); void requestAiCityForecast({ city: detailCityName, forceRefresh: aiRefreshToken > 0, locale, onProgress: (progress) => { - if (cancelled) return; 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", @@ -451,18 +506,26 @@ export function useAiCityForecast({ 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) { - const fallbackPayload = buildAiCityFallbackPayload({ - detail, - error, - isEn, - report, - }); setAiForecast({ payload: fallbackPayload, status: "ready" }); } });