feat: implement AiPinnedForecastView component with associated data hooks and decision utilities for AI-driven weather analysis

This commit is contained in:
2569718930@qq.com
2026-04-27 01:14:31 +08:00
parent 7e66b026b9
commit 8fe25d06a5
3 changed files with 93 additions and 21 deletions
@@ -375,7 +375,7 @@ function AiPinnedCityCard({
{isEn ? "YES" : "YES 买入"} <b>{marketDecisionView.priceText}</b>
</small>
<small>
{isEn ? "Edge" : "概率差"} <b>{marketDecisionView.edgeText}</b>
{isEn ? "Model-market" : "模型-市场差"} <b>{marketDecisionView.edgeText}</b>
</small>
</div>
{marketDecisionView.marketUrl ? (
@@ -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) {
@@ -22,6 +22,10 @@ const pendingAiCityForecastRequests = new Map<
string,
Promise<AiCityForecastPayload>
>();
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" });
}
});