feat: implement ScanTerminalDashboard with AI-driven city streaming and analysis features
This commit is contained in:
@@ -10982,6 +10982,275 @@
|
|||||||
text-align: right;
|
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) {
|
.root :global(.scan-ai-city-stack) {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
@@ -11245,6 +11514,81 @@
|
|||||||
font-weight: 800;
|
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) {
|
.root :global(.scan-ai-decision-metrics) {
|
||||||
min-width: 360px;
|
min-width: 360px;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -12671,119 +13015,216 @@
|
|||||||
min-height: 520px;
|
min-height: 520px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
align-content: center;
|
|
||||||
justify-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
padding: 48px 32px;
|
padding: 48px 32px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.scan-loading-orb) {
|
.root :global(.scan-loading-signal) {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 74px;
|
width: min(100%, 420px);
|
||||||
height: 74px;
|
display: grid;
|
||||||
border-radius: 999px;
|
justify-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 28px 30px;
|
||||||
|
border: 1px solid rgba(125, 211, 252, 0.14);
|
||||||
|
border-radius: 28px;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at center, rgba(77, 163, 255, 0.2) 0 22%, transparent 23%),
|
radial-gradient(circle at 18% 18%, rgba(77, 163, 255, 0.18), transparent 34%),
|
||||||
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));
|
linear-gradient(145deg, rgba(12, 20, 36, 0.92), rgba(5, 11, 22, 0.74));
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 1px rgba(77, 163, 255, 0.14),
|
0 22px 70px rgba(0, 0, 0, 0.3),
|
||||||
0 18px 42px rgba(77, 163, 255, 0.18);
|
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||||
animation: scan-loading-spin 1.25s linear infinite;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.scan-loading-orb::before) {
|
.root :global(.scan-loading-signal::before) {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 8px;
|
inset: 0;
|
||||||
border-radius: inherit;
|
background: linear-gradient(
|
||||||
background: #0b1220;
|
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) {
|
.root :global(.scan-loading-signal.compact) {
|
||||||
position: absolute;
|
width: min(100%, 340px);
|
||||||
inset: 26px;
|
gap: 12px;
|
||||||
z-index: 1;
|
padding: 18px 20px;
|
||||||
border-radius: inherit;
|
border-radius: 22px;
|
||||||
background: #4da3ff;
|
background:
|
||||||
box-shadow:
|
radial-gradient(circle at 20% 20%, rgba(77, 163, 255, 0.14), transparent 36%),
|
||||||
0 0 18px rgba(77, 163, 255, 0.72),
|
rgba(10, 18, 32, 0.62);
|
||||||
0 0 36px rgba(34, 197, 94, 0.28);
|
box-shadow: none;
|
||||||
animation: scan-loading-pulse 1.25s ease-in-out infinite;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.scan-loading-orb i) {
|
.root :global(.scan-loading-decision-flow) {
|
||||||
position: absolute;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
top: 4px;
|
display: grid;
|
||||||
left: 50%;
|
grid-template-columns: 16px minmax(70px, 1fr) 16px minmax(70px, 1fr) 16px;
|
||||||
width: 9px;
|
align-items: center;
|
||||||
height: 9px;
|
width: min(100%, 300px);
|
||||||
margin-left: -4.5px;
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-loading-node) {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.16);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #e6edf3;
|
background: #0b1220;
|
||||||
box-shadow: 0 0 18px rgba(230, 237, 243, 0.86);
|
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;
|
color: #e6edf3;
|
||||||
font-size: 20px;
|
font-size: 18px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.scan-loading-copy) {
|
.root :global(.scan-loading-copy-block span) {
|
||||||
|
max-width: 320px;
|
||||||
color: #9fb2c7;
|
color: #9fb2c7;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 650;
|
||||||
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.scan-loading-steps) {
|
.root :global(.scan-loading-signal-bars) {
|
||||||
display: inline-flex;
|
position: relative;
|
||||||
gap: 7px;
|
z-index: 1;
|
||||||
margin-top: 2px;
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
gap: 5px;
|
||||||
|
height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.scan-loading-steps span) {
|
.root :global(.scan-loading-signal-bars span) {
|
||||||
width: 32px;
|
width: 7px;
|
||||||
height: 3px;
|
min-height: 8px;
|
||||||
border-radius: 999px;
|
border-radius: 999px 999px 4px 4px;
|
||||||
background: rgba(77, 163, 255, 0.24);
|
background: linear-gradient(180deg, #7dd3fc, #4da3ff 52%, #1be392);
|
||||||
animation: scan-loading-step 1.2s ease-in-out infinite;
|
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;
|
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;
|
animation-delay: 0.32s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes scan-loading-spin {
|
.root :global(.scan-loading-signal-bars span:nth-child(4)) {
|
||||||
to {
|
height: 25px;
|
||||||
transform: rotate(360deg);
|
animation-delay: 0.48s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scan-loading-sweep {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-130%);
|
||||||
|
}
|
||||||
|
46%,
|
||||||
|
100% {
|
||||||
|
transform: translateX(130%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes scan-loading-pulse {
|
@keyframes scan-loading-rail-flow {
|
||||||
0%,
|
0% {
|
||||||
100% {
|
transform: translateX(-110%);
|
||||||
transform: scale(0.82);
|
opacity: 0.15;
|
||||||
opacity: 0.74;
|
|
||||||
}
|
}
|
||||||
50% {
|
45% {
|
||||||
transform: scale(1);
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(220%);
|
||||||
|
opacity: 0.15;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes scan-loading-step {
|
@keyframes scan-loading-node-breathe {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
background: rgba(77, 163, 255, 0.2);
|
transform: scale(0.92);
|
||||||
|
filter: saturate(0.9);
|
||||||
}
|
}
|
||||||
50% {
|
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-city-sub),
|
||||||
.root :global(.scan-terminal.light .scan-detail-volume-caption),
|
.root :global(.scan-terminal.light .scan-detail-volume-caption),
|
||||||
.root :global(.scan-terminal.light .scan-empty-copy),
|
.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-kv span:first-child),
|
||||||
.root :global(.scan-terminal.light .scan-chart-label),
|
.root :global(.scan-terminal.light .scan-chart-label),
|
||||||
.root :global(.scan-terminal.light .scan-trade-sub),
|
.root :global(.scan-terminal.light .scan-trade-sub),
|
||||||
@@ -12849,12 +13290,39 @@
|
|||||||
color: #475569;
|
color: #475569;
|
||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.scan-terminal.light .scan-loading-title) {
|
.root :global(.scan-terminal.light .scan-loading-copy-block strong) {
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.scan-terminal.light .scan-loading-orb::before) {
|
.root :global(.scan-terminal.light .scan-loading-signal) {
|
||||||
background: #f7f9fc;
|
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),
|
.root :global(.scan-terminal.light .scan-list-tabs button),
|
||||||
@@ -13157,6 +13625,38 @@
|
|||||||
border-color: #e2e8f0;
|
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-card),
|
||||||
.root :global(.scan-terminal.light .scan-ai-city-hero) {
|
.root :global(.scan-terminal.light .scan-ai-city-hero) {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
@@ -13165,6 +13665,8 @@
|
|||||||
|
|
||||||
.root :global(.scan-terminal.light .scan-ai-city-section),
|
.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-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-decision-metrics span),
|
||||||
.root :global(.scan-terminal.light .scan-ai-market-bucket),
|
.root :global(.scan-terminal.light .scan-ai-market-bucket),
|
||||||
.root :global(.scan-terminal.light .scan-ai-city-pills span) {
|
.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-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) {
|
.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;
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13189,6 +13693,9 @@
|
|||||||
.root :global(.scan-terminal.light .scan-ai-city-pills span),
|
.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-decision-metrics span),
|
||||||
.root :global(.scan-terminal.light .scan-ai-market-bucket 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-muted),
|
||||||
.root :global(.scan-terminal.light .scan-ai-city-loading),
|
.root :global(.scan-terminal.light .scan-ai-city-loading),
|
||||||
.root :global(.scan-terminal.light .scan-ai-city-chart-legend),
|
.root :global(.scan-terminal.light .scan-ai-city-chart-legend),
|
||||||
@@ -13381,12 +13888,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.scan-ai-workspace-head),
|
.root :global(.scan-ai-workspace-head),
|
||||||
|
.root :global(.scan-opportunity-hero),
|
||||||
.root :global(.scan-ai-city-hero),
|
.root :global(.scan-ai-city-hero),
|
||||||
.root :global(.scan-ai-city-section-head) {
|
.root :global(.scan-ai-city-section-head) {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-opportunity-hero) {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
.root :global(.scan-ai-workspace-head p),
|
.root :global(.scan-ai-workspace-head p),
|
||||||
.root :global(.scan-ai-city-hero-side) {
|
.root :global(.scan-ai-city-hero-side) {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -13398,6 +13910,11 @@
|
|||||||
grid-template-columns: 1fr;
|
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) {
|
.root :global(.scan-forecast-city-read) {
|
||||||
justify-items: start;
|
justify-items: start;
|
||||||
text-align: left;
|
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 today’s 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 today’s 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 };
|
||||||
|
}
|
||||||
@@ -390,11 +390,20 @@ def _extract_provider_stream_delta(data: Any) -> str:
|
|||||||
return ""
|
return ""
|
||||||
choices = data.get("choices") or []
|
choices = data.get("choices") or []
|
||||||
if not choices or not isinstance(choices[0], dict):
|
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 {}
|
delta = choices[0].get("delta") or {}
|
||||||
if not isinstance(delta, dict):
|
if isinstance(delta, dict):
|
||||||
return ""
|
content = delta.get("content")
|
||||||
return str(delta.get("content") or "")
|
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]:
|
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("}"))
|
looks_like_truncated_json = bool(content_preview.startswith("{") and not content_preview.rstrip().endswith("}"))
|
||||||
reason_preview = _truncate_ai_text(reason, 260)
|
reason_preview = _truncate_ai_text(reason, 260)
|
||||||
reason_lower = str(reason or "").lower()
|
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 "")
|
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:
|
if content_preview and not looks_like_truncated_json:
|
||||||
metar_zh = f"DeepSeek V4-Pro 返回了非结构化解读,系统已保留摘要:{content_preview}"
|
metar_zh = f"DeepSeek V4-Pro 返回了非结构化解读,系统已保留摘要:{content_preview}"
|
||||||
@@ -507,8 +522,8 @@ def _build_city_ai_fallback(
|
|||||||
else:
|
else:
|
||||||
final_zh = f"{city} 预计最高温暂以 {predicted_text} 附近为中枢;AI 输出格式异常,已降级为模型/METAR 兜底判断。"
|
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."
|
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_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 or 'AI output was not a 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 = (
|
risks_zh = (
|
||||||
["DeepSeek V4-Pro 本次超时,需刷新重试确认 AI 细节。"]
|
["DeepSeek V4-Pro 本次超时,需刷新重试确认 AI 细节。"]
|
||||||
if timed_out
|
if timed_out
|
||||||
@@ -1536,7 +1551,6 @@ def _build_city_ai_stream_request(
|
|||||||
"model": SCAN_AI_MODEL,
|
"model": SCAN_AI_MODEL,
|
||||||
"temperature": 0.2,
|
"temperature": 0.2,
|
||||||
"max_tokens": SCAN_CITY_AI_MAX_TOKENS,
|
"max_tokens": SCAN_CITY_AI_MAX_TOKENS,
|
||||||
"response_format": {"type": "json_object"},
|
|
||||||
"stream": True,
|
"stream": True,
|
||||||
"messages": [
|
"messages": [
|
||||||
{"role": "system", "content": system_prompt},
|
{"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(
|
def _build_city_ai_result_payload(
|
||||||
*,
|
*,
|
||||||
data: Dict[str, Any],
|
data: Dict[str, Any],
|
||||||
@@ -1677,6 +1698,24 @@ def stream_scan_city_ai_forecast_payload(
|
|||||||
detail_mode="full",
|
detail_mode="full",
|
||||||
)
|
)
|
||||||
ai_input = _build_city_ai_prompt(data)
|
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(
|
yield _sse_event(
|
||||||
"progress",
|
"progress",
|
||||||
{
|
{
|
||||||
@@ -1767,6 +1806,8 @@ def stream_scan_city_ai_forecast_payload(
|
|||||||
"raw_length": len(accumulated),
|
"raw_length": len(accumulated),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
degraded = False
|
||||||
|
degraded_reason: Optional[str] = None
|
||||||
try:
|
try:
|
||||||
ai_raw = _extract_ai_json_object(accumulated)
|
ai_raw = _extract_ai_json_object(accumulated)
|
||||||
if isinstance(ai_raw, dict):
|
if isinstance(ai_raw, dict):
|
||||||
@@ -1775,19 +1816,50 @@ def stream_scan_city_ai_forecast_payload(
|
|||||||
"streamed": True,
|
"streamed": True,
|
||||||
}
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
ai_raw = _build_city_ai_fallback(
|
retry_reason = str(exc)
|
||||||
ai_input,
|
yield _sse_event(
|
||||||
locale=normalized_locale,
|
"progress",
|
||||||
reason=str(exc),
|
{
|
||||||
raw_content=accumulated,
|
"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"
|
generated_at = datetime.utcnow().isoformat() + "Z"
|
||||||
_cache_city_ai_payload(
|
if not _is_city_ai_fallback(ai_raw):
|
||||||
cache_key,
|
_cache_city_ai_payload(
|
||||||
data=data,
|
cache_key,
|
||||||
generated_at=generated_at,
|
data=data,
|
||||||
ai_raw=ai_raw,
|
generated_at=generated_at,
|
||||||
)
|
ai_raw=ai_raw,
|
||||||
|
)
|
||||||
yield _sse_event(
|
yield _sse_event(
|
||||||
"final",
|
"final",
|
||||||
_build_city_ai_result_payload(
|
_build_city_ai_result_payload(
|
||||||
@@ -1795,6 +1867,10 @@ def stream_scan_city_ai_forecast_payload(
|
|||||||
generated_at=generated_at,
|
generated_at=generated_at,
|
||||||
started_at=started_at,
|
started_at=started_at,
|
||||||
ai_raw=ai_raw,
|
ai_raw=ai_raw,
|
||||||
|
degraded=degraded,
|
||||||
|
reason=degraded_reason,
|
||||||
|
reason_en=degraded_reason,
|
||||||
|
reason_zh=degraded_reason,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except httpx.TimeoutException as exc:
|
except httpx.TimeoutException as exc:
|
||||||
|
|||||||
Reference in New Issue
Block a user