feat: add AI city forecast terminal and integration components

This commit is contained in:
2569718930@qq.com
2026-04-26 10:43:14 +08:00
parent 312b366a50
commit e374ad4bf0
6 changed files with 155 additions and 34 deletions
+22 -4
View File
@@ -226,7 +226,9 @@ export function DetailPanel({
(detail.forecast?.daily?.length ?? 0) <= 1),
);
const isPanelSyncing = store.loadingState.cityDetail;
const isShowingCachedDetailDuringSync = Boolean(detail && isPanelSyncing);
const [panelSyncTimedOut, setPanelSyncTimedOut] = useState(false);
const showPanelSyncing = isPanelSyncing && !panelSyncTimedOut;
const isShowingCachedDetailDuringSync = false;
const blurActiveElement = () => {
if (typeof document === "undefined") return;
@@ -269,6 +271,22 @@ export function DetailPanel({
void store.openHistory();
};
useEffect(() => {
if (!isPanelSyncing || !store.selectedCity) {
setPanelSyncTimedOut(false);
return;
}
setPanelSyncTimedOut(false);
const timeoutId = window.setTimeout(() => {
setPanelSyncTimedOut(true);
}, 12_000);
return () => {
window.clearTimeout(timeoutId);
};
}, [isPanelSyncing, store.selectedCity]);
useEffect(() => {
const panel = panelRef.current;
if (!panel) return;
@@ -362,7 +380,7 @@ export function DetailPanel({
</div>
<h2>{panelDisplayName}</h2>
</div>
{isPanelSyncing && (
{showPanelSyncing && (
<div
className="panel-loading-hint"
role="status"
@@ -462,14 +480,14 @@ export function DetailPanel({
</div>
</div>
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>
{store.loadingState.cityDetail
{showPanelSyncing
? t("detail.loading")
: t("detail.emptyHint")}
</div>
</section>
) : !detail ? (
<div className="detail-mini-meta" role="status" aria-live="polite">
{store.loadingState.cityDetail
{showPanelSyncing
? locale === "en-US"
? "Loading city cards..."
: "正在加载城市卡片..."
@@ -423,8 +423,8 @@ function AiPinnedCityCard({
</p>
<p className="scan-ai-city-muted">
{isEn
? "Non-streaming mode is enabled to avoid truncated airport reads."
: "已停用流式输出,改用非流式 JSON 请求,避免报文解读被截断。"}
? "The final airport read will appear here shortly."
: "机场报文解读稍后将在这里显示。"}
</p>
</>
) : aiForecast.status === "ready" && aiCityForecast ? (
@@ -57,6 +57,77 @@ function writeCachedPayload<T>(key: string, payload: T) {
}
}
function buildAiCityFallbackPayload({
detail,
error,
isEn,
report,
}: {
detail: CityDetail | null;
error?: unknown;
isEn: boolean;
report: string;
}): AiCityForecastPayload {
const tempSymbol = detail?.temp_symbol || "°C";
const currentTemp =
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 = String(report || detail?.airport_current?.raw_metar || detail?.current?.raw_metar || "").trim();
const finalZh = timeoutLike
? "AI 解读暂未返回;先以多模型集中度和最新 METAR 实况作为判断依据。"
: "AI 解读暂不可用;先以多模型集中度和最新 METAR 实况作为判断依据。";
const finalEn = timeoutLike
? "The AI read is not back yet; use the model cluster and latest METAR as the working read."
: "The AI read is temporarily unavailable; use the model cluster and latest METAR as the working read.";
const metarZh = rawMetar
? `最新 METAR 显示 ${currentText};原始报文已保留,可继续结合后续报文确认温度路径。`
: `当前可先参考 ${currentText} 与多模型路径,等待下一次机场报文更新。`;
const metarEn = rawMetar
? `Latest METAR shows ${currentText}; the raw bulletin is preserved and later reports should confirm the path.`
: `Use ${currentText} and the model path for now while waiting for the next airport bulletin.`;
const reasonZh = timeoutLike
? "AI 服务响应较慢,本次未在页面等待窗口内完成;页面已自动降级为天气证据模式。"
: "AI 服务本次没有返回可用解读;页面已自动降级为天气证据模式。";
const reasonEn = timeoutLike
? "The AI service was slow and did not finish within the page wait window; the card fell back to weather evidence mode."
: "The AI service did not return a usable read; the card fell back to weather evidence mode.";
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,
@@ -156,21 +227,37 @@ export function useAiCityForecast({
})
.then((payload) => {
if (!cancelled) {
writeCachedPayload(cacheKey, payload);
setAiForecast({ payload, status: "ready" });
const usablePayload =
payload?.city_forecast
? payload
: buildAiCityFallbackPayload({
detail,
error: payload?.reason || payload?.raw_reason || payload?.status,
isEn,
report,
});
writeCachedPayload(cacheKey, usablePayload);
setAiForecast({ payload: usablePayload, status: "ready" });
}
})
.catch((error) => {
if (controller.signal.aborted) return;
if (!cancelled) {
setAiForecast({ error: String(error), status: "failed" });
const fallbackPayload = buildAiCityFallbackPayload({
detail,
error,
isEn,
report,
});
writeCachedPayload(cacheKey, fallbackPayload);
setAiForecast({ payload: fallbackPayload, status: "ready" });
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [aiForecastKey, aiRefreshToken, detailCityName, enabled, isEn, locale]);
}, [aiForecastKey, aiRefreshToken, detail, detailCityName, enabled, isEn, locale, report]);
const refreshAiForecast = useCallback(() => {
setAiRefreshToken((current) => current + 1);