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" });
}
});