feat: implement ScanTerminalDashboard component with AI streaming forecast and queue management
This commit is contained in:
@@ -11183,6 +11183,14 @@
|
|||||||
border-color: rgba(34, 197, 94, 0.34);
|
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) {
|
.root :global(.scan-ai-decision-band span) {
|
||||||
color: #4da3ff;
|
color: #4da3ff;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -11203,6 +11211,40 @@
|
|||||||
line-height: 1.55;
|
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) {
|
.root :global(.scan-ai-decision-metrics) {
|
||||||
min-width: 360px;
|
min-width: 360px;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -13136,7 +13178,8 @@
|
|||||||
.root :global(.scan-terminal.light .scan-ai-decision-band strong),
|
.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-decision-metrics b),
|
||||||
.root :global(.scan-terminal.light .scan-ai-market-bucket strong),
|
.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;
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -619,6 +619,150 @@ function waitForDeepAnalysisQueue(ms: number) {
|
|||||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
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 }) {
|
function AiCityTemperatureChart({ detail }: { detail: CityDetail }) {
|
||||||
const { locale } = useI18n();
|
const { locale } = useI18n();
|
||||||
const chartData = useMemo(
|
const chartData = useMemo(
|
||||||
@@ -724,7 +868,7 @@ function AiCityTemperatureChart({ detail }: { detail: CityDetail }) {
|
|||||||
<section className="scan-ai-city-section chart">
|
<section className="scan-ai-city-section chart">
|
||||||
<div className="scan-ai-city-section-title">
|
<div className="scan-ai-city-section-title">
|
||||||
<BarChart3 size={15} />
|
<BarChart3 size={15} />
|
||||||
<span>{locale === "en-US" ? "Intraday path" : "今日日内分析"}</span>
|
<span>{locale === "en-US" ? "Evidence · intraday path" : "证据 · 今日日内路径"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="scan-ai-city-chart">
|
<div className="scan-ai-city-chart">
|
||||||
<canvas ref={canvasRef} />
|
<canvas ref={canvasRef} />
|
||||||
@@ -1002,6 +1146,20 @@ function AiPinnedCityCard({
|
|||||||
: isEn
|
: isEn
|
||||||
? "Model support is unavailable, so this city must rely on DEB path and METAR observations."
|
? "Model support is unavailable, so this city must rely on DEB path and METAR observations."
|
||||||
: "暂无可用多模型支撑,需要主要参考 DEB 路径和 METAR 实测。";
|
: "暂无可用多模型支撑,需要主要参考 DEB 路径和 METAR 实测。";
|
||||||
|
const decisionView = buildWeatherDecisionView({
|
||||||
|
aiCityForecast,
|
||||||
|
currentTemp,
|
||||||
|
deb,
|
||||||
|
isEn,
|
||||||
|
localModelSupportNote,
|
||||||
|
modelEntries,
|
||||||
|
modelMax,
|
||||||
|
modelMin,
|
||||||
|
paceTone,
|
||||||
|
paceView,
|
||||||
|
peakWindow,
|
||||||
|
tempSymbol,
|
||||||
|
});
|
||||||
const localizedRisksRaw =
|
const localizedRisksRaw =
|
||||||
(isEn ? aiCityForecast?.risks_en : aiCityForecast?.risks_zh) || [];
|
(isEn ? aiCityForecast?.risks_en : aiCityForecast?.risks_zh) || [];
|
||||||
const localizedRisks = Array.isArray(localizedRisksRaw)
|
const localizedRisks = Array.isArray(localizedRisksRaw)
|
||||||
@@ -1096,27 +1254,33 @@ function AiPinnedCityCard({
|
|||||||
|
|
||||||
{detail && !collapsed ? (
|
{detail && !collapsed ? (
|
||||||
<div className="scan-ai-city-body" id={collapseId}>
|
<div className="scan-ai-city-body" id={collapseId}>
|
||||||
<section className={clsx("scan-ai-decision-band", paceTone)}>
|
<section className={clsx("scan-ai-decision-band", decisionView.tone)}>
|
||||||
<div>
|
<div className="scan-ai-decision-main">
|
||||||
<span>{isEn ? "Final read" : "最终判断"}</span>
|
<span>{decisionView.kicker}</span>
|
||||||
<strong>
|
<strong>{decisionView.action}</strong>
|
||||||
{paceTone === "warm"
|
<p>{localizedFinalJudgment || paceText}</p>
|
||||||
? isEn
|
<div className="scan-ai-decision-reasons">
|
||||||
? "Running above DEB path"
|
{decisionView.reasons.map((reason, index) => (
|
||||||
: "实测高于 DEB 路径"
|
<small key={`${reason}-${index}`}>{reason}</small>
|
||||||
: paceTone === "cold"
|
))}
|
||||||
? isEn
|
</div>
|
||||||
? "Running below DEB path"
|
<p className="scan-ai-decision-risk">{decisionView.risk}</p>
|
||||||
: "实测低于 DEB 路径"
|
|
||||||
: isEn
|
|
||||||
? "Tracking DEB path"
|
|
||||||
: "基本贴合 DEB 路径"}
|
|
||||||
</strong>
|
|
||||||
<p>{paceText}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="scan-ai-decision-metrics">
|
<div className="scan-ai-decision-metrics">
|
||||||
<span>
|
<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>
|
<b>
|
||||||
{currentTemp != null
|
{currentTemp != null
|
||||||
? formatTemperatureValue(currentTemp, tempSymbol, { digits: 1 })
|
? formatTemperatureValue(currentTemp, tempSymbol, { digits: 1 })
|
||||||
@@ -1127,7 +1291,7 @@ function AiPinnedCityCard({
|
|||||||
{isEn ? "Path delta" : "路径偏差"} <b>{paceView?.deltaText || "--"}</b>
|
{isEn ? "Path delta" : "路径偏差"} <b>{paceView?.deltaText || "--"}</b>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{isEn ? "Model support" : "模型支持"} <b>{modelValues.length}</b>
|
{isEn ? "Peak window" : "峰值窗口"} <b>{peakWindow}</b>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1136,7 +1300,7 @@ function AiPinnedCityCard({
|
|||||||
<AiCityTemperatureChart detail={detail} />
|
<AiCityTemperatureChart detail={detail} />
|
||||||
<section className="scan-ai-city-section">
|
<section className="scan-ai-city-section">
|
||||||
<div className="scan-ai-city-section-title">
|
<div className="scan-ai-city-section-title">
|
||||||
{isEn ? "AI airport weather read" : "AI 机场报文解读"}
|
{isEn ? "Evidence · AI airport read" : "证据 · AI 机场报文解读"}
|
||||||
</div>
|
</div>
|
||||||
{aiForecast.status === "loading" ? (
|
{aiForecast.status === "loading" ? (
|
||||||
<>
|
<>
|
||||||
@@ -1227,7 +1391,7 @@ function AiPinnedCityCard({
|
|||||||
|
|
||||||
<section className="scan-ai-city-section models">
|
<section className="scan-ai-city-section models">
|
||||||
<div className="scan-ai-city-section-title">
|
<div className="scan-ai-city-section-title">
|
||||||
{isEn ? "Multi-model support" : "多模型支撑"}
|
{isEn ? "Evidence · multi-model support" : "证据 · 多模型支撑"}
|
||||||
</div>
|
</div>
|
||||||
<ModelForecast detail={detail} targetDate={detail.local_date} hideTitle />
|
<ModelForecast detail={detail} targetDate={detail.local_date} hideTitle />
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user