feat: implement ScanTerminalDashboard with AI-driven city streaming and analysis features

This commit is contained in:
2569718930@qq.com
2026-04-26 09:13:18 +08:00
parent 1c644a59c6
commit da2126e33e
15 changed files with 3281 additions and 1701 deletions
@@ -10982,6 +10982,275 @@
text-align: right;
}
.root :global(.scan-opportunity-overview) {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
border: 1px solid rgba(77, 163, 255, 0.16);
border-radius: 18px;
background:
radial-gradient(circle at 12% 0%, rgba(77, 163, 255, 0.16), transparent 32%),
#0b1220;
padding: 18px;
}
.root :global(.scan-opportunity-overview.loading),
.root :global(.scan-opportunity-overview.empty) {
display: grid;
place-items: center;
align-content: center;
gap: 12px;
text-align: center;
color: #9fb2c7;
}
.root :global(.scan-opportunity-overview.empty strong) {
color: #e6edf3;
font-size: 22px;
}
.root :global(.scan-opportunity-overview.empty button) {
border: 1px solid rgba(77, 163, 255, 0.38);
border-radius: 999px;
background: rgba(77, 163, 255, 0.12);
color: #9ecbff;
cursor: pointer;
font-weight: 900;
padding: 10px 16px;
}
.root :global(.scan-opportunity-hero) {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(320px, 0.75fr);
gap: 18px;
align-items: stretch;
margin-bottom: 18px;
}
.root :global(.scan-opportunity-hero > div:first-child) {
border: 1px solid rgba(77, 163, 255, 0.2);
border-radius: 18px;
background: rgba(17, 26, 46, 0.78);
padding: 18px;
}
.root :global(.scan-opportunity-hero span) {
color: #4da3ff;
font-size: 12px;
font-weight: 900;
}
.root :global(.scan-opportunity-hero strong) {
display: block;
margin-top: 4px;
color: #e6edf3;
font-size: 26px;
line-height: 1.15;
}
.root :global(.scan-opportunity-hero p) {
max-width: 680px;
margin: 9px 0 0;
color: #9fb2c7;
font-size: 13px;
line-height: 1.6;
}
.root :global(.scan-opportunity-summary) {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.root :global(.scan-opportunity-summary span) {
display: grid;
gap: 5px;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 14px;
background: rgba(13, 17, 23, 0.52);
color: #9fb2c7;
padding: 12px;
}
.root :global(.scan-opportunity-summary b) {
color: #e6edf3;
font-size: 18px;
}
.root :global(.scan-opportunity-lanes) {
display: grid;
gap: 16px;
}
.root :global(.scan-opportunity-lane) {
border: 1px solid rgba(77, 163, 255, 0.14);
border-radius: 18px;
background: rgba(13, 17, 23, 0.38);
padding: 14px;
}
.root :global(.scan-opportunity-lane-head) {
display: flex;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.root :global(.scan-opportunity-lane-head strong) {
color: #e6edf3;
font-size: 17px;
}
.root :global(.scan-opportunity-lane-head p) {
margin: 3px 0 0;
color: #9fb2c7;
font-size: 12px;
}
.root :global(.scan-opportunity-lane-head > span) {
display: grid;
place-items: center;
min-width: 32px;
height: 32px;
border-radius: 999px;
background: rgba(77, 163, 255, 0.12);
color: #9ecbff;
font-weight: 900;
}
.root :global(.scan-opportunity-card-grid) {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.root :global(.scan-opportunity-decision-card) {
display: grid;
gap: 12px;
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 16px;
background: rgba(17, 26, 46, 0.78);
color: inherit;
cursor: pointer;
padding: 14px;
transition: border-color 0.18s ease, transform 0.18s ease, background 0.18s ease;
}
.root :global(.scan-opportunity-decision-card:hover),
.root :global(.scan-opportunity-decision-card.selected) {
border-color: rgba(77, 163, 255, 0.42);
background: rgba(20, 34, 58, 0.9);
transform: translateY(-1px);
}
.root :global(.scan-opportunity-decision-card.trade) {
border-color: rgba(34, 197, 94, 0.34);
}
.root :global(.scan-opportunity-decision-card.wait) {
border-color: rgba(245, 158, 11, 0.34);
}
.root :global(.scan-opportunity-decision-card.risk),
.root :global(.scan-opportunity-decision-card.avoid) {
border-color: rgba(239, 68, 68, 0.32);
}
.root :global(.scan-opportunity-decision-head),
.root :global(.scan-opportunity-decision-primary),
.root :global(.scan-opportunity-decision-foot) {
display: flex;
gap: 10px;
justify-content: space-between;
}
.root :global(.scan-opportunity-decision-head span),
.root :global(.scan-opportunity-decision-primary span),
.root :global(.scan-opportunity-decision-foot small) {
color: #9fb2c7;
font-size: 11px;
font-weight: 800;
}
.root :global(.scan-opportunity-decision-head strong) {
display: block;
margin-top: 3px;
color: #e6edf3;
font-size: 18px;
}
.root :global(.scan-opportunity-decision-head > b) {
align-self: start;
border-radius: 999px;
background: rgba(77, 163, 255, 0.14);
color: #9ecbff;
font-size: 12px;
padding: 6px 10px;
white-space: nowrap;
}
.root :global(.scan-opportunity-decision-card.trade .scan-opportunity-decision-head > b) {
background: rgba(34, 197, 94, 0.14);
color: #86efac;
}
.root :global(.scan-opportunity-decision-card.wait .scan-opportunity-decision-head > b) {
background: rgba(245, 158, 11, 0.14);
color: #facc15;
}
.root :global(.scan-opportunity-decision-card.risk .scan-opportunity-decision-head > b),
.root :global(.scan-opportunity-decision-card.avoid .scan-opportunity-decision-head > b) {
background: rgba(239, 68, 68, 0.14);
color: #fca5a5;
}
.root :global(.scan-opportunity-decision-primary span) {
display: grid;
gap: 4px;
flex: 1;
border-radius: 12px;
background: rgba(13, 17, 23, 0.52);
padding: 9px;
}
.root :global(.scan-opportunity-decision-primary b) {
color: #e6edf3;
font-size: 14px;
}
.root :global(.scan-opportunity-decision-card p) {
min-height: 38px;
margin: 0;
color: #b6c7db;
font-size: 12px;
line-height: 1.55;
}
.root :global(.scan-opportunity-decision-foot) {
flex-wrap: wrap;
justify-content: flex-start;
}
.root :global(.scan-opportunity-decision-foot small) {
border-radius: 999px;
background: rgba(77, 163, 255, 0.08);
padding: 5px 8px;
}
.root :global(.scan-opportunity-decision-card button) {
justify-self: start;
border: 1px solid rgba(77, 163, 255, 0.34);
border-radius: 10px;
background: rgba(77, 163, 255, 0.12);
color: #9ecbff;
cursor: pointer;
font-size: 12px;
font-weight: 900;
padding: 8px 10px;
}
.root :global(.scan-ai-city-stack) {
display: grid;
gap: 18px;
@@ -11245,6 +11514,81 @@
font-weight: 800;
}
.root :global(.scan-ai-market-decision) {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 12px;
align-items: center;
margin-top: 14px;
border: 1px solid rgba(77, 163, 255, 0.18);
border-radius: 14px;
background: rgba(11, 18, 32, 0.44);
padding: 12px;
}
.root :global(.scan-ai-market-decision.warm) {
border-color: rgba(34, 197, 94, 0.36);
}
.root :global(.scan-ai-market-decision.cold) {
border-color: rgba(239, 68, 68, 0.36);
}
.root :global(.scan-ai-market-decision.watch) {
border-color: rgba(245, 158, 11, 0.38);
}
.root :global(.scan-ai-market-decision span) {
color: #9fb2c7;
font-size: 11px;
font-weight: 900;
}
.root :global(.scan-ai-market-decision strong) {
margin-top: 3px;
color: #e6edf3;
font-size: 15px;
}
.root :global(.scan-ai-market-decision p) {
margin-top: 5px;
font-size: 12px;
}
.root :global(.scan-ai-market-decision-stats) {
display: grid;
grid-template-columns: repeat(3, minmax(82px, 1fr));
gap: 8px;
}
.root :global(.scan-ai-market-decision-stats small) {
display: grid;
gap: 3px;
border-radius: 10px;
background: rgba(17, 26, 46, 0.82);
color: #9fb2c7;
font-size: 11px;
font-weight: 800;
padding: 8px;
}
.root :global(.scan-ai-market-decision-stats b) {
color: #e6edf3;
font-size: 13px;
}
.root :global(.scan-ai-market-link) {
border: 1px solid rgba(77, 163, 255, 0.34);
border-radius: 10px;
background: rgba(77, 163, 255, 0.12);
color: #9ecbff;
font-size: 12px;
font-weight: 900;
padding: 9px 10px;
text-decoration: none;
white-space: nowrap;
}
.root :global(.scan-ai-decision-metrics) {
min-width: 360px;
display: grid;
@@ -12671,119 +13015,216 @@
min-height: 520px;
display: grid;
place-items: center;
align-content: center;
justify-items: center;
gap: 14px;
padding: 48px 32px;
text-align: center;
}
.root :global(.scan-loading-orb) {
.root :global(.scan-loading-signal) {
position: relative;
width: 74px;
height: 74px;
border-radius: 999px;
width: min(100%, 420px);
display: grid;
justify-items: center;
gap: 18px;
padding: 28px 30px;
border: 1px solid rgba(125, 211, 252, 0.14);
border-radius: 28px;
background:
radial-gradient(circle at center, rgba(77, 163, 255, 0.2) 0 22%, transparent 23%),
conic-gradient(from 20deg, rgba(77, 163, 255, 0.08), rgba(77, 163, 255, 0.95), rgba(34, 197, 94, 0.78), rgba(77, 163, 255, 0.08));
radial-gradient(circle at 18% 18%, rgba(77, 163, 255, 0.18), transparent 34%),
linear-gradient(145deg, rgba(12, 20, 36, 0.92), rgba(5, 11, 22, 0.74));
box-shadow:
0 0 0 1px rgba(77, 163, 255, 0.14),
0 18px 42px rgba(77, 163, 255, 0.18);
animation: scan-loading-spin 1.25s linear infinite;
0 22px 70px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
overflow: hidden;
}
.root :global(.scan-loading-orb::before) {
.root :global(.scan-loading-signal::before) {
content: "";
position: absolute;
inset: 8px;
border-radius: inherit;
background: #0b1220;
inset: 0;
background: linear-gradient(
100deg,
transparent 0%,
rgba(125, 211, 252, 0.08) 42%,
rgba(34, 197, 94, 0.1) 50%,
transparent 58%
);
transform: translateX(-120%);
animation: scan-loading-sweep 2.6s ease-in-out infinite;
pointer-events: none;
}
.root :global(.scan-loading-orb span) {
position: absolute;
inset: 26px;
z-index: 1;
border-radius: inherit;
background: #4da3ff;
box-shadow:
0 0 18px rgba(77, 163, 255, 0.72),
0 0 36px rgba(34, 197, 94, 0.28);
animation: scan-loading-pulse 1.25s ease-in-out infinite;
.root :global(.scan-loading-signal.compact) {
width: min(100%, 340px);
gap: 12px;
padding: 18px 20px;
border-radius: 22px;
background:
radial-gradient(circle at 20% 20%, rgba(77, 163, 255, 0.14), transparent 36%),
rgba(10, 18, 32, 0.62);
box-shadow: none;
}
.root :global(.scan-loading-orb i) {
position: absolute;
.root :global(.scan-loading-decision-flow) {
position: relative;
z-index: 1;
top: 4px;
left: 50%;
width: 9px;
height: 9px;
margin-left: -4.5px;
display: grid;
grid-template-columns: 16px minmax(70px, 1fr) 16px minmax(70px, 1fr) 16px;
align-items: center;
width: min(100%, 300px);
}
.root :global(.scan-loading-node) {
width: 16px;
height: 16px;
border: 1px solid rgba(226, 232, 240, 0.16);
border-radius: 999px;
background: #e6edf3;
box-shadow: 0 0 18px rgba(230, 237, 243, 0.86);
background: #0b1220;
box-shadow: 0 0 0 5px rgba(77, 163, 255, 0.05);
}
.root :global(.scan-loading-title) {
.root :global(.scan-loading-node.hot) {
background: linear-gradient(135deg, #f97316, #fde047);
box-shadow: 0 0 24px rgba(251, 146, 60, 0.26);
}
.root :global(.scan-loading-node.market) {
background: linear-gradient(135deg, #38bdf8, #4da3ff);
box-shadow: 0 0 24px rgba(56, 189, 248, 0.26);
}
.root :global(.scan-loading-node.action) {
background: linear-gradient(135deg, #22c55e, #1be392);
box-shadow: 0 0 24px rgba(34, 197, 94, 0.3);
animation: scan-loading-node-breathe 1.8s ease-in-out infinite;
}
.root :global(.scan-loading-rail) {
position: relative;
height: 2px;
overflow: hidden;
background: rgba(148, 163, 184, 0.16);
}
.root :global(.scan-loading-rail i) {
position: absolute;
inset: 0;
width: 48%;
border-radius: 999px;
background: linear-gradient(90deg, transparent, #7dd3fc, #22c55e);
transform: translateX(-110%);
animation: scan-loading-rail-flow 1.55s ease-in-out infinite;
}
.root :global(.scan-loading-rail:nth-of-type(4) i) {
animation-delay: 0.45s;
}
.root :global(.scan-loading-copy-block) {
position: relative;
z-index: 1;
display: grid;
gap: 7px;
justify-items: center;
}
.root :global(.scan-loading-copy-block strong) {
color: #e6edf3;
font-size: 20px;
font-size: 18px;
font-weight: 900;
letter-spacing: -0.02em;
}
.root :global(.scan-loading-copy) {
.root :global(.scan-loading-copy-block span) {
max-width: 320px;
color: #9fb2c7;
font-size: 13px;
font-weight: 700;
font-weight: 650;
line-height: 1.55;
}
.root :global(.scan-loading-steps) {
display: inline-flex;
gap: 7px;
margin-top: 2px;
.root :global(.scan-loading-signal-bars) {
position: relative;
z-index: 1;
display: flex;
align-items: end;
gap: 5px;
height: 28px;
}
.root :global(.scan-loading-steps span) {
width: 32px;
height: 3px;
border-radius: 999px;
background: rgba(77, 163, 255, 0.24);
animation: scan-loading-step 1.2s ease-in-out infinite;
.root :global(.scan-loading-signal-bars span) {
width: 7px;
min-height: 8px;
border-radius: 999px 999px 4px 4px;
background: linear-gradient(180deg, #7dd3fc, #4da3ff 52%, #1be392);
opacity: 0.42;
transform-origin: center bottom;
animation: scan-loading-bars 1.35s ease-in-out infinite;
}
.root :global(.scan-loading-steps span:nth-child(2)) {
.root :global(.scan-loading-signal-bars span:nth-child(1)) {
height: 12px;
}
.root :global(.scan-loading-signal-bars span:nth-child(2)) {
height: 22px;
animation-delay: 0.16s;
}
.root :global(.scan-loading-steps span:nth-child(3)) {
.root :global(.scan-loading-signal-bars span:nth-child(3)) {
height: 16px;
animation-delay: 0.32s;
}
@keyframes scan-loading-spin {
to {
transform: rotate(360deg);
.root :global(.scan-loading-signal-bars span:nth-child(4)) {
height: 25px;
animation-delay: 0.48s;
}
@keyframes scan-loading-sweep {
0% {
transform: translateX(-130%);
}
46%,
100% {
transform: translateX(130%);
}
}
@keyframes scan-loading-pulse {
0%,
100% {
transform: scale(0.82);
opacity: 0.74;
@keyframes scan-loading-rail-flow {
0% {
transform: translateX(-110%);
opacity: 0.15;
}
50% {
transform: scale(1);
45% {
opacity: 1;
}
100% {
transform: translateX(220%);
opacity: 0.15;
}
}
@keyframes scan-loading-step {
@keyframes scan-loading-node-breathe {
0%,
100% {
background: rgba(77, 163, 255, 0.2);
transform: scale(0.92);
filter: saturate(0.9);
}
50% {
background: rgba(77, 163, 255, 0.9);
transform: scale(1.08);
filter: saturate(1.25);
}
}
@keyframes scan-loading-bars {
0%,
100% {
opacity: 0.38;
transform: scaleY(0.72);
}
50% {
opacity: 0.95;
transform: scaleY(1);
}
}
@@ -12841,7 +13282,7 @@
.root :global(.scan-terminal.light .scan-detail-city-sub),
.root :global(.scan-terminal.light .scan-detail-volume-caption),
.root :global(.scan-terminal.light .scan-empty-copy),
.root :global(.scan-terminal.light .scan-loading-copy),
.root :global(.scan-terminal.light .scan-loading-copy-block span),
.root :global(.scan-terminal.light .scan-kv span:first-child),
.root :global(.scan-terminal.light .scan-chart-label),
.root :global(.scan-terminal.light .scan-trade-sub),
@@ -12849,12 +13290,39 @@
color: #475569;
}
.root :global(.scan-terminal.light .scan-loading-title) {
.root :global(.scan-terminal.light .scan-loading-copy-block strong) {
color: #0f172a;
}
.root :global(.scan-terminal.light .scan-loading-orb::before) {
background: #f7f9fc;
.root :global(.scan-terminal.light .scan-loading-signal) {
border-color: #dbeafe;
background:
radial-gradient(circle at 18% 18%, rgba(59, 130, 246, 0.12), transparent 34%),
linear-gradient(145deg, rgba(255, 255, 255, 0.96), rgba(241, 245, 249, 0.9));
box-shadow: 0 16px 34px rgba(40, 70, 110, 0.1);
}
.root :global(.scan-terminal.light .scan-loading-signal.compact) {
background:
radial-gradient(circle at 18% 18%, rgba(59, 130, 246, 0.1), transparent 34%),
rgba(255, 255, 255, 0.78);
}
.root :global(.scan-terminal.light .scan-loading-node) {
background: #ffffff;
border-color: rgba(148, 163, 184, 0.22);
}
.root :global(.scan-terminal.light .scan-loading-node.hot) {
background: linear-gradient(135deg, #f97316, #fde047);
}
.root :global(.scan-terminal.light .scan-loading-node.market) {
background: linear-gradient(135deg, #38bdf8, #2563eb);
}
.root :global(.scan-terminal.light .scan-loading-node.action) {
background: linear-gradient(135deg, #16a34a, #22c55e);
}
.root :global(.scan-terminal.light .scan-list-tabs button),
@@ -13157,6 +13625,38 @@
border-color: #e2e8f0;
}
.root :global(.scan-terminal.light .scan-opportunity-overview) {
background: #f7f9fc;
border-color: #e2e8f0;
}
.root :global(.scan-terminal.light .scan-opportunity-hero > div:first-child),
.root :global(.scan-terminal.light .scan-opportunity-summary span),
.root :global(.scan-terminal.light .scan-opportunity-lane),
.root :global(.scan-terminal.light .scan-opportunity-decision-card),
.root :global(.scan-terminal.light .scan-opportunity-decision-primary span) {
background: #ffffff;
border-color: #e2e8f0;
}
.root :global(.scan-terminal.light .scan-opportunity-hero strong),
.root :global(.scan-terminal.light .scan-opportunity-summary b),
.root :global(.scan-terminal.light .scan-opportunity-lane-head strong),
.root :global(.scan-terminal.light .scan-opportunity-decision-head strong),
.root :global(.scan-terminal.light .scan-opportunity-decision-primary b) {
color: #0f172a;
}
.root :global(.scan-terminal.light .scan-opportunity-hero p),
.root :global(.scan-terminal.light .scan-opportunity-summary span),
.root :global(.scan-terminal.light .scan-opportunity-lane-head p),
.root :global(.scan-terminal.light .scan-opportunity-decision-card p),
.root :global(.scan-terminal.light .scan-opportunity-decision-head span),
.root :global(.scan-terminal.light .scan-opportunity-decision-primary span),
.root :global(.scan-terminal.light .scan-opportunity-decision-foot small) {
color: #64748b;
}
.root :global(.scan-terminal.light .scan-ai-city-card),
.root :global(.scan-terminal.light .scan-ai-city-hero) {
background: #ffffff;
@@ -13165,6 +13665,8 @@
.root :global(.scan-terminal.light .scan-ai-city-section),
.root :global(.scan-terminal.light .scan-ai-decision-band),
.root :global(.scan-terminal.light .scan-ai-market-decision),
.root :global(.scan-terminal.light .scan-ai-market-decision-stats small),
.root :global(.scan-terminal.light .scan-ai-decision-metrics span),
.root :global(.scan-terminal.light .scan-ai-market-bucket),
.root :global(.scan-terminal.light .scan-ai-city-pills span) {
@@ -13179,7 +13681,9 @@
.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-decision-reasons small) {
.root :global(.scan-terminal.light .scan-ai-decision-reasons small),
.root :global(.scan-terminal.light .scan-ai-market-decision strong),
.root :global(.scan-terminal.light .scan-ai-market-decision-stats b) {
color: #0f172a;
}
@@ -13189,6 +13693,9 @@
.root :global(.scan-terminal.light .scan-ai-city-pills span),
.root :global(.scan-terminal.light .scan-ai-decision-metrics span),
.root :global(.scan-terminal.light .scan-ai-market-bucket span),
.root :global(.scan-terminal.light .scan-ai-market-decision span),
.root :global(.scan-terminal.light .scan-ai-market-decision p),
.root :global(.scan-terminal.light .scan-ai-market-decision-stats small),
.root :global(.scan-terminal.light .scan-ai-city-muted),
.root :global(.scan-terminal.light .scan-ai-city-loading),
.root :global(.scan-terminal.light .scan-ai-city-chart-legend),
@@ -13381,12 +13888,17 @@
}
.root :global(.scan-ai-workspace-head),
.root :global(.scan-opportunity-hero),
.root :global(.scan-ai-city-hero),
.root :global(.scan-ai-city-section-head) {
flex-direction: column;
align-items: flex-start;
}
.root :global(.scan-opportunity-hero) {
display: flex;
}
.root :global(.scan-ai-workspace-head p),
.root :global(.scan-ai-city-hero-side) {
text-align: left;
@@ -13398,6 +13910,11 @@
grid-template-columns: 1fr;
}
.root :global(.scan-opportunity-card-grid),
.root :global(.scan-opportunity-summary) {
grid-template-columns: 1fr;
}
.root :global(.scan-forecast-city-read) {
justify-items: start;
text-align: left;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,128 @@
import type { ChartConfiguration } from "chart.js";
import { useMemo } from "react";
import { BarChart3 } from "lucide-react";
import type { CityDetail } from "@/lib/dashboard-types";
import { useChart } from "@/hooks/useChart";
import { useI18n } from "@/hooks/useI18n";
import { getTemperatureChartData } from "@/lib/dashboard-utils";
export function AiCityTemperatureChart({ detail }: { detail: CityDetail }) {
const { locale } = useI18n();
const chartData = useMemo(
() => getTemperatureChartData(detail, locale),
[detail, locale],
);
const forecastLabel = chartData?.datasets.hasMgmHourly
? locale === "en-US"
? "MGM forecast"
: "MGM 预测"
: locale === "en-US"
? "DEB forecast"
: "DEB 预测";
const observationLabel =
chartData?.observationLabel ||
(locale === "en-US" ? "METAR obs" : "METAR 实况");
const canvasRef = useChart(() => {
if (!chartData) {
return {
data: { datasets: [], labels: [] },
type: "line",
} satisfies ChartConfiguration<"line">;
}
const forecastPoints = chartData.datasets.hasMgmHourly
? chartData.datasets.mgmHourlyPoints
: chartData.datasets.debPast.map(
(value, index) => value ?? chartData.datasets.debFuture[index],
);
return {
data: {
datasets: [
{
borderColor: "#4DA3FF",
borderWidth: 2,
data: forecastPoints,
fill: false,
label: forecastLabel,
pointRadius: 0,
spanGaps: true,
tension: 0.32,
},
{
backgroundColor: "#22C55E",
borderColor: "#22C55E",
borderWidth: 0,
data: chartData.datasets.metarPoints,
fill: false,
label: observationLabel,
pointHoverRadius: 5,
pointRadius: 3.5,
showLine: false,
},
],
labels: chartData.times,
},
options: {
interaction: { intersect: false, mode: "index" },
layout: { padding: { bottom: 2, left: 0, right: 8, top: 8 } },
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: "rgba(11, 18, 32, 0.96)",
borderColor: "rgba(77, 163, 255, 0.38)",
borderWidth: 1,
},
},
responsive: true,
scales: {
x: {
grid: { color: "rgba(159, 178, 199, 0.08)" },
ticks: {
callback: (_value, index) =>
typeof index === "number" && index % 4 === 0
? chartData.times[index]
: "",
color: "#6B7A90",
font: { size: 10 },
maxTicksLimit: 6,
maxRotation: 0,
},
},
y: {
grid: { color: "rgba(159, 178, 199, 0.08)" },
max: chartData.max,
min: chartData.min,
ticks: {
callback: (value) =>
`${Number(value).toFixed(chartData.yTickStep < 1 ? 1 : 0)}${detail.temp_symbol || "°C"}`,
color: "#6B7A90",
font: { size: 10 },
maxTicksLimit: 5,
stepSize: chartData.yTickStep,
},
},
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
}, [chartData, detail.temp_symbol, forecastLabel, observationLabel]);
return (
<section className="scan-ai-city-section chart">
<div className="scan-ai-city-section-title">
<BarChart3 size={15} />
<span>{locale === "en-US" ? "Evidence · intraday path" : "证据 · 今日日内路径"}</span>
</div>
<div className="scan-ai-city-chart">
<canvas ref={canvasRef} />
</div>
{chartData ? (
<div className="scan-ai-city-chart-legend">
<span><i className="forecast" />{forecastLabel}</span>
<span><i className="observation" />{observationLabel}</span>
</div>
) : null}
</section>
);
}
@@ -0,0 +1,76 @@
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
import { formatTemperatureValue, getTodayPaceView } from "@/lib/dashboard-utils";
import { getPeakWindowLabel } from "@/components/dashboard/scan-terminal/decision-utils";
export function AiForecastKPIBar({
pinnedCount,
activeCityName,
activeDetail,
activeRow,
locale,
}: {
pinnedCount: number;
activeCityName?: string | null;
activeDetail?: CityDetail | null;
activeRow?: ScanOpportunityRow | null;
locale: string;
}) {
const isEn = locale === "en-US";
const tempSymbol = activeDetail?.temp_symbol || activeRow?.temp_symbol || "°C";
const displayName =
activeDetail?.display_name ||
activeRow?.city_display_name ||
activeRow?.display_name ||
activeCityName ||
"--";
const deb = activeDetail?.deb?.prediction ?? activeRow?.deb_prediction ?? null;
const paceView = activeDetail
? getTodayPaceView(activeDetail, locale as "zh-CN" | "en-US")
: null;
const peakWindow =
paceView?.peakWindowText ||
(activeRow ? getPeakWindowLabel(activeRow) : null) ||
"--";
const cards = [
{
label: isEn ? "Decision Cards" : "决策卡",
value: String(pinnedCount),
note: isEn ? "Cities opened from opportunities or map" : "从机会榜或地图加入",
tone: "green",
},
{
label: isEn ? "Current City" : "当前城市",
value: displayName,
note: isEn ? "City briefing stays in the right rail" : "右侧城市简报同步显示,不自动切页",
tone: "blue",
},
{
label: isEn ? "Forecast Center" : "预测中枢",
value:
deb != null
? formatTemperatureValue(deb, tempSymbol, { digits: 1 })
: "--",
note: isEn ? "Weather center before price mapping" : "先定天气中枢,再映射价格",
tone: "cyan",
},
{
label: isEn ? "Peak Window" : "峰值窗口",
value: peakWindow,
note: isEn ? "Key window for daily high judgment" : "用于判断是否接近今日最高温",
tone: "amber",
},
];
return (
<section className="scan-kpi-bar">
{cards.map((card) => (
<article key={card.label} className={`scan-kpi-card ${card.tone}`}>
<div className="scan-kpi-label">{card.label}</div>
<div className="scan-kpi-value">{card.value}</div>
<div className="scan-kpi-note">{card.note}</div>
</article>
))}
</section>
);
}
@@ -0,0 +1,594 @@
"use client";
import clsx from "clsx";
import { ChevronDown, RefreshCw, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ModelForecast } from "@/components/dashboard/PanelSections";
import { AiCityTemperatureChart } from "@/components/dashboard/scan-terminal/AiCityTemperatureChart";
import { buildMarketDecisionView, buildWeatherDecisionView } from "@/components/dashboard/scan-terminal/city-card-decision-utils";
import { findDetailForCity } from "@/components/dashboard/scan-terminal/city-detail-utils";
import { findRowForCity, getPeakWindowLabel, normalizeCityKey } from "@/components/dashboard/scan-terminal/decision-utils";
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
import type { AiPinnedCity } from "@/components/dashboard/scan-terminal/types";
import {
useAiCityForecast,
useCityMarketScan,
} from "@/components/dashboard/scan-terminal/use-ai-city-card-data";
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
import { formatTemperatureValue, getModelView, getTodayPaceView } from "@/lib/dashboard-utils";
function AiPinnedCityCard({
item,
detail,
row,
locale,
collapsed,
removing,
onRemove,
onToggleCollapsed,
}: {
item: AiPinnedCity;
detail: CityDetail | null;
row: ScanOpportunityRow | null;
locale: string;
collapsed: boolean;
removing?: boolean;
onRemove: () => void;
onToggleCollapsed: () => void;
}) {
const isEn = locale === "en-US";
const displayName =
detail?.display_name ||
row?.city_display_name ||
row?.display_name ||
item.displayName ||
item.cityName;
const tempSymbol = detail?.temp_symbol || row?.temp_symbol || "°C";
const modelView = detail ? getModelView(detail, detail.local_date) : null;
const modelEntries = modelView
? Object.entries(modelView.models || {})
.map(([name, value]) => [name, Number(value)] as const)
.filter(([, value]) => Number.isFinite(value))
: [];
const modelValues = modelEntries.map(([, value]) => value);
const modelMin = modelValues.length ? Math.min(...modelValues) : null;
const modelMax = modelValues.length ? Math.max(...modelValues) : null;
const paceView = detail ? getTodayPaceView(detail, locale as "zh-CN" | "en-US") : null;
const peakWindow =
paceView?.peakWindowText ||
(row ? getPeakWindowLabel(row) : null) ||
"--";
const deb = detail?.deb?.prediction ?? row?.deb_prediction ?? null;
const currentTemp =
detail?.airport_primary?.temp ??
detail?.airport_current?.temp ??
detail?.current?.temp ??
row?.current_temp ??
null;
const modelRange =
modelMin != null && modelMax != null
? `${formatTemperatureValue(modelMin, tempSymbol, { digits: 1 })} ~ ${formatTemperatureValue(modelMax, tempSymbol, { digits: 1 })}`
: "--";
const paceTone = paceView?.biasTone || "neutral";
const paceText =
paceView?.summary ||
(isEn
? "Waiting for intraday observations to compare against the DEB path."
: "等待更多日内实测,用来对照 DEB 预测路径。");
const report = detail?.current?.raw_metar || detail?.airport_current?.raw_metar || "";
const airportStation =
detail?.risk?.icao ||
detail?.current?.station_code ||
detail?.airport_current?.station_code ||
detail?.airport_primary?.station_code ||
"";
const detailCityName = detail?.name || item.cityName;
const { aiForecast, refreshAiForecast } = useAiCityForecast({
detail,
detailCityName,
isEn,
locale,
report,
});
const { marketScan, marketStatus } = useCityMarketScan({
detail,
detailCityName,
});
const aiCityForecast = aiForecast.payload?.city_forecast || null;
const localizedFinalJudgment =
(isEn ? aiCityForecast?.final_judgment_en : aiCityForecast?.final_judgment_zh) ||
(isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) ||
"";
const localizedMetarRead =
(isEn ? aiCityForecast?.metar_read_en : aiCityForecast?.metar_read_zh) ||
"";
const localizedReasoning =
(isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) ||
"";
const localizedModelNote =
(isEn
? aiCityForecast?.model_cluster_note_en
: aiCityForecast?.model_cluster_note_zh) || "";
const modelPreview = modelEntries
.slice(0, 4)
.map(([name, value]) => `${name} ${formatTemperatureValue(value, tempSymbol, { digits: 1 })}`)
.join(isEn ? " / " : " / ");
const localModelSupportNote = modelEntries.length
? isEn
? modelEntries.length <= 2
? `Model support is sparse: only ${modelEntries.length} sources are available${modelPreview ? ` (${modelPreview})` : ""}, so the read should lean more on DEB path and METAR.`
: `Model support: ${modelEntries.length} sources cluster between ${modelRange}; ${modelPreview}.`
: modelEntries.length <= 2
? `多模型支撑偏少:当前只有 ${modelEntries.length} 个模型${modelPreview ? `${modelPreview}` : ""},需要更重视 DEB 路径和 METAR 实测。`
: `多模型支撑:${modelEntries.length} 个模型集中在 ${modelRange},代表模型为 ${modelPreview}`
: isEn
? "Model support is unavailable, so this city must rely on DEB path and METAR observations."
: "暂无可用多模型支撑,需要主要参考 DEB 路径和 METAR 实测。";
const aiPredictedMax = Number(aiCityForecast?.predicted_max);
const decisionExpectedHighNumber = Number.isFinite(aiPredictedMax)
? aiPredictedMax
: paceView?.paceAdjustedHigh != null
? paceView.paceAdjustedHigh
: deb;
const decisionView = buildWeatherDecisionView({
aiCityForecast,
currentTemp,
deb,
isEn,
localModelSupportNote,
modelEntries,
modelMax,
modelMin,
paceTone,
paceView,
peakWindow,
tempSymbol,
});
const marketDecisionView = buildMarketDecisionView({
expectedHigh: decisionExpectedHighNumber,
isEn,
marketScan,
marketStatus,
tempSymbol,
});
const localizedRisksRaw =
(isEn ? aiCityForecast?.risks_en : aiCityForecast?.risks_zh) || [];
const localizedRisks = Array.isArray(localizedRisksRaw)
? localizedRisksRaw
: localizedRisksRaw
? [String(localizedRisksRaw)]
: [];
const aiBullets = [
localizedMetarRead,
localizedReasoning !== localizedFinalJudgment ? localizedReasoning : "",
localizedModelNote || localModelSupportNote,
...localizedRisks,
].filter((line) => String(line || "").trim());
const fallbackAiReason =
(isEn ? aiForecast.payload?.reason_en : aiForecast.payload?.reason_zh) ||
aiForecast.payload?.reason ||
"";
const collapseId = `ai-city-body-${normalizeCityKey(item.cityName) || item.addedAt}`;
return (
<article className={clsx("scan-ai-city-card", collapsed && "collapsed", removing && "removing")}>
<header className="scan-ai-city-hero">
<div>
<span className="scan-ai-city-kicker">
{isEn ? "Deep analysis" : "城市深度分析"}
</span>
<h3>{displayName}</h3>
<div className="scan-ai-city-pills">
<span>{detail?.local_time || row?.local_time || "--"}</span>
<span>
DEB{" "}
{deb != null
? formatTemperatureValue(deb, tempSymbol, { digits: 1 })
: "--"}
</span>
<span>{isEn ? "Model" : "模型"} {modelRange}</span>
<span>{isEn ? "Peak" : "峰值"} {peakWindow}</span>
</div>
</div>
<div className="scan-ai-city-hero-side">
<span>{isEn ? "Expected high" : "预计最高温"}</span>
<strong>
{paceView?.paceAdjustedHigh != null
? formatTemperatureValue(paceView.paceAdjustedHigh, tempSymbol, { digits: 1 })
: deb != null
? formatTemperatureValue(deb, tempSymbol, { digits: 1 })
: "--"}
</strong>
<div className="scan-ai-city-actions">
<button
type="button"
className="scan-ai-city-icon-button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
refreshAiForecast();
}}
aria-label={isEn ? `Refresh ${displayName} analysis` : `刷新 ${displayName} 深度分析`}
title={isEn ? "Refresh analysis" : "刷新深度分析"}
disabled={aiForecast.status === "loading"}
>
<RefreshCw size={15} className={aiForecast.status === "loading" ? "spin" : undefined} />
</button>
<button
type="button"
className="scan-ai-city-icon-button danger"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onRemove();
}}
aria-label={isEn ? `Remove ${displayName}` : `移除 ${displayName}`}
title={isEn ? "Remove city" : "移除城市"}
disabled={removing}
>
<X size={15} />
</button>
<button
type="button"
className="scan-ai-city-collapse"
onClick={onToggleCollapsed}
aria-expanded={!collapsed}
aria-controls={collapseId}
>
<ChevronDown size={15} />
{collapsed ? (isEn ? "Expand" : "展开") : (isEn ? "Collapse" : "收起")}
</button>
</div>
</div>
</header>
{detail && !collapsed ? (
<div className="scan-ai-city-body" id={collapseId}>
<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 className={clsx("scan-ai-market-decision", marketDecisionView.tone)}>
<div>
<span>
{isEn ? "Polymarket price layer" : "Polymarket 价格层"}
</span>
<strong>{marketDecisionView.title}</strong>
<p>{marketDecisionView.reason}</p>
</div>
<div className="scan-ai-market-decision-stats">
<small>
{isEn ? "Bucket" : "温度桶"} <b>{marketDecisionView.bucketLabel}</b>
</small>
<small>
{isEn ? "YES" : "YES 买入"} <b>{marketDecisionView.priceText}</b>
</small>
<small>
{isEn ? "Edge" : "概率差"} <b>{marketDecisionView.edgeText}</b>
</small>
</div>
{marketDecisionView.marketUrl ? (
<a
className="scan-ai-market-link"
href={marketDecisionView.marketUrl}
target="_blank"
rel="noreferrer"
>
{isEn ? "Open market" : "打开市场"}
</a>
) : null}
</div>
</div>
<div className="scan-ai-decision-metrics">
<span>
{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 })
: "--"}
</b>
</span>
<span>
{isEn ? "Path delta" : "路径偏差"} <b>{paceView?.deltaText || "--"}</b>
</span>
<span>
{isEn ? "Peak window" : "峰值窗口"} <b>{peakWindow}</b>
</span>
<span>
{isEn ? "Market implied" : "市场隐含"} <b>{marketDecisionView.impliedText}</b>
</span>
<span>
{isEn ? "Model prob" : "模型概率"} <b>{marketDecisionView.modelText}</b>
</span>
<span>
{isEn ? "Quote status" : "报价状态"} <b>{marketDecisionView.status === "ready" ? (isEn ? "Ready" : "已同步") : marketDecisionView.status === "loading" ? (isEn ? "Loading" : "同步中") : (isEn ? "Unavailable" : "不可用")}</b>
</span>
</div>
</section>
<div className="scan-ai-city-analysis-grid">
<AiCityTemperatureChart detail={detail} />
<section className="scan-ai-city-section">
<div className="scan-ai-city-section-title">
{isEn ? "Evidence · AI airport read" : "证据 · AI 机场报文解读"}
</div>
{aiForecast.status === "loading" ? (
<>
<p className={aiForecast.streamText ? "scan-ai-weather-summary" : undefined}>
{aiForecast.streamText ||
(isEn
? "Deepseek V4 pro is reading the latest airport bulletin..."
: "Deepseek V4 pro 正在解读最新机场报文...")}
</p>
{aiForecast.streamText ? (
<p className="scan-ai-city-muted">
{isEn
? "Streaming airport read; final forecast is still being completed..."
: "机场报文解读正在流式输出,最终预报结论仍在补全..."}
</p>
) : null}
</>
) : aiForecast.status === "ready" && aiCityForecast ? (
<>
<p className="scan-ai-weather-summary">
{localizedFinalJudgment ||
(isEn ? "AI read returned without a final sentence." : "AI 已返回,但缺少最终判断。")}
</p>
<ul className="scan-ai-weather-bullets">
{aiBullets.map((line, index) => (
<li key={`${line}-${index}`}>{line}</li>
))}
</ul>
<p className="scan-ai-raw-metar">
{report
? `${isEn ? "Raw METAR" : "原始 METAR"}${`${airportStation} ${report}`.trim()}`
: isEn
? "Raw METAR: unavailable."
: "原始 METAR:暂无。"}
</p>
</>
) : aiForecast.status === "ready" ? (
<>
<p>
{aiForecast.payload?.status === "timeout"
? isEn
? "Deepseek V4-Pro timed out. You can retry; city data and the right briefing were not refreshed."
: "Deepseek V4-Pro 本次解读超时,可稍后重试;城市数据和右侧简报不会被刷新。"
: fallbackAiReason ||
(isEn
? "AI read is unavailable for this city right now."
: "该城市暂时没有可用的 AI 解读。")}
</p>
<ul className="scan-ai-weather-bullets">
<li>{localModelSupportNote}</li>
<li>
{report
? `${isEn ? "Raw METAR" : "原始 METAR"}${`${airportStation} ${report}`.trim()}`
: isEn
? "Raw METAR is unavailable."
: "暂无原始 METAR。"}
</li>
</ul>
</>
) : aiForecast.status === "failed" ? (
<>
<p>
{isEn
? "AI read failed. Model support and the raw METAR remain as fallback context."
: "AI 解读失败。下方保留多模型支撑和原始 METAR 作为兜底上下文。"}
{aiForecast.error ? ` ${aiForecast.error}` : ""}
</p>
<ul className="scan-ai-weather-bullets">
<li>{localModelSupportNote}</li>
<li>
{report
? `${isEn ? "Raw METAR" : "原始 METAR"}${`${airportStation} ${report}`.trim()}`
: isEn
? "Raw METAR is unavailable."
: "暂无原始 METAR。"}
</li>
</ul>
</>
) : (
<p>
{isEn
? "Waiting for AI to read the latest airport bulletin."
: "等待 AI 解读最新机场报文。"}
</p>
)}
</section>
</div>
<section className="scan-ai-city-section models">
<div className="scan-ai-city-section-title">
{isEn ? "Evidence · multi-model support" : "证据 · 多模型支撑"}
</div>
<ModelForecast detail={detail} targetDate={detail.local_date} hideTitle />
</section>
</div>
) : !detail ? (
<div className="scan-ai-city-loading">
<LoadingSignal
title={isEn ? "Loading city decision data" : "正在加载城市决策数据"}
description={
isEn
? "Hydrating todays model stack, METAR context and market layer."
: "正在补全今日模型、机场报文和市场价格层。"
}
compact
/>
</div>
) : null}
</article>
);
}
export function AiPinnedForecastView({
items,
rows,
detailsByName,
locale,
onRemoveCity,
}: {
items: AiPinnedCity[];
rows: ScanOpportunityRow[];
detailsByName: Record<string, CityDetail>;
locale: string;
onRemoveCity: (cityName: string) => void;
}) {
const isEn = locale === "en-US";
const [collapsedCities, setCollapsedCities] = useState<Set<string>>(
() => new Set(),
);
const [removingCities, setRemovingCities] = useState<Set<string>>(
() => new Set(),
);
const knownCityKeysRef = useRef<Set<string>>(new Set());
const removeTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
useEffect(() => {
const activeKeys = new Set(
items.map((item) => normalizeCityKey(item.cityName) || item.cityName),
);
setCollapsedCities((current) => {
const next = new Set<string>();
let changed = false;
current.forEach((key) => {
if (activeKeys.has(key)) {
next.add(key);
} else {
changed = true;
}
});
items.forEach((item) => {
const stableKey = normalizeCityKey(item.cityName) || item.cityName;
if (!knownCityKeysRef.current.has(stableKey)) {
next.add(stableKey);
changed = true;
}
});
return changed ? next : current;
});
knownCityKeysRef.current = activeKeys;
}, [items]);
useEffect(() => {
return () => {
removeTimersRef.current.forEach((timer) => clearTimeout(timer));
removeTimersRef.current.clear();
};
}, []);
const removeCityWithMotion = useCallback(
(item: AiPinnedCity, stableKey: string) => {
if (removeTimersRef.current.has(stableKey)) return;
setRemovingCities((current) => {
const next = new Set(current);
next.add(stableKey);
return next;
});
const timer = setTimeout(() => {
onRemoveCity(item.cityName);
setRemovingCities((current) => {
const next = new Set(current);
next.delete(stableKey);
return next;
});
removeTimersRef.current.delete(stableKey);
}, 260);
removeTimersRef.current.set(stableKey, timer);
},
[onRemoveCity],
);
if (!items.length) {
return (
<div className="scan-ai-workspace empty">
<div className="scan-empty-state">
<div className="scan-empty-title">
{isEn ? "Click a city on the map" : "从分布视图点击城市"}
</div>
<div className="scan-empty-copy">
{isEn
? "Selected cities will appear here as deep analysis blocks."
: "被点击的城市会加入深度分析页,并保留为城市分析区块。"}
</div>
</div>
</div>
);
}
return (
<div className="scan-ai-workspace">
<div className="scan-ai-workspace-head">
<div>
<span>{isEn ? "Selected city workspace" : "城市分析工作区"}</span>
<strong>
{isEn
? `${items.length} cities under deep analysis`
: `${items.length} 个城市正在深度分析`}
</strong>
</div>
<p>
{isEn
? "Map clicks add cities here. City analysis stays here until you remove it."
: "地图点击会把城市加入这里;城市分析会保留,直到你手动移除。"}
</p>
</div>
<div className="scan-ai-city-stack">
{items.map((item) => {
const detail = findDetailForCity(detailsByName, item.cityName);
const row = findRowForCity(rows, item.cityName);
const key = normalizeCityKey(item.cityName);
const stableKey = key || item.cityName;
const isKnownCity = knownCityKeysRef.current.has(stableKey);
return (
<AiPinnedCityCard
key={stableKey}
item={item}
detail={detail}
row={row}
locale={locale}
collapsed={!isKnownCity || collapsedCities.has(stableKey)}
removing={removingCities.has(stableKey)}
onRemove={() => removeCityWithMotion(item, stableKey)}
onToggleCollapsed={() => {
setCollapsedCities((current) => {
const next = new Set(current);
if (next.has(stableKey)) {
next.delete(stableKey);
} else {
next.add(stableKey);
}
return next;
});
}}
/>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,127 @@
import { useMemo } from "react";
import { getWindowPhaseMeta } from "@/components/dashboard/OpportunityTable";
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
import { formatTemperatureValue } from "@/lib/dashboard-utils";
import { formatShortDate, getPeakCountdownMeta } from "@/components/dashboard/scan-terminal/decision-utils";
export function CalendarView({
rows,
locale,
selectedRowId,
onSelectRow,
}: {
rows: ScanOpportunityRow[];
locale: string;
selectedRowId: string | null;
onSelectRow: (row: ScanOpportunityRow) => void;
}) {
const groups = useMemo(() => {
const order = ["active", "next", "today", "later", "past"];
const byPhase = new Map<
string,
{
label: string;
sort: number;
items: Array<{ row: ScanOpportunityRow; meta: ReturnType<typeof getPeakCountdownMeta> }>;
}
>();
rows.forEach((row) => {
const meta = getPeakCountdownMeta(row, locale);
const current = byPhase.get(meta.key) || {
label: meta.groupLabel,
sort: order.indexOf(meta.key) >= 0 ? order.indexOf(meta.key) : order.length,
items: [],
};
current.items.push({ row, meta });
byPhase.set(meta.key, current);
});
return Array.from(byPhase.entries())
.sort(([, left], [, right]) => left.sort - right.sort)
.map(([key, group]) => ({
key,
label: group.label,
items: group.items.sort((left, right) => {
if (left.meta.sort !== right.meta.sort) return left.meta.sort - right.meta.sort;
return Number(right.row.edge_percent || 0) - Number(left.row.edge_percent || 0);
}),
}));
}, [locale, rows]);
if (!groups.length) {
return (
<div className="scan-empty-state compact">
<div className="scan-empty-title">
{locale === "en-US" ? "No dated opportunities" : "当前没有日期机会"}
</div>
</div>
);
}
return (
<div className="scan-calendar-view">
{groups.map((group) => (
<section key={group.key} className="scan-calendar-group">
<div className="scan-calendar-group-head">
<div>
<div className="scan-calendar-date">{group.label}</div>
<div className="scan-calendar-subtitle">
{locale === "en-US"
? "Ordered by DEB peak-window countdown"
: "按 DEB 峰值窗口倒计时排序"}
</div>
</div>
<div className="scan-calendar-count">
{locale === "en-US" ? `${group.items.length} rows` : `${group.items.length}`}
</div>
</div>
<div className="scan-calendar-grid">
{group.items.map(({ row, meta }) => (
<button
key={row.id}
type="button"
className={`scan-calendar-card peak-${meta.tone} ${selectedRowId === row.id ? "selected" : ""}`}
onClick={() => onSelectRow(row)}
>
{(() => {
const tempSymbol = row.temp_symbol || "°C";
const phaseMeta = getWindowPhaseMeta(row, locale);
return (
<>
<div className="scan-calendar-city">
{getLocalizedCityName(
row.city,
row.city_display_name || row.display_name || row.city,
locale,
)}
</div>
<div className="scan-calendar-countdown">
{meta.title}
<small>{meta.detail}</small>
</div>
<div className="scan-calendar-action">
<span>{locale === "en-US" ? "DEB high" : "DEB 预测高点"}</span>
<b>
{row.deb_prediction != null
? formatTemperatureValue(row.deb_prediction, tempSymbol)
: "--"}
</b>
</div>
<div className="scan-calendar-meta">
<span>
{formatShortDate(row.selected_date || row.local_date, locale)} · {row.local_time || "--"}
</span>
<span>{phaseMeta.label}</span>
</div>
</>
);
})()}
</button>
))}
</div>
</section>
))}
</div>
);
}
@@ -0,0 +1,41 @@
import clsx from "clsx";
export function LoadingSignal({
title,
description,
compact = false,
}: {
title: string;
description?: string;
compact?: boolean;
}) {
return (
<div
className={clsx("scan-loading-signal", compact && "compact")}
role="status"
aria-live="polite"
>
<div className="scan-loading-decision-flow" aria-hidden="true">
<span className="scan-loading-node hot" />
<span className="scan-loading-rail">
<i />
</span>
<span className="scan-loading-node market" />
<span className="scan-loading-rail">
<i />
</span>
<span className="scan-loading-node action" />
</div>
<div className="scan-loading-copy-block">
<strong>{title}</strong>
{description ? <span>{description}</span> : null}
</div>
<div className="scan-loading-signal-bars" aria-hidden="true">
<span />
<span />
<span />
<span />
</div>
</div>
);
}
@@ -0,0 +1,213 @@
import { useMemo } from "react";
import clsx from "clsx";
import type { ScanOpportunityRow, ScanTerminalResponse } from "@/lib/dashboard-types";
import { formatTemperatureValue } from "@/lib/dashboard-utils";
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
import {
formatRowPrice,
formatRowProbability,
formatRowSignedPercent,
getPeakCountdownMeta,
getRowDecisionMeta,
getRowTemperatureBucket,
pickOpportunitySections,
} from "@/components/dashboard/scan-terminal/decision-utils";
function OpportunityDecisionCard({
row,
locale,
selected,
onOpenDecision,
onSelectRow,
}: {
row: ScanOpportunityRow;
locale: string;
selected: boolean;
onOpenDecision: (row: ScanOpportunityRow) => void;
onSelectRow: (row: ScanOpportunityRow) => void;
}) {
const isEn = locale === "en-US";
const displayName = row.city_display_name || row.display_name || row.city;
const unit = row.temp_symbol || row.target_unit || "°C";
const decision = getRowDecisionMeta(row, locale);
const phase = getPeakCountdownMeta(row, locale);
const modelProb = row.model_event_probability ?? row.model_probability ?? row.peak_probability ?? null;
const marketProb = row.market_event_probability ?? row.market_probability ?? null;
const price = row.yes_ask ?? row.ask ?? row.yes_bid ?? row.bid ?? row.midpoint ?? null;
const confidence =
row.ai_confidence ||
row.ai_city_confidence ||
row.ai_forecast_confidence ||
(row.signal_confidence != null ? formatRowProbability(row.signal_confidence) : "--");
const predicted =
row.ai_predicted_max ??
row.deb_prediction ??
row.cluster_center ??
row.current_max_so_far ??
null;
const reason = decision.reason.length > 128 ? `${decision.reason.slice(0, 125)}` : decision.reason;
return (
<article
className={clsx("scan-opportunity-decision-card", decision.tone, selected && "selected")}
onClick={() => onSelectRow(row)}
>
<div className="scan-opportunity-decision-head">
<div>
<span>{phase.title}</span>
<strong>{displayName}</strong>
</div>
<b>{decision.action}</b>
</div>
<div className="scan-opportunity-decision-primary">
<span>
{isEn ? "Forecast high" : "预测最高温"}
<b>{predicted != null ? formatTemperatureValue(predicted, unit, { digits: 1 }) : "--"}</b>
</span>
<span>
{isEn ? "Bucket" : "推荐温度桶"}
<b>{getRowTemperatureBucket(row)}</b>
</span>
<span>
{isEn ? "Edge" : "概率差"}
<b>{formatRowSignedPercent(row.edge_percent ?? row.gap ?? row.signed_gap)}</b>
</span>
</div>
<p>{reason}</p>
<div className="scan-opportunity-decision-foot">
<small>{isEn ? "Model" : "模型"} {formatRowProbability(modelProb)}</small>
<small>{isEn ? "Market" : "市场"} {formatRowProbability(marketProb)}</small>
<small>{isEn ? "YES" : "YES"} {formatRowPrice(price)}</small>
<small>{isEn ? "Confidence" : "信心"} {confidence}</small>
</div>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onOpenDecision(row);
}}
>
{isEn ? "Open decision card" : "打开决策卡"}
</button>
</article>
);
}
export function OpportunityOverview({
rows,
terminalData,
loading,
error,
locale,
selectedRowId,
onOpenDecision,
onSelectRow,
onOpenMap,
}: {
rows: ScanOpportunityRow[];
terminalData: ScanTerminalResponse | null;
loading: boolean;
error: string | null;
locale: string;
selectedRowId: string | null;
onOpenDecision: (row: ScanOpportunityRow) => void;
onSelectRow: (row: ScanOpportunityRow) => void;
onOpenMap: () => void;
}) {
const isEn = locale === "en-US";
const sections = useMemo(() => pickOpportunitySections(rows, locale), [locale, rows]);
const visibleSections = sections.filter((section) => section.rows.length > 0);
const summary = terminalData?.summary;
if (loading) {
return (
<div className="scan-opportunity-overview loading">
<LoadingSignal
title={isEn ? "Building todays opportunity board" : "正在生成今日机会榜"}
description={
isEn
? "Syncing market prices, model edge and peak-window timing."
: "正在同步市场价格、模型概率差和峰值窗口。"
}
compact
/>
</div>
);
}
if (error || rows.length === 0) {
return (
<div className="scan-opportunity-overview empty">
<strong>{isEn ? "No opportunity snapshot yet" : "暂无机会快照"}</strong>
<p>
{error ||
(isEn
? "Use the map to add cities, or refresh after the scan backend is ready."
: "可以先用地图添加城市;扫描后端就绪后会显示今日机会榜。")}
</p>
<button type="button" onClick={onOpenMap}>
{isEn ? "Explore map" : "去地图探索"}
</button>
</div>
);
}
return (
<div className="scan-opportunity-overview">
<div className="scan-opportunity-hero">
<div>
<span>{isEn ? "Today AI opportunity board" : "今日 AI 机会榜"}</span>
<strong>{isEn ? "Decide first, verify second" : "先看决策,再展开证据"}</strong>
<p>
{isEn
? "Cards translate weather, METAR and Polymarket pricing into action states."
: "把天气、METAR 与 Polymarket 报价先翻译成行动状态,再让你展开验证。"}
</p>
</div>
<div className="scan-opportunity-summary">
<span>{isEn ? "Candidates" : "候选"} <b>{summary?.candidate_total ?? rows.length}</b></span>
<span>{isEn ? "Tradable" : "可交易市场"} <b>{summary?.tradable_market_count ?? "--"}</b></span>
<span>{isEn ? "Avg edge" : "平均概率差"} <b>{formatRowSignedPercent(summary?.avg_edge_percent)}</b></span>
<span>
{isEn ? "Updated" : "更新时间"}{" "}
<b>
{terminalData?.generated_at
? new Date(terminalData.generated_at).toLocaleTimeString(isEn ? "en-US" : "zh-CN", {
hour: "2-digit",
minute: "2-digit",
})
: "--"}
</b>
</span>
</div>
</div>
<div className="scan-opportunity-lanes">
{visibleSections.map((section) => (
<section key={section.key} className="scan-opportunity-lane">
<div className="scan-opportunity-lane-head">
<div>
<strong>{section.title}</strong>
<p>{section.subtitle}</p>
</div>
<span>{section.rows.length}</span>
</div>
<div className="scan-opportunity-card-grid">
{section.rows.map((row) => (
<OpportunityDecisionCard
key={`${section.key}-${row.id}`}
row={row}
locale={locale}
selected={selectedRowId === row.id}
onOpenDecision={onOpenDecision}
onSelectRow={onSelectRow}
/>
))}
</div>
</section>
))}
</div>
</div>
);
}
@@ -0,0 +1,146 @@
const AI_CITY_MAX_CONCURRENT = 2;
let aiCityActiveCount = 0;
const aiCityPendingQueue: Array<() => void> = [];
function createAiCityAbortError() {
return new DOMException("The AI city request was aborted.", "AbortError");
}
function drainAiCityFetchQueue() {
while (aiCityActiveCount < AI_CITY_MAX_CONCURRENT && aiCityPendingQueue.length) {
const next = aiCityPendingQueue.shift();
next?.();
}
}
export function enqueueAiCityFetch<T>(
task: () => Promise<T>,
signal: AbortSignal,
callbacks?: {
onQueued?: () => void;
onStart?: () => void;
},
): Promise<T> {
return new Promise<T>((resolve, reject) => {
let started = false;
let queuedStart: (() => void) | null = null;
const cleanup = () => {
signal.removeEventListener("abort", handleAbort);
};
const removeQueuedStart = () => {
if (!queuedStart) return;
const index = aiCityPendingQueue.indexOf(queuedStart);
if (index >= 0) {
aiCityPendingQueue.splice(index, 1);
}
queuedStart = null;
};
const finishActive = () => {
aiCityActiveCount = Math.max(0, aiCityActiveCount - 1);
cleanup();
drainAiCityFetchQueue();
};
const handleAbort = () => {
if (started) return;
removeQueuedStart();
cleanup();
reject(createAiCityAbortError());
drainAiCityFetchQueue();
};
const start = () => {
queuedStart = null;
if (signal.aborted) {
cleanup();
reject(createAiCityAbortError());
drainAiCityFetchQueue();
return;
}
started = true;
aiCityActiveCount += 1;
callbacks?.onStart?.();
task()
.then(resolve, reject)
.finally(finishActive);
};
signal.addEventListener("abort", handleAbort, { once: true });
queuedStart = start;
if (aiCityActiveCount < AI_CITY_MAX_CONCURRENT) {
start();
} else {
callbacks?.onQueued?.();
aiCityPendingQueue.push(start);
}
});
}
export function parseSseBlock(block: string): { event: string; data: unknown } | null {
const lines = block.split(/\r?\n/);
let event = "message";
const dataLines: string[] = [];
for (const line of lines) {
if (line.startsWith("event:")) {
event = line.slice("event:".length).trim() || "message";
} else if (line.startsWith("data:")) {
dataLines.push(line.slice("data:".length).trimStart());
}
}
if (!dataLines.length) return null;
const raw = dataLines.join("\n");
try {
return { event, data: JSON.parse(raw) };
} catch {
return { event, data: raw };
}
}
function decodeJsonStringFragment(fragment: string) {
const safe = fragment.replace(/\\$/g, "");
try {
return JSON.parse(`"${safe.replace(/"/g, '\\"')}"`) as string;
} catch {
return safe
.replace(/\\"/g, '"')
.replace(/\\n/g, "\n")
.replace(/\\r/g, "\r")
.replace(/\\t/g, "\t")
.replace(/\\\\/g, "\\");
}
}
function extractStreamingJsonField(raw: string, field: string) {
const keyIndex = raw.indexOf(`"${field}"`);
if (keyIndex < 0) return "";
const colonIndex = raw.indexOf(":", keyIndex);
if (colonIndex < 0) return "";
const quoteIndex = raw.indexOf('"', colonIndex + 1);
if (quoteIndex < 0) return "";
let end = raw.length;
let escaped = false;
for (let i = quoteIndex + 1; i < raw.length; i += 1) {
const char = raw[i];
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === '"') {
end = i;
break;
}
}
return decodeJsonStringFragment(raw.slice(quoteIndex + 1, end)).trim();
}
export function extractStreamingAirportRead(raw: string, locale: string) {
const primaryField = locale === "en-US" ? "metar_read_en" : "metar_read_zh";
const fallbackField = locale === "en-US" ? "metar_read_zh" : "metar_read_en";
return (
extractStreamingJsonField(raw, primaryField) ||
extractStreamingJsonField(raw, fallbackField)
);
}
@@ -0,0 +1,370 @@
import type { MarketScan, MarketTopBucket } from "@/lib/dashboard-types";
import { formatTemperatureValue, getTodayPaceView } from "@/lib/dashboard-utils";
import type { AiCityForecastPayload } from "@/components/dashboard/scan-terminal/types";
export type WeatherDecisionView = {
action: string;
confidence: string;
expectedHigh: string;
kicker: string;
reasons: string[];
risk: string;
targetRange: string;
tone: "cold" | "neutral" | "warm" | "watch";
};
export type MarketDecisionView = {
bucketLabel: string;
confidence: string;
edgeText: string;
impliedText: string;
marketUrl?: string | null;
modelText: string;
priceText: string;
reason: string;
status: "loading" | "ready" | "unavailable";
title: string;
tone: "cold" | "neutral" | "warm" | "watch";
};
export function normalizeMarketProbability(value: unknown) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
if (numeric > 1) return numeric / 100;
if (numeric < 0) return null;
return numeric;
}
export function formatMarketPercent(value: number | null, digits = 1) {
if (value == null || !Number.isFinite(value)) return "--";
return `${(value * 100).toFixed(digits)}%`;
}
export function formatMarketCents(value: number | null) {
if (value == null || !Number.isFinite(value)) return "--";
return `${Math.round(value * 100)}¢`;
}
export function formatSignedMarketPercent(value: number | null) {
if (value == null || !Number.isFinite(value)) return "--";
const sign = value > 0 ? "+" : "";
return `${sign}${(value * 100).toFixed(1)}%`;
}
export function normalizeMarketComparableTemp(
displayTemp: number | null,
tempSymbol: string,
bucket?: MarketTopBucket | null,
) {
if (displayTemp == null || !Number.isFinite(displayTemp)) return null;
const bucketUnit = String(bucket?.unit || "").trim().toUpperCase();
const isDisplayF = String(tempSymbol || "").toUpperCase().includes("F");
if (bucketUnit === "F" && !isDisplayF) return displayTemp * 1.8 + 32;
if (bucketUnit === "C" && isDisplayF) return (displayTemp - 32) / 1.8;
return displayTemp;
}
export function getMarketBucketLabel(bucket?: MarketTopBucket | null, tempSymbol = "°C") {
const direct = String(bucket?.label || "").trim();
if (direct) return direct;
const value = bucket?.temp ?? bucket?.value ?? bucket?.lower ?? null;
const numeric = Number(value);
if (Number.isFinite(numeric)) {
const unit = bucket?.unit
? `°${String(bucket.unit).replace(/^°/, "").toUpperCase()}`
: tempSymbol;
return `${numeric.toFixed(0)}${unit}`;
}
return "--";
}
export function pickMarketBucketForWeatherCenter(
scan: MarketScan | null | undefined,
expectedHigh: number | null,
tempSymbol: string,
) {
const buckets = (
Array.isArray(scan?.all_buckets)
? scan?.all_buckets
: Array.isArray(scan?.top_buckets)
? scan?.top_buckets
: []
) as MarketTopBucket[];
if (!buckets.length || expectedHigh == null || !Number.isFinite(expectedHigh)) {
return null;
}
let nearest: MarketTopBucket | null = null;
let nearestDelta = Number.POSITIVE_INFINITY;
for (const bucket of buckets) {
const comparable = normalizeMarketComparableTemp(expectedHigh, tempSymbol, bucket);
if (comparable == null) continue;
const lower = bucket.lower != null ? Number(bucket.lower) : null;
const upper = bucket.upper != null ? Number(bucket.upper) : null;
if (
lower != null &&
upper != null &&
Number.isFinite(lower) &&
Number.isFinite(upper) &&
comparable >= lower - 0.01 &&
comparable <= upper + 0.01
) {
return bucket;
}
const anchor = Number(bucket.temp ?? bucket.value ?? bucket.lower);
if (!Number.isFinite(anchor)) continue;
const delta = Math.abs(anchor - comparable);
if (delta < nearestDelta) {
nearest = bucket;
nearestDelta = delta;
}
}
return nearest;
}
export function buildMarketDecisionView({
expectedHigh,
isEn,
marketScan,
marketStatus,
tempSymbol,
}: {
expectedHigh: number | null;
isEn: boolean;
marketScan: MarketScan | null;
marketStatus: "idle" | "loading" | "ready" | "failed";
tempSymbol: string;
}): MarketDecisionView {
if (marketStatus === "loading") {
return {
bucketLabel: "--",
confidence: "--",
edgeText: "--",
impliedText: "--",
modelText: "--",
priceText: "--",
reason: isEn
? "Fetching the existing Polymarket quote layer for this city."
: "正在读取项目内已有的 Polymarket 价格层。",
status: "loading",
title: isEn ? "Syncing market price" : "正在同步市场价格",
tone: "watch",
};
}
if (!marketScan?.available) {
return {
bucketLabel: "--",
confidence: "--",
edgeText: "--",
impliedText: "--",
modelText: "--",
priceText: "--",
reason:
marketScan?.reason ||
(isEn
? "No active matched Polymarket temperature market is available for this city yet."
: "该城市暂未匹配到可用的 Polymarket 温度市场。"),
status: "unavailable",
title: isEn ? "No market quote" : "暂无市场报价",
tone: "watch",
};
}
const bucket = pickMarketBucketForWeatherCenter(marketScan, expectedHigh, tempSymbol);
const bucketProbability = normalizeMarketProbability(bucket?.probability);
const scanProbability = normalizeMarketProbability(marketScan.model_probability);
const modelProbability = bucketProbability ?? scanProbability;
const yesBuy =
normalizeMarketProbability(bucket?.yes_buy) ??
normalizeMarketProbability(bucket?.market_price) ??
normalizeMarketProbability(marketScan.yes_buy) ??
normalizeMarketProbability(marketScan.market_price);
const yesSell =
normalizeMarketProbability(bucket?.yes_sell) ??
normalizeMarketProbability(marketScan.yes_sell);
const implied = yesBuy ?? yesSell ?? null;
const edge =
modelProbability != null && implied != null ? modelProbability - implied : null;
const tone =
edge == null
? "neutral"
: edge >= 0.08
? "warm"
: edge <= -0.08
? "cold"
: "neutral";
const title =
edge == null
? isEn
? "Market quote matched"
: "已匹配市场报价"
: edge >= 0.08
? isEn
? "Weather probability above market"
: "天气概率高于市场报价"
: edge <= -0.08
? isEn
? "Market already prices this in"
: "市场价格已偏充分"
: isEn
? "Price near weather probability"
: "价格接近天气概率";
return {
bucketLabel: getMarketBucketLabel(bucket, tempSymbol),
confidence: marketScan.confidence || "--",
edgeText: formatSignedMarketPercent(edge),
impliedText: formatMarketPercent(implied),
marketUrl:
bucket?.slug
? `https://polymarket.com/market/${bucket.slug}`
: marketScan.market_url || marketScan.primary_market_url || null,
modelText: formatMarketPercent(modelProbability),
priceText: formatMarketCents(yesBuy),
reason:
edge == null
? isEn
? "Quote is available, but model probability or YES price is incomplete."
: "已获取报价,但模型概率或 YES 价格不完整。"
: isEn
? `Model probability is ${formatMarketPercent(modelProbability)} versus market-implied ${formatMarketPercent(implied)}.`
: `模型概率 ${formatMarketPercent(modelProbability)},市场隐含约 ${formatMarketPercent(implied)}`,
status: "ready",
title,
tone,
};
}
export 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,
};
}
@@ -0,0 +1,48 @@
import type { CityDetail } from "@/lib/dashboard-types";
import { normalizeCityKey } from "@/components/dashboard/scan-terminal/decision-utils";
export function findDetailForCity(
detailsByName: Record<string, CityDetail>,
cityName?: string | null,
) {
const target = normalizeCityKey(cityName);
if (!target) return null;
return (
Object.values(detailsByName).find((detail) =>
[detail?.name, detail?.display_name].some(
(value) => normalizeCityKey(value) === target,
),
) || null
);
}
export function countDetailModels(detail?: CityDetail | null, targetDate?: string | null) {
if (!detail) return 0;
const date = String(targetDate || detail.local_date || "").trim();
const dailyModels = date ? detail.multi_model_daily?.[date]?.models : null;
const models =
dailyModels && typeof dailyModels === "object"
? dailyModels
: detail.multi_model || {};
return Object.values(models).filter((value) =>
Number.isFinite(Number(value)),
).length;
}
export function countDetailForecastDays(detail?: CityDetail | null) {
const daily = detail?.forecast?.daily;
return Array.isArray(daily) ? daily.length : 0;
}
export function isFullEnoughForDeepAnalysis(detail?: CityDetail | null) {
if (!detail) return false;
if (detail.detail_depth && detail.detail_depth !== "full") return false;
return (
countDetailModels(detail, detail.local_date) > 1 &&
countDetailForecastDays(detail) > 1
);
}
export function waitForDeepAnalysisQueue(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
@@ -0,0 +1,372 @@
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import {
getMarketFocus,
getRowMarketRegion,
getRowPeakSortValue,
} from "@/lib/scan-market-focus";
export function formatShortDate(value?: string | null, locale = "zh-CN") {
const text = String(value || "").trim();
if (!text) return "--";
const date = new Date(`${text}T00:00:00`);
if (Number.isNaN(date.getTime())) return text;
return locale === "en-US"
? date.toLocaleDateString("en-US", { month: "short", day: "numeric" })
: date.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" });
}
export function formatCountdownMinutes(value?: number | null, locale = "zh-CN") {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return "--";
const minutes = Math.max(0, Math.round(Math.abs(numeric)));
const hours = Math.floor(minutes / 60);
const remains = minutes % 60;
if (locale === "en-US") {
if (hours <= 0) return `${remains}m`;
if (remains <= 0) return `${hours}h`;
return `${hours}h ${remains}m`;
}
if (hours <= 0) return `${remains} 分钟`;
if (remains <= 0) return `${hours} 小时`;
return `${hours} 小时 ${remains} 分钟`;
}
export function getPeakWindowLabel(row: ScanOpportunityRow) {
const direct = String(row.peak_window_label || "").trim();
if (direct) return direct;
const start = String(row.peak_window_start || "").trim();
const end = String(row.peak_window_end || "").trim();
if (start && end) return `${start}-${end}`;
return "--";
}
export function getPeakCountdownMeta(row: ScanOpportunityRow, locale = "zh-CN") {
const isEn = locale === "en-US";
const phase = String(row.window_phase || "").toLowerCase();
const startDelta = Number(row.minutes_until_peak_start);
const endDelta = Number(row.minutes_until_peak_end);
const hasStart = Number.isFinite(startDelta);
const hasEnd = Number.isFinite(endDelta);
if (phase === "active_peak" || (hasStart && startDelta <= 0 && hasEnd && endDelta >= 0)) {
return {
key: "active",
groupLabel: isEn ? "Peak window now" : "峰值窗口进行中",
tone: "active",
sort: 0,
title: isEn ? "At peak window" : "已进入峰值窗口",
detail:
hasEnd && endDelta >= 0
? isEn
? `${formatCountdownMinutes(endDelta, locale)} left`
: `剩余 ${formatCountdownMinutes(endDelta, locale)}`
: getPeakWindowLabel(row),
};
}
if (hasStart && startDelta > 0 && startDelta <= 180) {
return {
key: "next",
groupLabel: isEn ? "Next 3 hours" : "未来 3 小时到峰值",
tone: "next",
sort: 1000 + startDelta,
title: isEn
? `${formatCountdownMinutes(startDelta, locale)} to peak`
: `还有 ${formatCountdownMinutes(startDelta, locale)} 到峰值`,
detail: getPeakWindowLabel(row),
};
}
if (hasStart && startDelta > 0 && startDelta <= 1440) {
return {
key: "today",
groupLabel: isEn ? "Later today" : "今日稍后",
tone: "upcoming",
sort: 2000 + startDelta,
title: isEn
? `${formatCountdownMinutes(startDelta, locale)} to peak`
: `还有 ${formatCountdownMinutes(startDelta, locale)} 到峰值`,
detail: getPeakWindowLabel(row),
};
}
if (hasStart && startDelta > 1440) {
return {
key: "later",
groupLabel: isEn ? "Later sessions" : "后续交易时段",
tone: "later",
sort: 3000 + startDelta,
title: isEn
? `${formatCountdownMinutes(startDelta, locale)} to peak`
: `还有 ${formatCountdownMinutes(startDelta, locale)} 到峰值`,
detail: getPeakWindowLabel(row),
};
}
return {
key: "past",
groupLabel: isEn ? "Past peak" : "峰值已过",
tone: "past",
sort: 9000 + Math.abs(startDelta || 0),
title:
hasEnd && endDelta < 0
? isEn
? `Peak passed ${formatCountdownMinutes(endDelta, locale)} ago`
: `峰值已过 ${formatCountdownMinutes(endDelta, locale)}`
: isEn
? "Peak window passed"
: "峰值窗口已过",
detail: getPeakWindowLabel(row),
};
}
export function formatUserLocalTime() {
const now = new Date();
return `${String(now.getHours()).padStart(2, "0")}:${String(
now.getMinutes(),
).padStart(2, "0")}`;
}
export function getLocalDateIndex(value?: string | null) {
const text = String(value || "").trim();
if (!text) return 0;
const date = new Date(`${text}T00:00:00`);
if (Number.isNaN(date.getTime())) return 0;
const today = new Date();
today.setHours(0, 0, 0, 0);
return Math.round((date.getTime() - today.getTime()) / 86_400_000);
}
export function getPhaseUrgency(row: ScanOpportunityRow) {
const phase = String(row.window_phase || "").toLowerCase();
if (phase === "active_peak") return 0;
if (phase === "setup_today") return 1;
if (phase === "post_peak") return 2;
if (phase === "early_today") return 3;
if (phase === "tomorrow") return 4;
if (phase === "week_ahead") return 5;
return 6;
}
export function sortRowsByUserTime(rows: ScanOpportunityRow[]) {
const focus = getMarketFocus(rows);
return [...rows].sort((left, right) => {
if (focus) {
const leftFocusRank = getRowMarketRegion(left) === focus.key ? 0 : 1;
const rightFocusRank = getRowMarketRegion(right) === focus.key ? 0 : 1;
if (leftFocusRank !== rightFocusRank) return leftFocusRank - rightFocusRank;
}
const leftPeakSort = getRowPeakSortValue(left);
const rightPeakSort = getRowPeakSortValue(right);
if (leftPeakSort.stage.rank !== rightPeakSort.stage.rank) {
return leftPeakSort.stage.rank - rightPeakSort.stage.rank;
}
if (leftPeakSort.countdown !== rightPeakSort.countdown) {
return leftPeakSort.countdown - rightPeakSort.countdown;
}
const leftDateIndex = getLocalDateIndex(left.selected_date || left.local_date);
const rightDateIndex = getLocalDateIndex(right.selected_date || right.local_date);
if (leftDateIndex !== rightDateIndex) return leftDateIndex - rightDateIndex;
const leftRemaining = Number.isFinite(Number(left.remaining_window_minutes))
? Number(left.remaining_window_minutes)
: Number.POSITIVE_INFINITY;
const rightRemaining = Number.isFinite(Number(right.remaining_window_minutes))
? Number(right.remaining_window_minutes)
: Number.POSITIVE_INFINITY;
if (leftRemaining !== rightRemaining) return leftRemaining - rightRemaining;
const leftPhase = getPhaseUrgency(left);
const rightPhase = getPhaseUrgency(right);
if (leftPhase !== rightPhase) return leftPhase - rightPhase;
const scoreDelta = Number(right.final_score || 0) - Number(left.final_score || 0);
if (scoreDelta !== 0) return scoreDelta;
return Number(right.edge_percent || 0) - Number(left.edge_percent || 0);
});
}
export function normalizeCityKey(value?: string | null) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[\s_-]+/g, "");
}
export function prettifyCityName(value?: string | null) {
return String(value || "")
.trim()
.replace(/[-_]+/g, " ")
.replace(/\b\w/g, (match) => match.toUpperCase());
}
export function rowMatchesCity(row: ScanOpportunityRow, cityName: string) {
const cityKey = normalizeCityKey(cityName);
if (!cityKey) return false;
return [row.city, row.city_display_name, row.display_name].some(
(value) => normalizeCityKey(value) === cityKey,
);
}
export function findRowForCity(rows: ScanOpportunityRow[], cityName?: string | null) {
const normalized = normalizeCityKey(cityName);
if (!normalized) return null;
return rows.find((row) => rowMatchesCity(row, cityName || "")) || null;
}
export function formatRowProbability(value?: number | null) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return "--";
const normalized = numeric > 1 ? numeric / 100 : numeric;
return `${(normalized * 100).toFixed(0)}%`;
}
export function formatRowPrice(value?: number | null) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return "--";
return `${Math.round((numeric > 1 ? numeric / 100 : numeric) * 100)}¢`;
}
export function formatRowSignedPercent(value?: number | null) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return "--";
const normalized = Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
return `${normalized > 0 ? "+" : ""}${normalized.toFixed(1)}%`;
}
export function normalizeRowPercentDelta(value?: number | null) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
return Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
}
export function getRowTemperatureBucket(row: ScanOpportunityRow) {
const direct = String(row.target_label || "").trim();
if (direct) return direct;
const unit = row.target_unit || row.temp_symbol || "°C";
const lower = Number(row.target_lower);
const upper = Number(row.target_upper);
if (Number.isFinite(lower) && Number.isFinite(upper)) {
return `${lower.toFixed(0)}-${upper.toFixed(0)}${unit}`;
}
const threshold = Number(row.target_threshold ?? row.target_value);
if (Number.isFinite(threshold)) {
const direction = String(row.temperature_direction || row.market_direction || row.side || "").toLowerCase();
if (direction.includes("below") || direction.includes("under") || direction.includes("no")) {
return `${threshold.toFixed(0)}${unit}`;
}
return `${threshold.toFixed(0)}${unit}`;
}
return "--";
}
export function getRowDecisionMeta(row: ScanOpportunityRow, locale = "zh-CN") {
const isEn = locale === "en-US";
const edge = normalizeRowPercentDelta(row.edge_percent ?? row.gap ?? row.signed_gap);
const phase = getPeakCountdownMeta(row, locale);
const metarDecision = String(row.v4_metar_decision || row.ai_decision || "").toLowerCase();
const tradable = Boolean(row.tradable || row.accepting_orders);
const closed = row.closed || (row.active === false && !tradable);
if (closed) {
return {
tone: "avoid",
action: isEn ? "Skip" : "放弃",
reason: isEn ? "Market is closed or inactive." : "市场已关闭或不活跃。",
};
}
if (metarDecision === "veto") {
return {
tone: "avoid",
action: isEn ? "Avoid" : "暂不交易",
reason:
(isEn ? row.v4_metar_reason_en || row.ai_reason_en : row.v4_metar_reason_zh || row.ai_reason_zh) ||
(isEn ? "METAR does not support the setup." : "METAR 暂不支持该方向。"),
};
}
if (edge != null && edge >= 8 && tradable) {
return {
tone: "trade",
action: isEn ? "Watch now" : "重点关注",
reason:
(isEn ? row.ai_reason_en || row.ai_city_thesis_en : row.ai_reason_zh || row.ai_city_thesis_zh) ||
(isEn ? "Weather probability is above market pricing." : "天气概率高于市场隐含概率。"),
};
}
if (phase.key === "next" || phase.key === "today") {
return {
tone: "wait",
action: isEn ? "Wait for confirmation" : "等待确认",
reason:
(isEn ? row.ai_watchlist_reason_en || row.ai_forecast_match_reason_en : row.ai_watchlist_reason_zh || row.ai_forecast_match_reason_zh) ||
phase.title,
};
}
if (metarDecision === "downgrade" || row.risk_level === "high") {
return {
tone: "risk",
action: isEn ? "Observe only" : "只观察",
reason:
(isEn ? row.v4_metar_reason_en || row.ai_reason_en : row.v4_metar_reason_zh || row.ai_reason_zh) ||
(isEn ? "Risk is elevated; require more confirmation." : "风险偏高,需要更多确认。"),
};
}
return {
tone: "neutral",
action: isEn ? "Review" : "观察",
reason:
(isEn ? row.ai_reason_en || row.ai_city_thesis_en : row.ai_reason_zh || row.ai_city_thesis_zh) ||
(isEn ? "Open the decision card to verify weather evidence." : "打开决策卡查看天气证据。"),
};
}
export function pickOpportunitySections(rows: ScanOpportunityRow[], locale = "zh-CN") {
const isEn = locale === "en-US";
const top = [...rows]
.sort((left, right) => {
const scoreDelta = Number(right.final_score || 0) - Number(left.final_score || 0);
if (scoreDelta !== 0) return scoreDelta;
return Number(right.edge_percent || 0) - Number(left.edge_percent || 0);
})
.slice(0, 4);
const peak = rows
.filter((row) => {
const meta = getPeakCountdownMeta(row, locale);
return meta.key === "active" || meta.key === "next";
})
.slice(0, 4);
const model = rows
.filter((row) => Number(row.cluster_model_count || 0) >= 4 || Number(row.consensus_score || 0) >= 0.65)
.slice(0, 4);
const risk = rows
.filter((row) => row.risk_level === "high" || ["veto", "downgrade"].includes(String(row.v4_metar_decision || row.ai_decision || "").toLowerCase()))
.slice(0, 4);
return [
{
key: "top",
title: isEn ? "Best opportunities" : "最值得关注",
subtitle: isEn ? "Sorted by final score and edge." : "按综合分与概率差优先排序。",
rows: top,
},
{
key: "peak",
title: isEn ? "Peak window soon" : "即将进入峰值窗口",
subtitle: isEn ? "Timing-sensitive cities." : "需要卡时间确认的城市。",
rows: peak,
},
{
key: "model",
title: isEn ? "Model consensus" : "模型高度一致",
subtitle: isEn ? "Weather side has stronger model support." : "天气侧模型支撑更集中。",
rows: model,
},
{
key: "risk",
title: isEn ? "High risk / avoid" : "高风险 / 不要碰",
subtitle: isEn ? "Open only for post-mortem or monitoring." : "仅适合复盘或观察。",
rows: risk,
},
];
}
@@ -0,0 +1,39 @@
export type AiPinnedCity = {
cityName: string;
displayName?: string | null;
addedAt: number;
};
export type AiCityForecastPayload = {
status?: string | null;
reason?: string | null;
reason_zh?: string | null;
reason_en?: string | null;
raw_reason?: string | null;
model?: string | null;
provider?: string | null;
city_forecast?: {
predicted_max?: number | string | null;
range_low?: number | string | null;
range_high?: number | string | null;
unit?: string | null;
confidence?: string | null;
final_judgment_zh?: string | null;
final_judgment_en?: string | null;
metar_read_zh?: string | null;
metar_read_en?: string | null;
reasoning_zh?: string | null;
reasoning_en?: string | null;
risks_zh?: string[] | null;
risks_en?: string[] | null;
model_cluster_note_zh?: string | null;
model_cluster_note_en?: string | null;
} | null;
};
export type AiCityForecastState = {
status: "idle" | "loading" | "ready" | "failed";
payload?: AiCityForecastPayload | null;
error?: string | null;
streamText?: string | null;
streamRaw?: string | null;
};
@@ -0,0 +1,295 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
enqueueAiCityFetch,
extractStreamingAirportRead,
parseSseBlock,
} from "@/components/dashboard/scan-terminal/ai-city-stream";
import type {
AiCityForecastPayload,
AiCityForecastState,
} from "@/components/dashboard/scan-terminal/types";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import type { CityDetail, MarketScan } from "@/lib/dashboard-types";
import { normalizeCityKey } from "./decision-utils";
export function useAiCityForecast({
detail,
detailCityName,
isEn,
locale,
report,
}: {
detail: CityDetail | null;
detailCityName: string;
isEn: boolean;
locale: string;
report: string;
}) {
const [aiForecast, setAiForecast] = useState<AiCityForecastState>({
status: "idle",
});
const [aiRefreshToken, setAiRefreshToken] = useState(0);
const aiForecastKey = useMemo(
() =>
detail
? `${normalizeCityKey(detailCityName)}:${detail.local_date || ""}:${report || ""}`
: "",
[detail, detailCityName, report],
);
useEffect(() => {
if (!aiForecastKey) {
setAiForecast({ status: "idle" });
return;
}
let cancelled = false;
const controller = new AbortController();
setAiForecast({ status: "loading", streamText: null, streamRaw: "" });
enqueueAiCityFetch(
() =>
fetch("/api/scan/terminal/ai-city/stream", {
method: "POST",
headers: {
Accept: "text/event-stream",
"Content-Type": "application/json",
},
cache: "no-store",
signal: controller.signal,
body: JSON.stringify({
city: detailCityName,
force_refresh: aiRefreshToken > 0,
locale,
}),
}).then(async (response) => {
if (!response.ok) {
let detailMessage = "";
try {
const errorPayload = await response.json();
const message = String(errorPayload?.error || "").trim();
const rawDetail = String(errorPayload?.detail || "").trim();
const elapsed = Number(errorPayload?.elapsed_ms);
const timeout = Number(errorPayload?.timeout_ms);
detailMessage = [
message,
rawDetail,
Number.isFinite(elapsed) && Number.isFinite(timeout)
? `elapsed ${Math.round(elapsed / 1000)}s / timeout ${Math.round(timeout / 1000)}s`
: "",
]
.filter(Boolean)
.join(" · ");
} catch {
detailMessage = "";
}
throw new Error(
detailMessage
? `HTTP ${response.status} · ${detailMessage}`
: `HTTP ${response.status}`,
);
}
const contentType = response.headers.get("content-type") || "";
if (!response.body || !contentType.includes("text/event-stream")) {
return response.json() as Promise<AiCityForecastPayload>;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let rawStream = "";
let finalPayload: AiCityForecastPayload | null = null;
const handleBlock = (block: string) => {
const message = parseSseBlock(block);
if (!message || !message.data || typeof message.data !== "object") {
return;
}
const data = message.data as Record<string, unknown>;
if (message.event === "progress") {
const progressText =
String(
locale === "en-US" ? data.message_en || "" : data.message_zh || "",
).trim() || String(data.message || "").trim();
if (progressText && !cancelled) {
setAiForecast((current) =>
current.status === "loading"
? { ...current, streamText: current.streamText || progressText }
: current,
);
}
} else if (message.event === "preview") {
const previewText =
String(
locale === "en-US"
? data.metar_read_en || ""
: data.metar_read_zh || "",
).trim() ||
String(data.metar_read_zh || data.metar_read_en || "").trim() ||
String(
locale === "en-US"
? data.final_judgment_en || ""
: data.final_judgment_zh || "",
).trim() ||
String(data.final_judgment_zh || data.final_judgment_en || "").trim();
if (previewText && !cancelled) {
setAiForecast((current) =>
current.status === "loading"
? {
...current,
streamText: previewText,
}
: current,
);
}
} else if (message.event === "delta") {
const content = String(data.content || "");
if (!content) return;
rawStream += content;
const airportRead = extractStreamingAirportRead(rawStream, locale);
const streamingText =
airportRead ||
(rawStream.trim()
? isEn
? "AI has started streaming; parsing the METAR read field…"
: "AI 已开始流式输出,正在解析机场报文字段…"
: "");
if (!cancelled) {
setAiForecast((current) =>
current.status === "loading"
? {
...current,
streamRaw: rawStream,
streamText: streamingText || current.streamText || null,
}
: current,
);
}
} else if (message.event === "final") {
finalPayload = data as AiCityForecastPayload;
}
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const blocks = buffer.split(/\n\n|\r\n\r\n/);
buffer = blocks.pop() || "";
for (const block of blocks) {
handleBlock(block);
}
}
buffer += decoder.decode();
if (buffer.trim()) {
handleBlock(buffer);
}
if (!finalPayload) {
throw new Error("AI stream ended before final payload");
}
return finalPayload;
}),
controller.signal,
{
onQueued: () => {
if (cancelled) return;
setAiForecast((current) =>
current.status === "loading"
? {
...current,
streamText: isEn
? "Waiting for the AI airport read queue..."
: "正在等待 AI 机场报文解读队列...",
}
: current,
);
},
onStart: () => {
if (cancelled) return;
setAiForecast((current) =>
current.status === "loading"
? {
...current,
streamText: current.streamRaw
? current.streamText
: isEn
? "Connecting to DeepSeek V4-Pro for airport bulletin streaming..."
: "正在连接 DeepSeek V4-Pro,准备流式解读机场报文...",
}
: current,
);
},
},
)
.then((payload) => {
if (!cancelled) {
setAiForecast({ payload, status: "ready" });
}
})
.catch((error) => {
if (controller.signal.aborted) return;
if (!cancelled) {
setAiForecast({ error: String(error), status: "failed" });
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [aiForecastKey, aiRefreshToken, detailCityName, isEn, locale]);
const refreshAiForecast = useCallback(() => {
setAiRefreshToken((current) => current + 1);
}, []);
return { aiForecast, refreshAiForecast };
}
export function useCityMarketScan({
detail,
detailCityName,
}: {
detail: CityDetail | null;
detailCityName: string;
}) {
const ensureCityMarketScan = useDashboardStore().ensureCityMarketScan;
const [marketScan, setMarketScan] = useState<MarketScan | null>(
detail?.market_scan || null,
);
const [marketStatus, setMarketStatus] = useState<
"idle" | "loading" | "ready" | "failed"
>(detail?.market_scan ? "ready" : "idle");
useEffect(() => {
if (!detail) {
setMarketScan(null);
setMarketStatus("idle");
return;
}
let cancelled = false;
if (detail.market_scan) {
setMarketScan(detail.market_scan);
setMarketStatus("ready");
} else {
setMarketStatus("loading");
}
void ensureCityMarketScan(detailCityName, false, {
lite: true,
targetDate: detail.local_date || null,
})
.then((payload) => {
if (cancelled) return;
setMarketScan(payload || detail.market_scan || null);
setMarketStatus("ready");
})
.catch(() => {
if (cancelled) return;
setMarketScan(detail.market_scan || null);
setMarketStatus(detail.market_scan ? "ready" : "failed");
});
return () => {
cancelled = true;
};
}, [detail, detailCityName, ensureCityMarketScan]);
return { marketScan, marketStatus };
}
+94 -18
View File
@@ -390,11 +390,20 @@ def _extract_provider_stream_delta(data: Any) -> str:
return ""
choices = data.get("choices") or []
if not choices or not isinstance(choices[0], dict):
return ""
text = data.get("text") or data.get("content")
return str(text or "")
delta = choices[0].get("delta") or {}
if not isinstance(delta, dict):
return ""
return str(delta.get("content") or "")
if isinstance(delta, dict):
content = delta.get("content")
if content:
return str(content)
message = choices[0].get("message") or {}
if isinstance(message, dict):
content = message.get("content")
if content:
return str(content)
text = choices[0].get("text") or data.get("text") or data.get("content")
return str(text or "")
def _provider_response_meta(data: Any) -> Dict[str, Any]:
@@ -487,6 +496,12 @@ def _build_city_ai_fallback(
looks_like_truncated_json = bool(content_preview.startswith("{") and not content_preview.rstrip().endswith("}"))
reason_preview = _truncate_ai_text(reason, 260)
reason_lower = str(reason or "").lower()
if reason_lower.strip() == "empty ai content":
reason_preview_zh = "模型没有返回可解析正文"
reason_preview_en = "model returned no parseable content"
else:
reason_preview_zh = reason_preview
reason_preview_en = reason_preview
timed_out = "timeout" in reason_lower or "timed out" in reason_lower or "超时" in str(reason or "")
if content_preview and not looks_like_truncated_json:
metar_zh = f"DeepSeek V4-Pro 返回了非结构化解读,系统已保留摘要:{content_preview}"
@@ -507,8 +522,8 @@ def _build_city_ai_fallback(
else:
final_zh = f"{city} 预计最高温暂以 {predicted_text} 附近为中枢;AI 输出格式异常,已降级为模型/METAR 兜底判断。"
final_en = f"{city} daily high is centered near {predicted_text}; AI output was not strict JSON, so this is a model/METAR fallback."
reasoning_zh = f"DEB、多模型集合和最新 METAR 仍可用于判断方向;原始失败原因:{reason_preview or 'AI 输出不是 JSON object'}"
reasoning_en = f"DEB, the model cluster and latest METAR still support a directional read; raw failure: {reason_preview or 'AI output was not a JSON object'}."
reasoning_zh = f"DEB、多模型集合和最新 METAR 仍可用于判断方向;原始失败原因:{reason_preview_zh or 'AI 输出不是 JSON object'}"
reasoning_en = f"DEB, the model cluster and latest METAR still support a directional read; raw failure: {reason_preview_en or 'AI output was not a JSON object'}."
risks_zh = (
["DeepSeek V4-Pro 本次超时,需刷新重试确认 AI 细节。"]
if timed_out
@@ -1536,7 +1551,6 @@ def _build_city_ai_stream_request(
"model": SCAN_AI_MODEL,
"temperature": 0.2,
"max_tokens": SCAN_CITY_AI_MAX_TOKENS,
"response_format": {"type": "json_object"},
"stream": True,
"messages": [
{"role": "system", "content": system_prompt},
@@ -1577,6 +1591,13 @@ def _cache_city_ai_payload(
}
def _is_city_ai_fallback(ai_raw: Any) -> bool:
if not isinstance(ai_raw, dict):
return True
meta = ai_raw.get("_polyweather_meta")
return bool(isinstance(meta, dict) and meta.get("fallback"))
def _build_city_ai_result_payload(
*,
data: Dict[str, Any],
@@ -1677,6 +1698,24 @@ def stream_scan_city_ai_forecast_payload(
detail_mode="full",
)
ai_input = _build_city_ai_prompt(data)
preview_raw = _build_city_ai_fallback(
ai_input,
locale=normalized_locale,
reason="stream preview",
)
yield _sse_event(
"preview",
{
"city": data.get("name") or city_name,
"city_display_name": data.get("display_name") or city_name,
"metar_read_zh": preview_raw.get("metar_read_zh"),
"metar_read_en": preview_raw.get("metar_read_en"),
"final_judgment_zh": preview_raw.get("final_judgment_zh"),
"final_judgment_en": preview_raw.get("final_judgment_en"),
"model_cluster_note_zh": preview_raw.get("model_cluster_note_zh"),
"model_cluster_note_en": preview_raw.get("model_cluster_note_en"),
},
)
yield _sse_event(
"progress",
{
@@ -1767,6 +1806,8 @@ def stream_scan_city_ai_forecast_payload(
"raw_length": len(accumulated),
},
)
degraded = False
degraded_reason: Optional[str] = None
try:
ai_raw = _extract_ai_json_object(accumulated)
if isinstance(ai_raw, dict):
@@ -1775,19 +1816,50 @@ def stream_scan_city_ai_forecast_payload(
"streamed": True,
}
except Exception as exc:
ai_raw = _build_city_ai_fallback(
ai_input,
locale=normalized_locale,
reason=str(exc),
raw_content=accumulated,
retry_reason = str(exc)
yield _sse_event(
"progress",
{
"stage": "retry_non_stream",
"message_zh": "流式内容为空或 JSON 不完整,正在改用非流式严格 JSON 重试…",
"message_en": "Stream content was empty or incomplete JSON; retrying with a strict non-stream request…",
"raw_length": len(accumulated),
"reason": retry_reason,
},
)
try:
ai_raw = _call_deepseek_city_ai(ai_input, locale=normalized_locale)
if isinstance(ai_raw, dict):
meta = ai_raw.get("_polyweather_meta")
if not isinstance(meta, dict):
meta = {}
ai_raw["_polyweather_meta"] = {
**meta,
"streamed": False,
"stream_retry_non_stream": True,
"stream_retry_reason": retry_reason,
"stream_raw_length": len(accumulated),
}
if _is_city_ai_fallback(ai_raw):
degraded = True
degraded_reason = retry_reason
except Exception as retry_exc:
degraded = True
degraded_reason = str(retry_exc)
ai_raw = _build_city_ai_fallback(
ai_input,
locale=normalized_locale,
reason=degraded_reason or retry_reason,
raw_content=accumulated,
)
generated_at = datetime.utcnow().isoformat() + "Z"
_cache_city_ai_payload(
cache_key,
data=data,
generated_at=generated_at,
ai_raw=ai_raw,
)
if not _is_city_ai_fallback(ai_raw):
_cache_city_ai_payload(
cache_key,
data=data,
generated_at=generated_at,
ai_raw=ai_raw,
)
yield _sse_event(
"final",
_build_city_ai_result_payload(
@@ -1795,6 +1867,10 @@ def stream_scan_city_ai_forecast_payload(
generated_at=generated_at,
started_at=started_at,
ai_raw=ai_raw,
degraded=degraded,
reason=degraded_reason,
reason_en=degraded_reason,
reason_zh=degraded_reason,
),
)
except httpx.TimeoutException as exc: