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> {isEn ? "YES" : "YES 买入"} <b>{marketDecisionView.priceText}</b>
</small> </small>
<small> <small>
{isEn ? "Edge" : "概率差"} <b>{marketDecisionView.edgeText}</b> {isEn ? "Model-market" : "模型-市场差"} <b>{marketDecisionView.edgeText}</b>
</small> </small>
</div> </div>
{marketDecisionView.marketUrl ? ( {marketDecisionView.marketUrl ? (
@@ -1,5 +1,9 @@
import type { MarketScan, MarketTopBucket } from "@/lib/dashboard-types"; 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"; import type { AiCityForecastPayload } from "@/components/dashboard/scan-terminal/types";
export type WeatherDecisionView = { export type WeatherDecisionView = {
@@ -126,7 +130,12 @@ export function normalizeMarketComparableTemp(
export function getMarketBucketLabel(bucket?: MarketTopBucket | null, tempSymbol = "°C") { export function getMarketBucketLabel(bucket?: MarketTopBucket | null, tempSymbol = "°C") {
const direct = String(bucket?.label || "").trim(); const direct = String(bucket?.label || "").trim();
if (direct && /[°]?[CF]\b|\d+\s*[+-]?$/i.test(direct) && !/[。紊]/.test(direct)) { 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); const numeric = toFiniteMarketNumber(bucket?.temp ?? bucket?.value ?? bucket?.lower);
if (numeric != null) { if (numeric != null) {
@@ -22,6 +22,10 @@ const pendingAiCityForecastRequests = new Map<
string, string,
Promise<AiCityForecastPayload> Promise<AiCityForecastPayload>
>(); >();
const aiCityForecastStateCache = new Map<
string,
{ state: AiCityForecastState; updatedAt: number }
>();
type AiCityStreamProgress = { type AiCityStreamProgress = {
stage?: string | null; 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 { function parseAiCityStreamBlock(block: string): AiCityStreamEvent | null {
const eventLines = block const eventLines = block
.split(/\r?\n/) .split(/\r?\n/)
@@ -400,32 +422,65 @@ export function useAiCityForecast({
!cachedPayload.degraded && !cachedPayload.degraded &&
cachedPayload.city_forecast cachedPayload.city_forecast
) { ) {
setAiForecast({ payload: cachedPayload, status: "ready" }); const readyState: AiCityForecastState = {
payload: cachedPayload,
status: "ready",
};
writeCachedAiForecastState(cacheKey, readyState);
setAiForecast(readyState);
return () => { return () => {
cancelled = true; cancelled = true;
}; };
} }
removeCachedPayload(cacheKey); 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 initialFallback = buildAiCityFallbackPayload({ detail, isEn, report });
setAiForecast({ const loadingState: AiCityForecastState =
status: "loading", cachedState?.status === "loading"
streamText: ? cachedState
(isEn : {
? initialFallback.city_forecast?.metar_read_en status: "loading",
: initialFallback.city_forecast?.metar_read_zh) || streamText:
(isEn (isEn
? "Reading the latest airport bulletin with model/METAR fallback ready..." ? initialFallback.city_forecast?.metar_read_en
: "已先用最新 METAR 给出兜底解读,正在等待 DeepSeek 补充…"), : 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({ void requestAiCityForecast({
city: detailCityName, city: detailCityName,
forceRefresh: aiRefreshToken > 0, forceRefresh: aiRefreshToken > 0,
locale, locale,
onProgress: (progress) => { onProgress: (progress) => {
if (cancelled) return;
const progressText = getAiCityStreamProgressText(progress, isEn); const progressText = getAiCityStreamProgressText(progress, isEn);
if (!progressText) return; 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) => ({ setAiForecast((current) => ({
...current, ...current,
status: "loading", status: "loading",
@@ -451,18 +506,26 @@ export function useAiCityForecast({
if (usablePayload.status === "ready" && !usablePayload.degraded) { if (usablePayload.status === "ready" && !usablePayload.degraded) {
writeCachedPayload(cacheKey, usablePayload); writeCachedPayload(cacheKey, usablePayload);
} }
writeCachedAiForecastState(cacheKey, {
payload: usablePayload,
status: "ready",
});
if (!cancelled) { if (!cancelled) {
setAiForecast({ payload: usablePayload, status: "ready" }); setAiForecast({ payload: usablePayload, status: "ready" });
} }
}) })
.catch((error) => { .catch((error) => {
const fallbackPayload = buildAiCityFallbackPayload({
detail,
error,
isEn,
report,
});
writeCachedAiForecastState(cacheKey, {
payload: fallbackPayload,
status: "ready",
});
if (!cancelled) { if (!cancelled) {
const fallbackPayload = buildAiCityFallbackPayload({
detail,
error,
isEn,
report,
});
setAiForecast({ payload: fallbackPayload, status: "ready" }); setAiForecast({ payload: fallbackPayload, status: "ready" });
} }
}); });