From 1c644a59c659deec62487cfaa374c7d3729a2103 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sun, 26 Apr 2026 08:19:00 +0800 Subject: [PATCH] feat: implement ScanTerminalDashboard component with AI streaming forecast and queue management --- .../components/dashboard/Dashboard.module.css | 45 +++- .../dashboard/ScanTerminalDashboard.tsx | 208 ++++++++++++++++-- 2 files changed, 230 insertions(+), 23 deletions(-) diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index d9096e4c..6159e3fe 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -11183,6 +11183,14 @@ border-color: rgba(34, 197, 94, 0.34); } +.root :global(.scan-ai-decision-band.watch) { + border-color: rgba(245, 158, 11, 0.38); +} + +.root :global(.scan-ai-decision-main) { + min-width: 0; +} + .root :global(.scan-ai-decision-band span) { color: #4da3ff; font-size: 12px; @@ -11203,6 +11211,40 @@ line-height: 1.55; } +.root :global(.scan-ai-decision-reasons) { + display: grid; + gap: 8px; + margin-top: 14px; +} + +.root :global(.scan-ai-decision-reasons small) { + position: relative; + display: block; + padding-left: 16px; + color: #c9d7e8; + font-size: 12px; + font-weight: 760; + line-height: 1.45; +} + +.root :global(.scan-ai-decision-reasons small::before) { + position: absolute; + top: 0.58em; + left: 0; + width: 6px; + height: 6px; + border-radius: 999px; + background: #4da3ff; + content: ""; +} + +.root :global(.scan-ai-decision-risk) { + margin-top: 12px !important; + color: #fbbf24 !important; + font-size: 12px; + font-weight: 800; +} + .root :global(.scan-ai-decision-metrics) { min-width: 360px; display: grid; @@ -13136,7 +13178,8 @@ .root :global(.scan-terminal.light .scan-ai-decision-band strong), .root :global(.scan-terminal.light .scan-ai-decision-metrics b), .root :global(.scan-terminal.light .scan-ai-market-bucket strong), -.root :global(.scan-terminal.light .scan-ai-weather-summary) { +.root :global(.scan-terminal.light .scan-ai-weather-summary), +.root :global(.scan-terminal.light .scan-ai-decision-reasons small) { color: #0f172a; } diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 8c33cd42..736823ff 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -619,6 +619,150 @@ function waitForDeepAnalysisQueue(ms: number) { return new Promise((resolve) => window.setTimeout(resolve, ms)); } +type WeatherDecisionView = { + action: string; + confidence: string; + expectedHigh: string; + kicker: string; + reasons: string[]; + risk: string; + targetRange: string; + tone: "cold" | "neutral" | "warm" | "watch"; +}; + +function buildWeatherDecisionView({ + aiCityForecast, + currentTemp, + deb, + isEn, + localModelSupportNote, + modelEntries, + modelMax, + modelMin, + paceTone, + paceView, + peakWindow, + tempSymbol, +}: { + aiCityForecast: AiCityForecastPayload["city_forecast"] | null; + currentTemp: number | null; + deb: number | null; + isEn: boolean; + localModelSupportNote: string; + modelEntries: Array; + modelMax: number | null; + modelMin: number | null; + paceTone: string; + paceView: ReturnType | null; + peakWindow: string; + tempSymbol: string; +}): WeatherDecisionView { + const aiPredicted = Number(aiCityForecast?.predicted_max); + const center = Number.isFinite(aiPredicted) + ? aiPredicted + : paceView?.paceAdjustedHigh != null + ? paceView.paceAdjustedHigh + : deb; + const aiLow = Number(aiCityForecast?.range_low); + const aiHigh = Number(aiCityForecast?.range_high); + const low = Number.isFinite(aiLow) + ? aiLow + : modelMin != null + ? modelMin + : center != null + ? center - 1 + : null; + const high = Number.isFinite(aiHigh) + ? aiHigh + : modelMax != null + ? modelMax + : center != null + ? center + 1 + : null; + const spread = modelMax != null && modelMin != null ? modelMax - modelMin : null; + const modelCount = modelEntries.length; + const aiConfidence = String(aiCityForecast?.confidence || "").trim(); + const confidence = + aiConfidence || + (modelCount >= 4 && spread != null && spread <= 2 + ? isEn + ? "High" + : "高" + : modelCount >= 2 + ? isEn + ? "Medium" + : "中" + : isEn + ? "Low" + : "低"); + const tone = + modelCount <= 1 + ? "watch" + : paceTone === "warm" || paceTone === "cold" || paceTone === "neutral" + ? paceTone + : "neutral"; + const action = + modelCount <= 1 + ? isEn + ? "Wait for model cluster" + : "等待模型补齐" + : paceTone === "warm" + ? isEn + ? "Watch hotter range" + : "关注偏高温区间" + : paceTone === "cold" + ? isEn + ? "Avoid chasing high" + : "暂不追高温" + : isEn + ? "Wait for peak-window confirmation" + : "等待峰值窗口确认"; + const expectedHigh = + center != null && Number.isFinite(Number(center)) + ? formatTemperatureValue(Number(center), tempSymbol, { digits: 1 }) + : "--"; + const targetRange = + low != null && high != null && Number.isFinite(Number(low)) && Number.isFinite(Number(high)) + ? `${formatTemperatureValue(Number(low), tempSymbol, { digits: 1 })} ~ ${formatTemperatureValue(Number(high), tempSymbol, { digits: 1 })}` + : expectedHigh; + const reasons = [ + localModelSupportNote, + paceView?.summary || "", + currentTemp != null + ? isEn + ? `Latest observed anchor is ${formatTemperatureValue(currentTemp, tempSymbol, { digits: 1 })}.` + : `最新实测锚点为 ${formatTemperatureValue(currentTemp, tempSymbol, { digits: 1 })}。` + : "", + ] + .filter(Boolean) + .slice(0, 3); + const risk = + paceTone === "warm" + ? isEn + ? "Risk trigger: if later METAR cools back toward the curve before the peak window, downgrade the hotter read." + : "风险触发:如果后续 METAR 在峰值窗口前回落到曲线附近,需要下调偏高温判断。" + : paceTone === "cold" + ? isEn + ? "Risk trigger: only restore higher buckets if observations recover before the peak window." + : "风险触发:只有实测在峰值窗口前修复,才重新考虑更高温区间。" + : isEn + ? "Risk trigger: a clear METAR/path break before the peak window should decide direction." + : "风险触发:峰值窗口前若 METAR 或路径明显偏离,再决定方向。"; + + return { + action, + confidence, + expectedHigh, + kicker: isEn + ? "Weather decision layer · no market price input" + : "天气决策层 · 未接入市场价格", + reasons, + risk, + targetRange, + tone, + }; +} + function AiCityTemperatureChart({ detail }: { detail: CityDetail }) { const { locale } = useI18n(); const chartData = useMemo( @@ -724,7 +868,7 @@ function AiCityTemperatureChart({ detail }: { detail: CityDetail }) {
- {locale === "en-US" ? "Intraday path" : "今日日内分析"} + {locale === "en-US" ? "Evidence · intraday path" : "证据 · 今日日内路径"}
@@ -1002,6 +1146,20 @@ function AiPinnedCityCard({ : isEn ? "Model support is unavailable, so this city must rely on DEB path and METAR observations." : "暂无可用多模型支撑,需要主要参考 DEB 路径和 METAR 实测。"; + const decisionView = buildWeatherDecisionView({ + aiCityForecast, + currentTemp, + deb, + isEn, + localModelSupportNote, + modelEntries, + modelMax, + modelMin, + paceTone, + paceView, + peakWindow, + tempSymbol, + }); const localizedRisksRaw = (isEn ? aiCityForecast?.risks_en : aiCityForecast?.risks_zh) || []; const localizedRisks = Array.isArray(localizedRisksRaw) @@ -1096,27 +1254,33 @@ function AiPinnedCityCard({ {detail && !collapsed ? (
-
-
- {isEn ? "Final read" : "最终判断"} - - {paceTone === "warm" - ? isEn - ? "Running above DEB path" - : "实测高于 DEB 路径" - : paceTone === "cold" - ? isEn - ? "Running below DEB path" - : "实测低于 DEB 路径" - : isEn - ? "Tracking DEB path" - : "基本贴合 DEB 路径"} - -

{paceText}

+
+
+ {decisionView.kicker} + {decisionView.action} +

{localizedFinalJudgment || paceText}

+
+ {decisionView.reasons.map((reason, index) => ( + {reason} + ))} +
+

{decisionView.risk}

- {isEn ? "Observed" : "实测"}{" "} + {isEn ? "Expected high" : "预计高点"} + {decisionView.expectedHigh} + + + {isEn ? "Weather range" : "天气区间"} + {decisionView.targetRange} + + + {isEn ? "Confidence" : "信心"} + {decisionView.confidence} + + + {isEn ? "Observed" : "实测"} {currentTemp != null ? formatTemperatureValue(currentTemp, tempSymbol, { digits: 1 }) @@ -1127,7 +1291,7 @@ function AiPinnedCityCard({ {isEn ? "Path delta" : "路径偏差"} {paceView?.deltaText || "--"} - {isEn ? "Model support" : "模型支持"} {modelValues.length} + {isEn ? "Peak window" : "峰值窗口"} {peakWindow}
@@ -1136,7 +1300,7 @@ function AiPinnedCityCard({
- {isEn ? "AI airport weather read" : "AI 机场报文解读"} + {isEn ? "Evidence · AI airport read" : "证据 · AI 机场报文解读"}
{aiForecast.status === "loading" ? ( <> @@ -1227,7 +1391,7 @@ function AiPinnedCityCard({
- {isEn ? "Multi-model support" : "多模型支撑"} + {isEn ? "Evidence · multi-model support" : "证据 · 多模型支撑"}