feat: implement ScanTerminalDashboard with AI-driven city streaming and analysis features
This commit is contained in:
@@ -10982,6 +10982,275 @@
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-overview) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
border: 1px solid rgba(77, 163, 255, 0.16);
|
||||
border-radius: 18px;
|
||||
background:
|
||||
radial-gradient(circle at 12% 0%, rgba(77, 163, 255, 0.16), transparent 32%),
|
||||
#0b1220;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-overview.loading),
|
||||
.root :global(.scan-opportunity-overview.empty) {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
color: #9fb2c7;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-overview.empty strong) {
|
||||
color: #e6edf3;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-overview.empty button) {
|
||||
border: 1px solid rgba(77, 163, 255, 0.38);
|
||||
border-radius: 999px;
|
||||
background: rgba(77, 163, 255, 0.12);
|
||||
color: #9ecbff;
|
||||
cursor: pointer;
|
||||
font-weight: 900;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-hero) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(320px, 0.75fr);
|
||||
gap: 18px;
|
||||
align-items: stretch;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-hero > div:first-child) {
|
||||
border: 1px solid rgba(77, 163, 255, 0.2);
|
||||
border-radius: 18px;
|
||||
background: rgba(17, 26, 46, 0.78);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-hero span) {
|
||||
color: #4da3ff;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-hero strong) {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: #e6edf3;
|
||||
font-size: 26px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-hero p) {
|
||||
max-width: 680px;
|
||||
margin: 9px 0 0;
|
||||
color: #9fb2c7;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-summary) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-summary span) {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 14px;
|
||||
background: rgba(13, 17, 23, 0.52);
|
||||
color: #9fb2c7;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-summary b) {
|
||||
color: #e6edf3;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-lanes) {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-lane) {
|
||||
border: 1px solid rgba(77, 163, 255, 0.14);
|
||||
border-radius: 18px;
|
||||
background: rgba(13, 17, 23, 0.38);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-lane-head) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-lane-head strong) {
|
||||
color: #e6edf3;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-lane-head p) {
|
||||
margin: 3px 0 0;
|
||||
color: #9fb2c7;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-lane-head > span) {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
background: rgba(77, 163, 255, 0.12);
|
||||
color: #9ecbff;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-card-grid) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-decision-card) {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 16px;
|
||||
background: rgba(17, 26, 46, 0.78);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 14px;
|
||||
transition: border-color 0.18s ease, transform 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-decision-card:hover),
|
||||
.root :global(.scan-opportunity-decision-card.selected) {
|
||||
border-color: rgba(77, 163, 255, 0.42);
|
||||
background: rgba(20, 34, 58, 0.9);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-decision-card.trade) {
|
||||
border-color: rgba(34, 197, 94, 0.34);
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-decision-card.wait) {
|
||||
border-color: rgba(245, 158, 11, 0.34);
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-decision-card.risk),
|
||||
.root :global(.scan-opportunity-decision-card.avoid) {
|
||||
border-color: rgba(239, 68, 68, 0.32);
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-decision-head),
|
||||
.root :global(.scan-opportunity-decision-primary),
|
||||
.root :global(.scan-opportunity-decision-foot) {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-decision-head span),
|
||||
.root :global(.scan-opportunity-decision-primary span),
|
||||
.root :global(.scan-opportunity-decision-foot small) {
|
||||
color: #9fb2c7;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-decision-head strong) {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: #e6edf3;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-decision-head > b) {
|
||||
align-self: start;
|
||||
border-radius: 999px;
|
||||
background: rgba(77, 163, 255, 0.14);
|
||||
color: #9ecbff;
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-decision-card.trade .scan-opportunity-decision-head > b) {
|
||||
background: rgba(34, 197, 94, 0.14);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-decision-card.wait .scan-opportunity-decision-head > b) {
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
color: #facc15;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-decision-card.risk .scan-opportunity-decision-head > b),
|
||||
.root :global(.scan-opportunity-decision-card.avoid .scan-opportunity-decision-head > b) {
|
||||
background: rgba(239, 68, 68, 0.14);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-decision-primary span) {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
border-radius: 12px;
|
||||
background: rgba(13, 17, 23, 0.52);
|
||||
padding: 9px;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-decision-primary b) {
|
||||
color: #e6edf3;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-decision-card p) {
|
||||
min-height: 38px;
|
||||
margin: 0;
|
||||
color: #b6c7db;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-decision-foot) {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-decision-foot small) {
|
||||
border-radius: 999px;
|
||||
background: rgba(77, 163, 255, 0.08);
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-decision-card button) {
|
||||
justify-self: start;
|
||||
border: 1px solid rgba(77, 163, 255, 0.34);
|
||||
border-radius: 10px;
|
||||
background: rgba(77, 163, 255, 0.12);
|
||||
color: #9ecbff;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-stack) {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
@@ -11245,6 +11514,81 @@
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-market-decision) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-top: 14px;
|
||||
border: 1px solid rgba(77, 163, 255, 0.18);
|
||||
border-radius: 14px;
|
||||
background: rgba(11, 18, 32, 0.44);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-market-decision.warm) {
|
||||
border-color: rgba(34, 197, 94, 0.36);
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-market-decision.cold) {
|
||||
border-color: rgba(239, 68, 68, 0.36);
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-market-decision.watch) {
|
||||
border-color: rgba(245, 158, 11, 0.38);
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-market-decision span) {
|
||||
color: #9fb2c7;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-market-decision strong) {
|
||||
margin-top: 3px;
|
||||
color: #e6edf3;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-market-decision p) {
|
||||
margin-top: 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-market-decision-stats) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(82px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-market-decision-stats small) {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
border-radius: 10px;
|
||||
background: rgba(17, 26, 46, 0.82);
|
||||
color: #9fb2c7;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-market-decision-stats b) {
|
||||
color: #e6edf3;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-market-link) {
|
||||
border: 1px solid rgba(77, 163, 255, 0.34);
|
||||
border-radius: 10px;
|
||||
background: rgba(77, 163, 255, 0.12);
|
||||
color: #9ecbff;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
padding: 9px 10px;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-decision-metrics) {
|
||||
min-width: 360px;
|
||||
display: grid;
|
||||
@@ -12671,119 +13015,216 @@
|
||||
min-height: 520px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
gap: 14px;
|
||||
padding: 48px 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-orb) {
|
||||
.root :global(.scan-loading-signal) {
|
||||
position: relative;
|
||||
width: 74px;
|
||||
height: 74px;
|
||||
border-radius: 999px;
|
||||
width: min(100%, 420px);
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 18px;
|
||||
padding: 28px 30px;
|
||||
border: 1px solid rgba(125, 211, 252, 0.14);
|
||||
border-radius: 28px;
|
||||
background:
|
||||
radial-gradient(circle at center, rgba(77, 163, 255, 0.2) 0 22%, transparent 23%),
|
||||
conic-gradient(from 20deg, rgba(77, 163, 255, 0.08), rgba(77, 163, 255, 0.95), rgba(34, 197, 94, 0.78), rgba(77, 163, 255, 0.08));
|
||||
radial-gradient(circle at 18% 18%, rgba(77, 163, 255, 0.18), transparent 34%),
|
||||
linear-gradient(145deg, rgba(12, 20, 36, 0.92), rgba(5, 11, 22, 0.74));
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(77, 163, 255, 0.14),
|
||||
0 18px 42px rgba(77, 163, 255, 0.18);
|
||||
animation: scan-loading-spin 1.25s linear infinite;
|
||||
0 22px 70px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-orb::before) {
|
||||
.root :global(.scan-loading-signal::before) {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 8px;
|
||||
border-radius: inherit;
|
||||
background: #0b1220;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
transparent 0%,
|
||||
rgba(125, 211, 252, 0.08) 42%,
|
||||
rgba(34, 197, 94, 0.1) 50%,
|
||||
transparent 58%
|
||||
);
|
||||
transform: translateX(-120%);
|
||||
animation: scan-loading-sweep 2.6s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-orb span) {
|
||||
position: absolute;
|
||||
inset: 26px;
|
||||
z-index: 1;
|
||||
border-radius: inherit;
|
||||
background: #4da3ff;
|
||||
box-shadow:
|
||||
0 0 18px rgba(77, 163, 255, 0.72),
|
||||
0 0 36px rgba(34, 197, 94, 0.28);
|
||||
animation: scan-loading-pulse 1.25s ease-in-out infinite;
|
||||
.root :global(.scan-loading-signal.compact) {
|
||||
width: min(100%, 340px);
|
||||
gap: 12px;
|
||||
padding: 18px 20px;
|
||||
border-radius: 22px;
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, rgba(77, 163, 255, 0.14), transparent 36%),
|
||||
rgba(10, 18, 32, 0.62);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-orb i) {
|
||||
position: absolute;
|
||||
.root :global(.scan-loading-decision-flow) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
top: 4px;
|
||||
left: 50%;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
margin-left: -4.5px;
|
||||
display: grid;
|
||||
grid-template-columns: 16px minmax(70px, 1fr) 16px minmax(70px, 1fr) 16px;
|
||||
align-items: center;
|
||||
width: min(100%, 300px);
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-node) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.16);
|
||||
border-radius: 999px;
|
||||
background: #e6edf3;
|
||||
box-shadow: 0 0 18px rgba(230, 237, 243, 0.86);
|
||||
background: #0b1220;
|
||||
box-shadow: 0 0 0 5px rgba(77, 163, 255, 0.05);
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-title) {
|
||||
.root :global(.scan-loading-node.hot) {
|
||||
background: linear-gradient(135deg, #f97316, #fde047);
|
||||
box-shadow: 0 0 24px rgba(251, 146, 60, 0.26);
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-node.market) {
|
||||
background: linear-gradient(135deg, #38bdf8, #4da3ff);
|
||||
box-shadow: 0 0 24px rgba(56, 189, 248, 0.26);
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-node.action) {
|
||||
background: linear-gradient(135deg, #22c55e, #1be392);
|
||||
box-shadow: 0 0 24px rgba(34, 197, 94, 0.3);
|
||||
animation: scan-loading-node-breathe 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-rail) {
|
||||
position: relative;
|
||||
height: 2px;
|
||||
overflow: hidden;
|
||||
background: rgba(148, 163, 184, 0.16);
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-rail i) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 48%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, transparent, #7dd3fc, #22c55e);
|
||||
transform: translateX(-110%);
|
||||
animation: scan-loading-rail-flow 1.55s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-rail:nth-of-type(4) i) {
|
||||
animation-delay: 0.45s;
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-copy-block) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-copy-block strong) {
|
||||
color: #e6edf3;
|
||||
font-size: 20px;
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-copy) {
|
||||
.root :global(.scan-loading-copy-block span) {
|
||||
max-width: 320px;
|
||||
color: #9fb2c7;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-weight: 650;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-steps) {
|
||||
display: inline-flex;
|
||||
gap: 7px;
|
||||
margin-top: 2px;
|
||||
.root :global(.scan-loading-signal-bars) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 5px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-steps span) {
|
||||
width: 32px;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: rgba(77, 163, 255, 0.24);
|
||||
animation: scan-loading-step 1.2s ease-in-out infinite;
|
||||
.root :global(.scan-loading-signal-bars span) {
|
||||
width: 7px;
|
||||
min-height: 8px;
|
||||
border-radius: 999px 999px 4px 4px;
|
||||
background: linear-gradient(180deg, #7dd3fc, #4da3ff 52%, #1be392);
|
||||
opacity: 0.42;
|
||||
transform-origin: center bottom;
|
||||
animation: scan-loading-bars 1.35s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-steps span:nth-child(2)) {
|
||||
.root :global(.scan-loading-signal-bars span:nth-child(1)) {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-signal-bars span:nth-child(2)) {
|
||||
height: 22px;
|
||||
animation-delay: 0.16s;
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-steps span:nth-child(3)) {
|
||||
.root :global(.scan-loading-signal-bars span:nth-child(3)) {
|
||||
height: 16px;
|
||||
animation-delay: 0.32s;
|
||||
}
|
||||
|
||||
@keyframes scan-loading-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
.root :global(.scan-loading-signal-bars span:nth-child(4)) {
|
||||
height: 25px;
|
||||
animation-delay: 0.48s;
|
||||
}
|
||||
|
||||
@keyframes scan-loading-sweep {
|
||||
0% {
|
||||
transform: translateX(-130%);
|
||||
}
|
||||
46%,
|
||||
100% {
|
||||
transform: translateX(130%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scan-loading-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(0.82);
|
||||
opacity: 0.74;
|
||||
@keyframes scan-loading-rail-flow {
|
||||
0% {
|
||||
transform: translateX(-110%);
|
||||
opacity: 0.15;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1);
|
||||
45% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(220%);
|
||||
opacity: 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scan-loading-step {
|
||||
@keyframes scan-loading-node-breathe {
|
||||
0%,
|
||||
100% {
|
||||
background: rgba(77, 163, 255, 0.2);
|
||||
transform: scale(0.92);
|
||||
filter: saturate(0.9);
|
||||
}
|
||||
50% {
|
||||
background: rgba(77, 163, 255, 0.9);
|
||||
transform: scale(1.08);
|
||||
filter: saturate(1.25);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scan-loading-bars {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.38;
|
||||
transform: scaleY(0.72);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.95;
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12841,7 +13282,7 @@
|
||||
.root :global(.scan-terminal.light .scan-detail-city-sub),
|
||||
.root :global(.scan-terminal.light .scan-detail-volume-caption),
|
||||
.root :global(.scan-terminal.light .scan-empty-copy),
|
||||
.root :global(.scan-terminal.light .scan-loading-copy),
|
||||
.root :global(.scan-terminal.light .scan-loading-copy-block span),
|
||||
.root :global(.scan-terminal.light .scan-kv span:first-child),
|
||||
.root :global(.scan-terminal.light .scan-chart-label),
|
||||
.root :global(.scan-terminal.light .scan-trade-sub),
|
||||
@@ -12849,12 +13290,39 @@
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-loading-title) {
|
||||
.root :global(.scan-terminal.light .scan-loading-copy-block strong) {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-loading-orb::before) {
|
||||
background: #f7f9fc;
|
||||
.root :global(.scan-terminal.light .scan-loading-signal) {
|
||||
border-color: #dbeafe;
|
||||
background:
|
||||
radial-gradient(circle at 18% 18%, rgba(59, 130, 246, 0.12), transparent 34%),
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.96), rgba(241, 245, 249, 0.9));
|
||||
box-shadow: 0 16px 34px rgba(40, 70, 110, 0.1);
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-loading-signal.compact) {
|
||||
background:
|
||||
radial-gradient(circle at 18% 18%, rgba(59, 130, 246, 0.1), transparent 34%),
|
||||
rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-loading-node) {
|
||||
background: #ffffff;
|
||||
border-color: rgba(148, 163, 184, 0.22);
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-loading-node.hot) {
|
||||
background: linear-gradient(135deg, #f97316, #fde047);
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-loading-node.market) {
|
||||
background: linear-gradient(135deg, #38bdf8, #2563eb);
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-loading-node.action) {
|
||||
background: linear-gradient(135deg, #16a34a, #22c55e);
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-list-tabs button),
|
||||
@@ -13157,6 +13625,38 @@
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-opportunity-overview) {
|
||||
background: #f7f9fc;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-opportunity-hero > div:first-child),
|
||||
.root :global(.scan-terminal.light .scan-opportunity-summary span),
|
||||
.root :global(.scan-terminal.light .scan-opportunity-lane),
|
||||
.root :global(.scan-terminal.light .scan-opportunity-decision-card),
|
||||
.root :global(.scan-terminal.light .scan-opportunity-decision-primary span) {
|
||||
background: #ffffff;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-opportunity-hero strong),
|
||||
.root :global(.scan-terminal.light .scan-opportunity-summary b),
|
||||
.root :global(.scan-terminal.light .scan-opportunity-lane-head strong),
|
||||
.root :global(.scan-terminal.light .scan-opportunity-decision-head strong),
|
||||
.root :global(.scan-terminal.light .scan-opportunity-decision-primary b) {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-opportunity-hero p),
|
||||
.root :global(.scan-terminal.light .scan-opportunity-summary span),
|
||||
.root :global(.scan-terminal.light .scan-opportunity-lane-head p),
|
||||
.root :global(.scan-terminal.light .scan-opportunity-decision-card p),
|
||||
.root :global(.scan-terminal.light .scan-opportunity-decision-head span),
|
||||
.root :global(.scan-terminal.light .scan-opportunity-decision-primary span),
|
||||
.root :global(.scan-terminal.light .scan-opportunity-decision-foot small) {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-ai-city-card),
|
||||
.root :global(.scan-terminal.light .scan-ai-city-hero) {
|
||||
background: #ffffff;
|
||||
@@ -13165,6 +13665,8 @@
|
||||
|
||||
.root :global(.scan-terminal.light .scan-ai-city-section),
|
||||
.root :global(.scan-terminal.light .scan-ai-decision-band),
|
||||
.root :global(.scan-terminal.light .scan-ai-market-decision),
|
||||
.root :global(.scan-terminal.light .scan-ai-market-decision-stats small),
|
||||
.root :global(.scan-terminal.light .scan-ai-decision-metrics span),
|
||||
.root :global(.scan-terminal.light .scan-ai-market-bucket),
|
||||
.root :global(.scan-terminal.light .scan-ai-city-pills span) {
|
||||
@@ -13179,7 +13681,9 @@
|
||||
.root :global(.scan-terminal.light .scan-ai-decision-metrics b),
|
||||
.root :global(.scan-terminal.light .scan-ai-market-bucket strong),
|
||||
.root :global(.scan-terminal.light .scan-ai-weather-summary),
|
||||
.root :global(.scan-terminal.light .scan-ai-decision-reasons small) {
|
||||
.root :global(.scan-terminal.light .scan-ai-decision-reasons small),
|
||||
.root :global(.scan-terminal.light .scan-ai-market-decision strong),
|
||||
.root :global(.scan-terminal.light .scan-ai-market-decision-stats b) {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
@@ -13189,6 +13693,9 @@
|
||||
.root :global(.scan-terminal.light .scan-ai-city-pills span),
|
||||
.root :global(.scan-terminal.light .scan-ai-decision-metrics span),
|
||||
.root :global(.scan-terminal.light .scan-ai-market-bucket span),
|
||||
.root :global(.scan-terminal.light .scan-ai-market-decision span),
|
||||
.root :global(.scan-terminal.light .scan-ai-market-decision p),
|
||||
.root :global(.scan-terminal.light .scan-ai-market-decision-stats small),
|
||||
.root :global(.scan-terminal.light .scan-ai-city-muted),
|
||||
.root :global(.scan-terminal.light .scan-ai-city-loading),
|
||||
.root :global(.scan-terminal.light .scan-ai-city-chart-legend),
|
||||
@@ -13381,12 +13888,17 @@
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-workspace-head),
|
||||
.root :global(.scan-opportunity-hero),
|
||||
.root :global(.scan-ai-city-hero),
|
||||
.root :global(.scan-ai-city-section-head) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-hero) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-workspace-head p),
|
||||
.root :global(.scan-ai-city-hero-side) {
|
||||
text-align: left;
|
||||
@@ -13398,6 +13910,11 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-card-grid),
|
||||
.root :global(.scan-opportunity-summary) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.root :global(.scan-forecast-city-read) {
|
||||
justify-items: start;
|
||||
text-align: left;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
import type { ChartConfiguration } from "chart.js";
|
||||
import { useMemo } from "react";
|
||||
import { BarChart3 } from "lucide-react";
|
||||
import type { CityDetail } from "@/lib/dashboard-types";
|
||||
import { useChart } from "@/hooks/useChart";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import { getTemperatureChartData } from "@/lib/dashboard-utils";
|
||||
|
||||
export function AiCityTemperatureChart({ detail }: { detail: CityDetail }) {
|
||||
const { locale } = useI18n();
|
||||
const chartData = useMemo(
|
||||
() => getTemperatureChartData(detail, locale),
|
||||
[detail, locale],
|
||||
);
|
||||
const forecastLabel = chartData?.datasets.hasMgmHourly
|
||||
? locale === "en-US"
|
||||
? "MGM forecast"
|
||||
: "MGM 预测"
|
||||
: locale === "en-US"
|
||||
? "DEB forecast"
|
||||
: "DEB 预测";
|
||||
const observationLabel =
|
||||
chartData?.observationLabel ||
|
||||
(locale === "en-US" ? "METAR obs" : "METAR 实况");
|
||||
const canvasRef = useChart(() => {
|
||||
if (!chartData) {
|
||||
return {
|
||||
data: { datasets: [], labels: [] },
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
}
|
||||
const forecastPoints = chartData.datasets.hasMgmHourly
|
||||
? chartData.datasets.mgmHourlyPoints
|
||||
: chartData.datasets.debPast.map(
|
||||
(value, index) => value ?? chartData.datasets.debFuture[index],
|
||||
);
|
||||
return {
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
borderColor: "#4DA3FF",
|
||||
borderWidth: 2,
|
||||
data: forecastPoints,
|
||||
fill: false,
|
||||
label: forecastLabel,
|
||||
pointRadius: 0,
|
||||
spanGaps: true,
|
||||
tension: 0.32,
|
||||
},
|
||||
{
|
||||
backgroundColor: "#22C55E",
|
||||
borderColor: "#22C55E",
|
||||
borderWidth: 0,
|
||||
data: chartData.datasets.metarPoints,
|
||||
fill: false,
|
||||
label: observationLabel,
|
||||
pointHoverRadius: 5,
|
||||
pointRadius: 3.5,
|
||||
showLine: false,
|
||||
},
|
||||
],
|
||||
labels: chartData.times,
|
||||
},
|
||||
options: {
|
||||
interaction: { intersect: false, mode: "index" },
|
||||
layout: { padding: { bottom: 2, left: 0, right: 8, top: 8 } },
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(11, 18, 32, 0.96)",
|
||||
borderColor: "rgba(77, 163, 255, 0.38)",
|
||||
borderWidth: 1,
|
||||
},
|
||||
},
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: "rgba(159, 178, 199, 0.08)" },
|
||||
ticks: {
|
||||
callback: (_value, index) =>
|
||||
typeof index === "number" && index % 4 === 0
|
||||
? chartData.times[index]
|
||||
: "",
|
||||
color: "#6B7A90",
|
||||
font: { size: 10 },
|
||||
maxTicksLimit: 6,
|
||||
maxRotation: 0,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: { color: "rgba(159, 178, 199, 0.08)" },
|
||||
max: chartData.max,
|
||||
min: chartData.min,
|
||||
ticks: {
|
||||
callback: (value) =>
|
||||
`${Number(value).toFixed(chartData.yTickStep < 1 ? 1 : 0)}${detail.temp_symbol || "°C"}`,
|
||||
color: "#6B7A90",
|
||||
font: { size: 10 },
|
||||
maxTicksLimit: 5,
|
||||
stepSize: chartData.yTickStep,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
}, [chartData, detail.temp_symbol, forecastLabel, observationLabel]);
|
||||
|
||||
return (
|
||||
<section className="scan-ai-city-section chart">
|
||||
<div className="scan-ai-city-section-title">
|
||||
<BarChart3 size={15} />
|
||||
<span>{locale === "en-US" ? "Evidence · intraday path" : "证据 · 今日日内路径"}</span>
|
||||
</div>
|
||||
<div className="scan-ai-city-chart">
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
{chartData ? (
|
||||
<div className="scan-ai-city-chart-legend">
|
||||
<span><i className="forecast" />{forecastLabel}</span>
|
||||
<span><i className="observation" />{observationLabel}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
import { formatTemperatureValue, getTodayPaceView } from "@/lib/dashboard-utils";
|
||||
import { getPeakWindowLabel } from "@/components/dashboard/scan-terminal/decision-utils";
|
||||
|
||||
export function AiForecastKPIBar({
|
||||
pinnedCount,
|
||||
activeCityName,
|
||||
activeDetail,
|
||||
activeRow,
|
||||
locale,
|
||||
}: {
|
||||
pinnedCount: number;
|
||||
activeCityName?: string | null;
|
||||
activeDetail?: CityDetail | null;
|
||||
activeRow?: ScanOpportunityRow | null;
|
||||
locale: string;
|
||||
}) {
|
||||
const isEn = locale === "en-US";
|
||||
const tempSymbol = activeDetail?.temp_symbol || activeRow?.temp_symbol || "°C";
|
||||
const displayName =
|
||||
activeDetail?.display_name ||
|
||||
activeRow?.city_display_name ||
|
||||
activeRow?.display_name ||
|
||||
activeCityName ||
|
||||
"--";
|
||||
const deb = activeDetail?.deb?.prediction ?? activeRow?.deb_prediction ?? null;
|
||||
const paceView = activeDetail
|
||||
? getTodayPaceView(activeDetail, locale as "zh-CN" | "en-US")
|
||||
: null;
|
||||
const peakWindow =
|
||||
paceView?.peakWindowText ||
|
||||
(activeRow ? getPeakWindowLabel(activeRow) : null) ||
|
||||
"--";
|
||||
const cards = [
|
||||
{
|
||||
label: isEn ? "Decision Cards" : "决策卡",
|
||||
value: String(pinnedCount),
|
||||
note: isEn ? "Cities opened from opportunities or map" : "从机会榜或地图加入",
|
||||
tone: "green",
|
||||
},
|
||||
{
|
||||
label: isEn ? "Current City" : "当前城市",
|
||||
value: displayName,
|
||||
note: isEn ? "City briefing stays in the right rail" : "右侧城市简报同步显示,不自动切页",
|
||||
tone: "blue",
|
||||
},
|
||||
{
|
||||
label: isEn ? "Forecast Center" : "预测中枢",
|
||||
value:
|
||||
deb != null
|
||||
? formatTemperatureValue(deb, tempSymbol, { digits: 1 })
|
||||
: "--",
|
||||
note: isEn ? "Weather center before price mapping" : "先定天气中枢,再映射价格",
|
||||
tone: "cyan",
|
||||
},
|
||||
{
|
||||
label: isEn ? "Peak Window" : "峰值窗口",
|
||||
value: peakWindow,
|
||||
note: isEn ? "Key window for daily high judgment" : "用于判断是否接近今日最高温",
|
||||
tone: "amber",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="scan-kpi-bar">
|
||||
{cards.map((card) => (
|
||||
<article key={card.label} className={`scan-kpi-card ${card.tone}`}>
|
||||
<div className="scan-kpi-label">{card.label}</div>
|
||||
<div className="scan-kpi-value">{card.value}</div>
|
||||
<div className="scan-kpi-note">{card.note}</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { ChevronDown, RefreshCw, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ModelForecast } from "@/components/dashboard/PanelSections";
|
||||
import { AiCityTemperatureChart } from "@/components/dashboard/scan-terminal/AiCityTemperatureChart";
|
||||
import { buildMarketDecisionView, buildWeatherDecisionView } from "@/components/dashboard/scan-terminal/city-card-decision-utils";
|
||||
import { findDetailForCity } from "@/components/dashboard/scan-terminal/city-detail-utils";
|
||||
import { findRowForCity, getPeakWindowLabel, normalizeCityKey } from "@/components/dashboard/scan-terminal/decision-utils";
|
||||
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
|
||||
import type { AiPinnedCity } from "@/components/dashboard/scan-terminal/types";
|
||||
import {
|
||||
useAiCityForecast,
|
||||
useCityMarketScan,
|
||||
} from "@/components/dashboard/scan-terminal/use-ai-city-card-data";
|
||||
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
import { formatTemperatureValue, getModelView, getTodayPaceView } from "@/lib/dashboard-utils";
|
||||
|
||||
function AiPinnedCityCard({
|
||||
item,
|
||||
detail,
|
||||
row,
|
||||
locale,
|
||||
collapsed,
|
||||
removing,
|
||||
onRemove,
|
||||
onToggleCollapsed,
|
||||
}: {
|
||||
item: AiPinnedCity;
|
||||
detail: CityDetail | null;
|
||||
row: ScanOpportunityRow | null;
|
||||
locale: string;
|
||||
collapsed: boolean;
|
||||
removing?: boolean;
|
||||
onRemove: () => void;
|
||||
onToggleCollapsed: () => void;
|
||||
}) {
|
||||
const isEn = locale === "en-US";
|
||||
const displayName =
|
||||
detail?.display_name ||
|
||||
row?.city_display_name ||
|
||||
row?.display_name ||
|
||||
item.displayName ||
|
||||
item.cityName;
|
||||
const tempSymbol = detail?.temp_symbol || row?.temp_symbol || "°C";
|
||||
const modelView = detail ? getModelView(detail, detail.local_date) : null;
|
||||
const modelEntries = modelView
|
||||
? Object.entries(modelView.models || {})
|
||||
.map(([name, value]) => [name, Number(value)] as const)
|
||||
.filter(([, value]) => Number.isFinite(value))
|
||||
: [];
|
||||
const modelValues = modelEntries.map(([, value]) => value);
|
||||
const modelMin = modelValues.length ? Math.min(...modelValues) : null;
|
||||
const modelMax = modelValues.length ? Math.max(...modelValues) : null;
|
||||
const paceView = detail ? getTodayPaceView(detail, locale as "zh-CN" | "en-US") : null;
|
||||
const peakWindow =
|
||||
paceView?.peakWindowText ||
|
||||
(row ? getPeakWindowLabel(row) : null) ||
|
||||
"--";
|
||||
const deb = detail?.deb?.prediction ?? row?.deb_prediction ?? null;
|
||||
const currentTemp =
|
||||
detail?.airport_primary?.temp ??
|
||||
detail?.airport_current?.temp ??
|
||||
detail?.current?.temp ??
|
||||
row?.current_temp ??
|
||||
null;
|
||||
const modelRange =
|
||||
modelMin != null && modelMax != null
|
||||
? `${formatTemperatureValue(modelMin, tempSymbol, { digits: 1 })} ~ ${formatTemperatureValue(modelMax, tempSymbol, { digits: 1 })}`
|
||||
: "--";
|
||||
const paceTone = paceView?.biasTone || "neutral";
|
||||
const paceText =
|
||||
paceView?.summary ||
|
||||
(isEn
|
||||
? "Waiting for intraday observations to compare against the DEB path."
|
||||
: "等待更多日内实测,用来对照 DEB 预测路径。");
|
||||
const report = detail?.current?.raw_metar || detail?.airport_current?.raw_metar || "";
|
||||
const airportStation =
|
||||
detail?.risk?.icao ||
|
||||
detail?.current?.station_code ||
|
||||
detail?.airport_current?.station_code ||
|
||||
detail?.airport_primary?.station_code ||
|
||||
"";
|
||||
const detailCityName = detail?.name || item.cityName;
|
||||
const { aiForecast, refreshAiForecast } = useAiCityForecast({
|
||||
detail,
|
||||
detailCityName,
|
||||
isEn,
|
||||
locale,
|
||||
report,
|
||||
});
|
||||
const { marketScan, marketStatus } = useCityMarketScan({
|
||||
detail,
|
||||
detailCityName,
|
||||
});
|
||||
|
||||
const aiCityForecast = aiForecast.payload?.city_forecast || null;
|
||||
const localizedFinalJudgment =
|
||||
(isEn ? aiCityForecast?.final_judgment_en : aiCityForecast?.final_judgment_zh) ||
|
||||
(isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) ||
|
||||
"";
|
||||
const localizedMetarRead =
|
||||
(isEn ? aiCityForecast?.metar_read_en : aiCityForecast?.metar_read_zh) ||
|
||||
"";
|
||||
const localizedReasoning =
|
||||
(isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) ||
|
||||
"";
|
||||
const localizedModelNote =
|
||||
(isEn
|
||||
? aiCityForecast?.model_cluster_note_en
|
||||
: aiCityForecast?.model_cluster_note_zh) || "";
|
||||
const modelPreview = modelEntries
|
||||
.slice(0, 4)
|
||||
.map(([name, value]) => `${name} ${formatTemperatureValue(value, tempSymbol, { digits: 1 })}`)
|
||||
.join(isEn ? " / " : " / ");
|
||||
const localModelSupportNote = modelEntries.length
|
||||
? isEn
|
||||
? modelEntries.length <= 2
|
||||
? `Model support is sparse: only ${modelEntries.length} sources are available${modelPreview ? ` (${modelPreview})` : ""}, so the read should lean more on DEB path and METAR.`
|
||||
: `Model support: ${modelEntries.length} sources cluster between ${modelRange}; ${modelPreview}.`
|
||||
: modelEntries.length <= 2
|
||||
? `多模型支撑偏少:当前只有 ${modelEntries.length} 个模型${modelPreview ? `(${modelPreview})` : ""},需要更重视 DEB 路径和 METAR 实测。`
|
||||
: `多模型支撑:${modelEntries.length} 个模型集中在 ${modelRange},代表模型为 ${modelPreview}。`
|
||||
: isEn
|
||||
? "Model support is unavailable, so this city must rely on DEB path and METAR observations."
|
||||
: "暂无可用多模型支撑,需要主要参考 DEB 路径和 METAR 实测。";
|
||||
const aiPredictedMax = Number(aiCityForecast?.predicted_max);
|
||||
const decisionExpectedHighNumber = Number.isFinite(aiPredictedMax)
|
||||
? aiPredictedMax
|
||||
: paceView?.paceAdjustedHigh != null
|
||||
? paceView.paceAdjustedHigh
|
||||
: deb;
|
||||
const decisionView = buildWeatherDecisionView({
|
||||
aiCityForecast,
|
||||
currentTemp,
|
||||
deb,
|
||||
isEn,
|
||||
localModelSupportNote,
|
||||
modelEntries,
|
||||
modelMax,
|
||||
modelMin,
|
||||
paceTone,
|
||||
paceView,
|
||||
peakWindow,
|
||||
tempSymbol,
|
||||
});
|
||||
const marketDecisionView = buildMarketDecisionView({
|
||||
expectedHigh: decisionExpectedHighNumber,
|
||||
isEn,
|
||||
marketScan,
|
||||
marketStatus,
|
||||
tempSymbol,
|
||||
});
|
||||
const localizedRisksRaw =
|
||||
(isEn ? aiCityForecast?.risks_en : aiCityForecast?.risks_zh) || [];
|
||||
const localizedRisks = Array.isArray(localizedRisksRaw)
|
||||
? localizedRisksRaw
|
||||
: localizedRisksRaw
|
||||
? [String(localizedRisksRaw)]
|
||||
: [];
|
||||
const aiBullets = [
|
||||
localizedMetarRead,
|
||||
localizedReasoning !== localizedFinalJudgment ? localizedReasoning : "",
|
||||
localizedModelNote || localModelSupportNote,
|
||||
...localizedRisks,
|
||||
].filter((line) => String(line || "").trim());
|
||||
const fallbackAiReason =
|
||||
(isEn ? aiForecast.payload?.reason_en : aiForecast.payload?.reason_zh) ||
|
||||
aiForecast.payload?.reason ||
|
||||
"";
|
||||
|
||||
const collapseId = `ai-city-body-${normalizeCityKey(item.cityName) || item.addedAt}`;
|
||||
|
||||
return (
|
||||
<article className={clsx("scan-ai-city-card", collapsed && "collapsed", removing && "removing")}>
|
||||
<header className="scan-ai-city-hero">
|
||||
<div>
|
||||
<span className="scan-ai-city-kicker">
|
||||
{isEn ? "Deep analysis" : "城市深度分析"}
|
||||
</span>
|
||||
<h3>{displayName}</h3>
|
||||
<div className="scan-ai-city-pills">
|
||||
<span>{detail?.local_time || row?.local_time || "--"}</span>
|
||||
<span>
|
||||
DEB{" "}
|
||||
{deb != null
|
||||
? formatTemperatureValue(deb, tempSymbol, { digits: 1 })
|
||||
: "--"}
|
||||
</span>
|
||||
<span>{isEn ? "Model" : "模型"} {modelRange}</span>
|
||||
<span>{isEn ? "Peak" : "峰值"} {peakWindow}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="scan-ai-city-hero-side">
|
||||
<span>{isEn ? "Expected high" : "预计最高温"}</span>
|
||||
<strong>
|
||||
{paceView?.paceAdjustedHigh != null
|
||||
? formatTemperatureValue(paceView.paceAdjustedHigh, tempSymbol, { digits: 1 })
|
||||
: deb != null
|
||||
? formatTemperatureValue(deb, tempSymbol, { digits: 1 })
|
||||
: "--"}
|
||||
</strong>
|
||||
<div className="scan-ai-city-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="scan-ai-city-icon-button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
refreshAiForecast();
|
||||
}}
|
||||
aria-label={isEn ? `Refresh ${displayName} analysis` : `刷新 ${displayName} 深度分析`}
|
||||
title={isEn ? "Refresh analysis" : "刷新深度分析"}
|
||||
disabled={aiForecast.status === "loading"}
|
||||
>
|
||||
<RefreshCw size={15} className={aiForecast.status === "loading" ? "spin" : undefined} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="scan-ai-city-icon-button danger"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
aria-label={isEn ? `Remove ${displayName}` : `移除 ${displayName}`}
|
||||
title={isEn ? "Remove city" : "移除城市"}
|
||||
disabled={removing}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="scan-ai-city-collapse"
|
||||
onClick={onToggleCollapsed}
|
||||
aria-expanded={!collapsed}
|
||||
aria-controls={collapseId}
|
||||
>
|
||||
<ChevronDown size={15} />
|
||||
{collapsed ? (isEn ? "Expand" : "展开") : (isEn ? "Collapse" : "收起")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{detail && !collapsed ? (
|
||||
<div className="scan-ai-city-body" id={collapseId}>
|
||||
<section className={clsx("scan-ai-decision-band", decisionView.tone)}>
|
||||
<div className="scan-ai-decision-main">
|
||||
<span>{decisionView.kicker}</span>
|
||||
<strong>{decisionView.action}</strong>
|
||||
<p>{localizedFinalJudgment || paceText}</p>
|
||||
<div className="scan-ai-decision-reasons">
|
||||
{decisionView.reasons.map((reason, index) => (
|
||||
<small key={`${reason}-${index}`}>{reason}</small>
|
||||
))}
|
||||
</div>
|
||||
<p className="scan-ai-decision-risk">{decisionView.risk}</p>
|
||||
<div className={clsx("scan-ai-market-decision", marketDecisionView.tone)}>
|
||||
<div>
|
||||
<span>
|
||||
{isEn ? "Polymarket price layer" : "Polymarket 价格层"}
|
||||
</span>
|
||||
<strong>{marketDecisionView.title}</strong>
|
||||
<p>{marketDecisionView.reason}</p>
|
||||
</div>
|
||||
<div className="scan-ai-market-decision-stats">
|
||||
<small>
|
||||
{isEn ? "Bucket" : "温度桶"} <b>{marketDecisionView.bucketLabel}</b>
|
||||
</small>
|
||||
<small>
|
||||
{isEn ? "YES" : "YES 买入"} <b>{marketDecisionView.priceText}</b>
|
||||
</small>
|
||||
<small>
|
||||
{isEn ? "Edge" : "概率差"} <b>{marketDecisionView.edgeText}</b>
|
||||
</small>
|
||||
</div>
|
||||
{marketDecisionView.marketUrl ? (
|
||||
<a
|
||||
className="scan-ai-market-link"
|
||||
href={marketDecisionView.marketUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{isEn ? "Open market" : "打开市场"}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="scan-ai-decision-metrics">
|
||||
<span>
|
||||
{isEn ? "Expected high" : "预计高点"}
|
||||
<b>{decisionView.expectedHigh}</b>
|
||||
</span>
|
||||
<span>
|
||||
{isEn ? "Weather range" : "天气区间"}
|
||||
<b>{decisionView.targetRange}</b>
|
||||
</span>
|
||||
<span>
|
||||
{isEn ? "Confidence" : "信心"}
|
||||
<b>{decisionView.confidence}</b>
|
||||
</span>
|
||||
<span>
|
||||
{isEn ? "Observed" : "实测"}
|
||||
<b>
|
||||
{currentTemp != null
|
||||
? formatTemperatureValue(currentTemp, tempSymbol, { digits: 1 })
|
||||
: "--"}
|
||||
</b>
|
||||
</span>
|
||||
<span>
|
||||
{isEn ? "Path delta" : "路径偏差"} <b>{paceView?.deltaText || "--"}</b>
|
||||
</span>
|
||||
<span>
|
||||
{isEn ? "Peak window" : "峰值窗口"} <b>{peakWindow}</b>
|
||||
</span>
|
||||
<span>
|
||||
{isEn ? "Market implied" : "市场隐含"} <b>{marketDecisionView.impliedText}</b>
|
||||
</span>
|
||||
<span>
|
||||
{isEn ? "Model prob" : "模型概率"} <b>{marketDecisionView.modelText}</b>
|
||||
</span>
|
||||
<span>
|
||||
{isEn ? "Quote status" : "报价状态"} <b>{marketDecisionView.status === "ready" ? (isEn ? "Ready" : "已同步") : marketDecisionView.status === "loading" ? (isEn ? "Loading" : "同步中") : (isEn ? "Unavailable" : "不可用")}</b>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="scan-ai-city-analysis-grid">
|
||||
<AiCityTemperatureChart detail={detail} />
|
||||
<section className="scan-ai-city-section">
|
||||
<div className="scan-ai-city-section-title">
|
||||
{isEn ? "Evidence · AI airport read" : "证据 · AI 机场报文解读"}
|
||||
</div>
|
||||
{aiForecast.status === "loading" ? (
|
||||
<>
|
||||
<p className={aiForecast.streamText ? "scan-ai-weather-summary" : undefined}>
|
||||
{aiForecast.streamText ||
|
||||
(isEn
|
||||
? "Deepseek V4 pro is reading the latest airport bulletin..."
|
||||
: "Deepseek V4 pro 正在解读最新机场报文...")}
|
||||
</p>
|
||||
{aiForecast.streamText ? (
|
||||
<p className="scan-ai-city-muted">
|
||||
{isEn
|
||||
? "Streaming airport read; final forecast is still being completed..."
|
||||
: "机场报文解读正在流式输出,最终预报结论仍在补全..."}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
) : aiForecast.status === "ready" && aiCityForecast ? (
|
||||
<>
|
||||
<p className="scan-ai-weather-summary">
|
||||
{localizedFinalJudgment ||
|
||||
(isEn ? "AI read returned without a final sentence." : "AI 已返回,但缺少最终判断。")}
|
||||
</p>
|
||||
<ul className="scan-ai-weather-bullets">
|
||||
{aiBullets.map((line, index) => (
|
||||
<li key={`${line}-${index}`}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="scan-ai-raw-metar">
|
||||
{report
|
||||
? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${airportStation} ${report}`.trim()}`
|
||||
: isEn
|
||||
? "Raw METAR: unavailable."
|
||||
: "原始 METAR:暂无。"}
|
||||
</p>
|
||||
</>
|
||||
) : aiForecast.status === "ready" ? (
|
||||
<>
|
||||
<p>
|
||||
{aiForecast.payload?.status === "timeout"
|
||||
? isEn
|
||||
? "Deepseek V4-Pro timed out. You can retry; city data and the right briefing were not refreshed."
|
||||
: "Deepseek V4-Pro 本次解读超时,可稍后重试;城市数据和右侧简报不会被刷新。"
|
||||
: fallbackAiReason ||
|
||||
(isEn
|
||||
? "AI read is unavailable for this city right now."
|
||||
: "该城市暂时没有可用的 AI 解读。")}
|
||||
</p>
|
||||
<ul className="scan-ai-weather-bullets">
|
||||
<li>{localModelSupportNote}</li>
|
||||
<li>
|
||||
{report
|
||||
? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${airportStation} ${report}`.trim()}`
|
||||
: isEn
|
||||
? "Raw METAR is unavailable."
|
||||
: "暂无原始 METAR。"}
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
) : aiForecast.status === "failed" ? (
|
||||
<>
|
||||
<p>
|
||||
{isEn
|
||||
? "AI read failed. Model support and the raw METAR remain as fallback context."
|
||||
: "AI 解读失败。下方保留多模型支撑和原始 METAR 作为兜底上下文。"}
|
||||
{aiForecast.error ? ` ${aiForecast.error}` : ""}
|
||||
</p>
|
||||
<ul className="scan-ai-weather-bullets">
|
||||
<li>{localModelSupportNote}</li>
|
||||
<li>
|
||||
{report
|
||||
? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${airportStation} ${report}`.trim()}`
|
||||
: isEn
|
||||
? "Raw METAR is unavailable."
|
||||
: "暂无原始 METAR。"}
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
{isEn
|
||||
? "Waiting for AI to read the latest airport bulletin."
|
||||
: "等待 AI 解读最新机场报文。"}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="scan-ai-city-section models">
|
||||
<div className="scan-ai-city-section-title">
|
||||
{isEn ? "Evidence · multi-model support" : "证据 · 多模型支撑"}
|
||||
</div>
|
||||
<ModelForecast detail={detail} targetDate={detail.local_date} hideTitle />
|
||||
</section>
|
||||
|
||||
</div>
|
||||
) : !detail ? (
|
||||
<div className="scan-ai-city-loading">
|
||||
<LoadingSignal
|
||||
title={isEn ? "Loading city decision data" : "正在加载城市决策数据"}
|
||||
description={
|
||||
isEn
|
||||
? "Hydrating 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 ""
|
||||
choices = data.get("choices") or []
|
||||
if not choices or not isinstance(choices[0], dict):
|
||||
return ""
|
||||
text = data.get("text") or data.get("content")
|
||||
return str(text or "")
|
||||
delta = choices[0].get("delta") or {}
|
||||
if not isinstance(delta, dict):
|
||||
return ""
|
||||
return str(delta.get("content") or "")
|
||||
if isinstance(delta, dict):
|
||||
content = delta.get("content")
|
||||
if content:
|
||||
return str(content)
|
||||
message = choices[0].get("message") or {}
|
||||
if isinstance(message, dict):
|
||||
content = message.get("content")
|
||||
if content:
|
||||
return str(content)
|
||||
text = choices[0].get("text") or data.get("text") or data.get("content")
|
||||
return str(text or "")
|
||||
|
||||
|
||||
def _provider_response_meta(data: Any) -> Dict[str, Any]:
|
||||
@@ -487,6 +496,12 @@ def _build_city_ai_fallback(
|
||||
looks_like_truncated_json = bool(content_preview.startswith("{") and not content_preview.rstrip().endswith("}"))
|
||||
reason_preview = _truncate_ai_text(reason, 260)
|
||||
reason_lower = str(reason or "").lower()
|
||||
if reason_lower.strip() == "empty ai content":
|
||||
reason_preview_zh = "模型没有返回可解析正文"
|
||||
reason_preview_en = "model returned no parseable content"
|
||||
else:
|
||||
reason_preview_zh = reason_preview
|
||||
reason_preview_en = reason_preview
|
||||
timed_out = "timeout" in reason_lower or "timed out" in reason_lower or "超时" in str(reason or "")
|
||||
if content_preview and not looks_like_truncated_json:
|
||||
metar_zh = f"DeepSeek V4-Pro 返回了非结构化解读,系统已保留摘要:{content_preview}"
|
||||
@@ -507,8 +522,8 @@ def _build_city_ai_fallback(
|
||||
else:
|
||||
final_zh = f"{city} 预计最高温暂以 {predicted_text} 附近为中枢;AI 输出格式异常,已降级为模型/METAR 兜底判断。"
|
||||
final_en = f"{city} daily high is centered near {predicted_text}; AI output was not strict JSON, so this is a model/METAR fallback."
|
||||
reasoning_zh = f"DEB、多模型集合和最新 METAR 仍可用于判断方向;原始失败原因:{reason_preview or 'AI 输出不是 JSON object'}。"
|
||||
reasoning_en = f"DEB, the model cluster and latest METAR still support a directional read; raw failure: {reason_preview or 'AI output was not a JSON object'}."
|
||||
reasoning_zh = f"DEB、多模型集合和最新 METAR 仍可用于判断方向;原始失败原因:{reason_preview_zh or 'AI 输出不是 JSON object'}。"
|
||||
reasoning_en = f"DEB, the model cluster and latest METAR still support a directional read; raw failure: {reason_preview_en or 'AI output was not a JSON object'}."
|
||||
risks_zh = (
|
||||
["DeepSeek V4-Pro 本次超时,需刷新重试确认 AI 细节。"]
|
||||
if timed_out
|
||||
@@ -1536,7 +1551,6 @@ def _build_city_ai_stream_request(
|
||||
"model": SCAN_AI_MODEL,
|
||||
"temperature": 0.2,
|
||||
"max_tokens": SCAN_CITY_AI_MAX_TOKENS,
|
||||
"response_format": {"type": "json_object"},
|
||||
"stream": True,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
@@ -1577,6 +1591,13 @@ def _cache_city_ai_payload(
|
||||
}
|
||||
|
||||
|
||||
def _is_city_ai_fallback(ai_raw: Any) -> bool:
|
||||
if not isinstance(ai_raw, dict):
|
||||
return True
|
||||
meta = ai_raw.get("_polyweather_meta")
|
||||
return bool(isinstance(meta, dict) and meta.get("fallback"))
|
||||
|
||||
|
||||
def _build_city_ai_result_payload(
|
||||
*,
|
||||
data: Dict[str, Any],
|
||||
@@ -1677,6 +1698,24 @@ def stream_scan_city_ai_forecast_payload(
|
||||
detail_mode="full",
|
||||
)
|
||||
ai_input = _build_city_ai_prompt(data)
|
||||
preview_raw = _build_city_ai_fallback(
|
||||
ai_input,
|
||||
locale=normalized_locale,
|
||||
reason="stream preview",
|
||||
)
|
||||
yield _sse_event(
|
||||
"preview",
|
||||
{
|
||||
"city": data.get("name") or city_name,
|
||||
"city_display_name": data.get("display_name") or city_name,
|
||||
"metar_read_zh": preview_raw.get("metar_read_zh"),
|
||||
"metar_read_en": preview_raw.get("metar_read_en"),
|
||||
"final_judgment_zh": preview_raw.get("final_judgment_zh"),
|
||||
"final_judgment_en": preview_raw.get("final_judgment_en"),
|
||||
"model_cluster_note_zh": preview_raw.get("model_cluster_note_zh"),
|
||||
"model_cluster_note_en": preview_raw.get("model_cluster_note_en"),
|
||||
},
|
||||
)
|
||||
yield _sse_event(
|
||||
"progress",
|
||||
{
|
||||
@@ -1767,6 +1806,8 @@ def stream_scan_city_ai_forecast_payload(
|
||||
"raw_length": len(accumulated),
|
||||
},
|
||||
)
|
||||
degraded = False
|
||||
degraded_reason: Optional[str] = None
|
||||
try:
|
||||
ai_raw = _extract_ai_json_object(accumulated)
|
||||
if isinstance(ai_raw, dict):
|
||||
@@ -1775,19 +1816,50 @@ def stream_scan_city_ai_forecast_payload(
|
||||
"streamed": True,
|
||||
}
|
||||
except Exception as exc:
|
||||
ai_raw = _build_city_ai_fallback(
|
||||
ai_input,
|
||||
locale=normalized_locale,
|
||||
reason=str(exc),
|
||||
raw_content=accumulated,
|
||||
retry_reason = str(exc)
|
||||
yield _sse_event(
|
||||
"progress",
|
||||
{
|
||||
"stage": "retry_non_stream",
|
||||
"message_zh": "流式内容为空或 JSON 不完整,正在改用非流式严格 JSON 重试…",
|
||||
"message_en": "Stream content was empty or incomplete JSON; retrying with a strict non-stream request…",
|
||||
"raw_length": len(accumulated),
|
||||
"reason": retry_reason,
|
||||
},
|
||||
)
|
||||
try:
|
||||
ai_raw = _call_deepseek_city_ai(ai_input, locale=normalized_locale)
|
||||
if isinstance(ai_raw, dict):
|
||||
meta = ai_raw.get("_polyweather_meta")
|
||||
if not isinstance(meta, dict):
|
||||
meta = {}
|
||||
ai_raw["_polyweather_meta"] = {
|
||||
**meta,
|
||||
"streamed": False,
|
||||
"stream_retry_non_stream": True,
|
||||
"stream_retry_reason": retry_reason,
|
||||
"stream_raw_length": len(accumulated),
|
||||
}
|
||||
if _is_city_ai_fallback(ai_raw):
|
||||
degraded = True
|
||||
degraded_reason = retry_reason
|
||||
except Exception as retry_exc:
|
||||
degraded = True
|
||||
degraded_reason = str(retry_exc)
|
||||
ai_raw = _build_city_ai_fallback(
|
||||
ai_input,
|
||||
locale=normalized_locale,
|
||||
reason=degraded_reason or retry_reason,
|
||||
raw_content=accumulated,
|
||||
)
|
||||
generated_at = datetime.utcnow().isoformat() + "Z"
|
||||
_cache_city_ai_payload(
|
||||
cache_key,
|
||||
data=data,
|
||||
generated_at=generated_at,
|
||||
ai_raw=ai_raw,
|
||||
)
|
||||
if not _is_city_ai_fallback(ai_raw):
|
||||
_cache_city_ai_payload(
|
||||
cache_key,
|
||||
data=data,
|
||||
generated_at=generated_at,
|
||||
ai_raw=ai_raw,
|
||||
)
|
||||
yield _sse_event(
|
||||
"final",
|
||||
_build_city_ai_result_payload(
|
||||
@@ -1795,6 +1867,10 @@ def stream_scan_city_ai_forecast_payload(
|
||||
generated_at=generated_at,
|
||||
started_at=started_at,
|
||||
ai_raw=ai_raw,
|
||||
degraded=degraded,
|
||||
reason=degraded_reason,
|
||||
reason_en=degraded_reason,
|
||||
reason_zh=degraded_reason,
|
||||
),
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
|
||||
Reference in New Issue
Block a user