feat: implement ScanTerminalDashboard component with AI streaming forecast and queue management

This commit is contained in:
2569718930@qq.com
2026-04-26 08:19:00 +08:00
parent 1595f4a92e
commit 1c644a59c6
2 changed files with 230 additions and 23 deletions
@@ -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;
}
@@ -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<readonly [string, number]>;
modelMax: number | null;
modelMin: number | null;
paceTone: string;
paceView: ReturnType<typeof getTodayPaceView> | 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 }) {
<section className="scan-ai-city-section chart">
<div className="scan-ai-city-section-title">
<BarChart3 size={15} />
<span>{locale === "en-US" ? "Intraday path" : "今日日内分析"}</span>
<span>{locale === "en-US" ? "Evidence · intraday path" : "证据 · 今日日内路径"}</span>
</div>
<div className="scan-ai-city-chart">
<canvas ref={canvasRef} />
@@ -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 ? (
<div className="scan-ai-city-body" id={collapseId}>
<section className={clsx("scan-ai-decision-band", paceTone)}>
<div>
<span>{isEn ? "Final read" : "最终判断"}</span>
<strong>
{paceTone === "warm"
? isEn
? "Running above DEB path"
: "实测高于 DEB 路径"
: paceTone === "cold"
? isEn
? "Running below DEB path"
: "实测低于 DEB 路径"
: isEn
? "Tracking DEB path"
: "基本贴合 DEB 路径"}
</strong>
<p>{paceText}</p>
<section className={clsx("scan-ai-decision-band", decisionView.tone)}>
<div className="scan-ai-decision-main">
<span>{decisionView.kicker}</span>
<strong>{decisionView.action}</strong>
<p>{localizedFinalJudgment || paceText}</p>
<div className="scan-ai-decision-reasons">
{decisionView.reasons.map((reason, index) => (
<small key={`${reason}-${index}`}>{reason}</small>
))}
</div>
<p className="scan-ai-decision-risk">{decisionView.risk}</p>
</div>
<div className="scan-ai-decision-metrics">
<span>
{isEn ? "Observed" : "实测"}{" "}
{isEn ? "Expected high" : "预计高点"}
<b>{decisionView.expectedHigh}</b>
</span>
<span>
{isEn ? "Weather range" : "天气区间"}
<b>{decisionView.targetRange}</b>
</span>
<span>
{isEn ? "Confidence" : "信心"}
<b>{decisionView.confidence}</b>
</span>
<span>
{isEn ? "Observed" : "实测"}
<b>
{currentTemp != null
? formatTemperatureValue(currentTemp, tempSymbol, { digits: 1 })
@@ -1127,7 +1291,7 @@ function AiPinnedCityCard({
{isEn ? "Path delta" : "路径偏差"} <b>{paceView?.deltaText || "--"}</b>
</span>
<span>
{isEn ? "Model support" : "模型支持"} <b>{modelValues.length}</b>
{isEn ? "Peak window" : "峰值窗口"} <b>{peakWindow}</b>
</span>
</div>
</section>
@@ -1136,7 +1300,7 @@ function AiPinnedCityCard({
<AiCityTemperatureChart detail={detail} />
<section className="scan-ai-city-section">
<div className="scan-ai-city-section-title">
{isEn ? "AI airport weather read" : "AI 机场报文解读"}
{isEn ? "Evidence · AI airport read" : "证据 · AI 机场报文解读"}
</div>
{aiForecast.status === "loading" ? (
<>
@@ -1227,7 +1391,7 @@ function AiPinnedCityCard({
<section className="scan-ai-city-section models">
<div className="scan-ai-city-section-title">
{isEn ? "Multi-model support" : "多模型支撑"}
{isEn ? "Evidence · multi-model support" : "证据 · 多模型支撑"}
</div>
<ModelForecast detail={detail} targetDate={detail.local_date} hideTitle />
</section>