From da2126e33e9335698cd8851a2ffb354bab300d97 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sun, 26 Apr 2026 09:13:18 +0800 Subject: [PATCH] feat: implement ScanTerminalDashboard with AI-driven city streaming and analysis features --- .../components/dashboard/Dashboard.module.css | 655 +++++- .../dashboard/ScanTerminalDashboard.tsx | 1766 ++--------------- .../scan-terminal/AiCityTemperatureChart.tsx | 128 ++ .../scan-terminal/AiForecastKPIBar.tsx | 76 + .../scan-terminal/AiPinnedForecastView.tsx | 594 ++++++ .../dashboard/scan-terminal/CalendarView.tsx | 127 ++ .../dashboard/scan-terminal/LoadingSignal.tsx | 41 + .../scan-terminal/OpportunityOverview.tsx | 213 ++ .../dashboard/scan-terminal/ai-city-stream.ts | 146 ++ .../scan-terminal/city-card-decision-utils.ts | 370 ++++ .../scan-terminal/city-detail-utils.ts | 48 + .../dashboard/scan-terminal/decision-utils.ts | 372 ++++ .../dashboard/scan-terminal/types.ts | 39 + .../scan-terminal/use-ai-city-card-data.ts | 295 +++ web/scan_terminal_service.py | 112 +- 15 files changed, 3281 insertions(+), 1701 deletions(-) create mode 100644 frontend/components/dashboard/scan-terminal/AiCityTemperatureChart.tsx create mode 100644 frontend/components/dashboard/scan-terminal/AiForecastKPIBar.tsx create mode 100644 frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx create mode 100644 frontend/components/dashboard/scan-terminal/CalendarView.tsx create mode 100644 frontend/components/dashboard/scan-terminal/LoadingSignal.tsx create mode 100644 frontend/components/dashboard/scan-terminal/OpportunityOverview.tsx create mode 100644 frontend/components/dashboard/scan-terminal/ai-city-stream.ts create mode 100644 frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts create mode 100644 frontend/components/dashboard/scan-terminal/city-detail-utils.ts create mode 100644 frontend/components/dashboard/scan-terminal/decision-utils.ts create mode 100644 frontend/components/dashboard/scan-terminal/types.ts create mode 100644 frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index 6159e3fe..89f2345f 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -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; diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 736823ff..c851a8e5 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -1,17 +1,12 @@ "use client"; -import type { ChartConfiguration } from "chart.js"; import clsx from "clsx"; import Link from "next/link"; import { - BarChart3, - ChevronDown, LogIn, Moon, - RefreshCw, Sun, UserRound, - X, } from "lucide-react"; import { useCallback, @@ -26,14 +21,11 @@ import modalChromeStyles from "./ModalChrome.module.css"; import { DetailPanel as CityDetailPanel } from "@/components/dashboard/DetailPanel"; import { FutureForecastModal } from "@/components/dashboard/FutureForecastModal"; import { MapCanvas } from "@/components/dashboard/MapCanvas"; -import { ModelForecast } from "@/components/dashboard/PanelSections"; -import { getWindowPhaseMeta } from "@/components/dashboard/OpportunityTable"; import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall"; import { DashboardStoreProvider, useDashboardStore, } from "@/hooks/useDashboardStore"; -import { useChart } from "@/hooks/useChart"; import { I18nProvider, useI18n } from "@/hooks/useI18n"; import type { CityDetail, @@ -41,1591 +33,28 @@ import type { ScanTerminalResponse, } from "@/lib/dashboard-types"; import { getLocalizedCityName } from "@/lib/dashboard-home-copy"; +import { AiPinnedForecastView } from "@/components/dashboard/scan-terminal/AiPinnedForecastView"; +import { CalendarView } from "@/components/dashboard/scan-terminal/CalendarView"; +import { AiForecastKPIBar } from "@/components/dashboard/scan-terminal/AiForecastKPIBar"; +import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal"; +import { OpportunityOverview } from "@/components/dashboard/scan-terminal/OpportunityOverview"; import { - formatTemperatureValue, - getModelView, - getTemperatureChartData, - getTodayPaceView, - normalizeTemperatureLabel, -} from "@/lib/dashboard-utils"; + findDetailForCity, + isFullEnoughForDeepAnalysis, + waitForDeepAnalysisQueue, +} from "@/components/dashboard/scan-terminal/city-detail-utils"; +import type { AiPinnedCity } from "@/components/dashboard/scan-terminal/types"; import { - getMarketFocus, - getRowMarketRegion, - getRowPeakSortValue, -} from "@/lib/scan-market-focus"; + findRowForCity, + formatUserLocalTime, + normalizeCityKey, + prettifyCityName, + rowMatchesCity, + sortRowsByUserTime, +} from "@/components/dashboard/scan-terminal/decision-utils"; -type ContentView = "list" | "map" | "calendar"; +type ContentView = "opportunities" | "analysis" | "map" | "calendar"; type ThemeMode = "dark" | "light"; -type AiPinnedCity = { - cityName: string; - displayName?: string | null; - addedAt: number; -}; -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; -}; -type AiCityForecastState = { - status: "idle" | "loading" | "ready" | "failed"; - payload?: AiCityForecastPayload | null; - error?: string | null; - streamText?: string | null; - streamRaw?: string | null; -}; - -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?.(); - } -} - -function enqueueAiCityFetch( - task: () => Promise, - signal: AbortSignal, - callbacks?: { - onQueued?: () => void; - onStart?: () => void; - }, -): Promise { - return new Promise((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); - } - }); -} - -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(); -} - -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) - ); -} - -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" }); -} - -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} 分钟`; -} - -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 "--"; -} - -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), - }; -} - -function formatUserLocalTime() { - const now = new Date(); - return `${String(now.getHours()).padStart(2, "0")}:${String( - now.getMinutes(), - ).padStart(2, "0")}`; -} - -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); -} - -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; -} - -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); - }); -} - -function normalizeCityKey(value?: string | null) { - return String(value || "") - .trim() - .toLowerCase() - .replace(/[\s_-]+/g, ""); -} - -function prettifyCityName(value?: string | null) { - return String(value || "") - .trim() - .replace(/[-_]+/g, " ") - .replace(/\b\w/g, (match) => match.toUpperCase()); -} - -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, - ); -} - -function findRowForCity(rows: ScanOpportunityRow[], cityName?: string | null) { - const normalized = normalizeCityKey(cityName); - if (!normalized) return null; - return rows.find((row) => rowMatchesCity(row, cityName || "")) || null; -} - -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 }>; - } - >(); - 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 ( -
-
- {locale === "en-US" ? "No dated opportunities" : "当前没有日期机会"} -
-
- ); - } - - return ( -
- {groups.map((group) => ( -
-
-
-
{group.label}
-
- {locale === "en-US" - ? "Ordered by DEB peak-window countdown" - : "按 DEB 峰值窗口倒计时排序"} -
-
-
- {locale === "en-US" ? `${group.items.length} rows` : `${group.items.length} 条`} -
-
-
- {group.items.map(({ row, meta }) => ( - - ))} -
-
- ))} -
- ); -} - -function findDetailForCity( - detailsByName: Record, - 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 - ); -} - -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; -} - -function countDetailForecastDays(detail?: CityDetail | null) { - const daily = detail?.forecast?.daily; - return Array.isArray(daily) ? daily.length : 0; -} - -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 - ); -} - -function waitForDeepAnalysisQueue(ms: number) { - return new Promise((resolve) => window.setTimeout(resolve, ms)); -} - -type WeatherDecisionView = { - action: string; - confidence: string; - expectedHigh: string; - kicker: string; - reasons: string[]; - risk: string; - targetRange: string; - tone: "cold" | "neutral" | "warm" | "watch"; -}; - -function buildWeatherDecisionView({ - aiCityForecast, - currentTemp, - deb, - isEn, - localModelSupportNote, - modelEntries, - modelMax, - modelMin, - paceTone, - paceView, - peakWindow, - tempSymbol, -}: { - aiCityForecast: AiCityForecastPayload["city_forecast"] | null; - currentTemp: number | null; - deb: number | null; - isEn: boolean; - localModelSupportNote: string; - modelEntries: Array; - modelMax: number | null; - modelMin: number | null; - paceTone: string; - paceView: ReturnType | null; - peakWindow: string; - tempSymbol: string; -}): WeatherDecisionView { - const aiPredicted = Number(aiCityForecast?.predicted_max); - const center = Number.isFinite(aiPredicted) - ? aiPredicted - : paceView?.paceAdjustedHigh != null - ? paceView.paceAdjustedHigh - : deb; - const aiLow = Number(aiCityForecast?.range_low); - const aiHigh = Number(aiCityForecast?.range_high); - const low = Number.isFinite(aiLow) - ? aiLow - : modelMin != null - ? modelMin - : center != null - ? center - 1 - : null; - const high = Number.isFinite(aiHigh) - ? aiHigh - : modelMax != null - ? modelMax - : center != null - ? center + 1 - : null; - const spread = modelMax != null && modelMin != null ? modelMax - modelMin : null; - const modelCount = modelEntries.length; - const aiConfidence = String(aiCityForecast?.confidence || "").trim(); - const confidence = - aiConfidence || - (modelCount >= 4 && spread != null && spread <= 2 - ? isEn - ? "High" - : "高" - : modelCount >= 2 - ? isEn - ? "Medium" - : "中" - : isEn - ? "Low" - : "低"); - const tone = - modelCount <= 1 - ? "watch" - : paceTone === "warm" || paceTone === "cold" || paceTone === "neutral" - ? paceTone - : "neutral"; - const action = - modelCount <= 1 - ? isEn - ? "Wait for model cluster" - : "等待模型补齐" - : paceTone === "warm" - ? isEn - ? "Watch hotter range" - : "关注偏高温区间" - : paceTone === "cold" - ? isEn - ? "Avoid chasing high" - : "暂不追高温" - : isEn - ? "Wait for peak-window confirmation" - : "等待峰值窗口确认"; - const expectedHigh = - center != null && Number.isFinite(Number(center)) - ? formatTemperatureValue(Number(center), tempSymbol, { digits: 1 }) - : "--"; - const targetRange = - low != null && high != null && Number.isFinite(Number(low)) && Number.isFinite(Number(high)) - ? `${formatTemperatureValue(Number(low), tempSymbol, { digits: 1 })} ~ ${formatTemperatureValue(Number(high), tempSymbol, { digits: 1 })}` - : expectedHigh; - const reasons = [ - localModelSupportNote, - paceView?.summary || "", - currentTemp != null - ? isEn - ? `Latest observed anchor is ${formatTemperatureValue(currentTemp, tempSymbol, { digits: 1 })}.` - : `最新实测锚点为 ${formatTemperatureValue(currentTemp, tempSymbol, { digits: 1 })}。` - : "", - ] - .filter(Boolean) - .slice(0, 3); - const risk = - paceTone === "warm" - ? isEn - ? "Risk trigger: if later METAR cools back toward the curve before the peak window, downgrade the hotter read." - : "风险触发:如果后续 METAR 在峰值窗口前回落到曲线附近,需要下调偏高温判断。" - : paceTone === "cold" - ? isEn - ? "Risk trigger: only restore higher buckets if observations recover before the peak window." - : "风险触发:只有实测在峰值窗口前修复,才重新考虑更高温区间。" - : isEn - ? "Risk trigger: a clear METAR/path break before the peak window should decide direction." - : "风险触发:峰值窗口前若 METAR 或路径明显偏离,再决定方向。"; - - return { - action, - confidence, - expectedHigh, - kicker: isEn - ? "Weather decision layer · no market price input" - : "天气决策层 · 未接入市场价格", - reasons, - risk, - targetRange, - tone, - }; -} - -function AiCityTemperatureChart({ detail }: { detail: CityDetail }) { - const { locale } = useI18n(); - const chartData = useMemo( - () => 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 ( -
-
- - {locale === "en-US" ? "Evidence · intraday path" : "证据 · 今日日内路径"} -
-
- -
- {chartData ? ( -
- {forecastLabel} - {observationLabel} -
- ) : null} -
- ); -} - -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 [aiForecast, setAiForecast] = useState({ - status: "idle", - }); - const [aiRefreshToken, setAiRefreshToken] = useState(0); - const detailCityName = detail?.name || item.cityName; - const aiForecastKey = detail - ? `${normalizeCityKey(detailCityName)}:${detail.local_date || ""}:${report || ""}` - : ""; - - useEffect(() => { - if (!aiForecastKey) 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; - } - - 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; - 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 === "delta") { - const content = String(data.content || ""); - if (!content) return; - rawStream += content; - const airportRead = extractStreamingAirportRead(rawStream, locale); - if (!cancelled) { - setAiForecast((current) => - current.status === "loading" - ? { - ...current, - streamRaw: rawStream, - streamText: airportRead || 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, locale]); - - 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 decisionView = buildWeatherDecisionView({ - aiCityForecast, - currentTemp, - deb, - isEn, - localModelSupportNote, - modelEntries, - modelMax, - modelMin, - paceTone, - paceView, - peakWindow, - tempSymbol, - }); - const localizedRisksRaw = - (isEn ? aiCityForecast?.risks_en : aiCityForecast?.risks_zh) || []; - const localizedRisks = Array.isArray(localizedRisksRaw) - ? 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 ( -
-
-
- - {isEn ? "Deep analysis" : "城市深度分析"} - -

{displayName}

-
- {detail?.local_time || row?.local_time || "--"} - - DEB{" "} - {deb != null - ? formatTemperatureValue(deb, tempSymbol, { digits: 1 }) - : "--"} - - {isEn ? "Model" : "模型"} {modelRange} - {isEn ? "Peak" : "峰值"} {peakWindow} -
-
-
- {isEn ? "Expected high" : "预计最高温"} - - {paceView?.paceAdjustedHigh != null - ? formatTemperatureValue(paceView.paceAdjustedHigh, tempSymbol, { digits: 1 }) - : deb != null - ? formatTemperatureValue(deb, tempSymbol, { digits: 1 }) - : "--"} - -
- - - -
-
-
- - {detail && !collapsed ? ( -
-
-
- {decisionView.kicker} - {decisionView.action} -

{localizedFinalJudgment || paceText}

-
- {decisionView.reasons.map((reason, index) => ( - {reason} - ))} -
-

{decisionView.risk}

-
-
- - {isEn ? "Expected high" : "预计高点"} - {decisionView.expectedHigh} - - - {isEn ? "Weather range" : "天气区间"} - {decisionView.targetRange} - - - {isEn ? "Confidence" : "信心"} - {decisionView.confidence} - - - {isEn ? "Observed" : "实测"} - - {currentTemp != null - ? formatTemperatureValue(currentTemp, tempSymbol, { digits: 1 }) - : "--"} - - - - {isEn ? "Path delta" : "路径偏差"} {paceView?.deltaText || "--"} - - - {isEn ? "Peak window" : "峰值窗口"} {peakWindow} - -
-
- -
- -
-
- {isEn ? "Evidence · AI airport read" : "证据 · AI 机场报文解读"} -
- {aiForecast.status === "loading" ? ( - <> -

- {aiForecast.streamText || - (isEn - ? "Deepseek V4 pro is reading the latest airport bulletin..." - : "Deepseek V4 pro 正在解读最新机场报文...")} -

- {aiForecast.streamText ? ( -

- {isEn - ? "Streaming airport read; final forecast is still being completed..." - : "机场报文解读正在流式输出,最终预报结论仍在补全..."} -

- ) : null} - - ) : aiForecast.status === "ready" && aiCityForecast ? ( - <> -

- {localizedFinalJudgment || - (isEn ? "AI read returned without a final sentence." : "AI 已返回,但缺少最终判断。")} -

-
    - {aiBullets.map((line, index) => ( -
  • {line}
  • - ))} -
-

- {report - ? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${airportStation} ${report}`.trim()}` - : isEn - ? "Raw METAR: unavailable." - : "原始 METAR:暂无。"} -

- - ) : aiForecast.status === "ready" ? ( - <> -

- {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 解读。")} -

-
    -
  • {localModelSupportNote}
  • -
  • - {report - ? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${airportStation} ${report}`.trim()}` - : isEn - ? "Raw METAR is unavailable." - : "暂无原始 METAR。"} -
  • -
- - ) : aiForecast.status === "failed" ? ( - <> -

- {isEn - ? "AI read failed. Model support and the raw METAR remain as fallback context." - : "AI 解读失败。下方保留多模型支撑和原始 METAR 作为兜底上下文。"} - {aiForecast.error ? ` ${aiForecast.error}` : ""} -

-
    -
  • {localModelSupportNote}
  • -
  • - {report - ? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${airportStation} ${report}`.trim()}` - : isEn - ? "Raw METAR is unavailable." - : "暂无原始 METAR。"} -
  • -
- - ) : ( -

- {isEn - ? "Waiting for AI to read the latest airport bulletin." - : "等待 AI 解读最新机场报文。"} -

- )} -
-
- -
-
- {isEn ? "Evidence · multi-model support" : "证据 · 多模型支撑"} -
- -
- -
- ) : !detail ? ( -
- {isEn ? "Loading today analysis for this city..." : "正在加载该城市的今日日内分析..."} -
- ) : null} -
- ); -} - -function AiPinnedForecastView({ - items, - rows, - detailsByName, - locale, - onRemoveCity, -}: { - items: AiPinnedCity[]; - rows: ScanOpportunityRow[]; - detailsByName: Record; - locale: string; - onRemoveCity: (cityName: string) => void; -}) { - const isEn = locale === "en-US"; - const [collapsedCities, setCollapsedCities] = useState>( - () => new Set(), - ); - const [removingCities, setRemovingCities] = useState>( - () => new Set(), - ); - const knownCityKeysRef = useRef>(new Set()); - const removeTimersRef = useRef>>(new Map()); - - useEffect(() => { - const activeKeys = new Set( - items.map((item) => normalizeCityKey(item.cityName) || item.cityName), - ); - setCollapsedCities((current) => { - const next = new Set(); - 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 ( -
-
-
- {isEn ? "Click a city on the map" : "从分布视图点击城市"} -
-
- {isEn - ? "Selected cities will appear here as deep analysis blocks." - : "被点击的城市会加入深度分析页,并保留为城市分析区块。"} -
-
-
- ); - } - - return ( -
-
-
- {isEn ? "Selected city workspace" : "城市分析工作区"} - - {isEn - ? `${items.length} cities under deep analysis` - : `${items.length} 个城市正在深度分析`} - -
-

- {isEn - ? "Map clicks add cities here. City analysis stays here until you remove it." - : "地图点击会把城市加入这里;城市分析会保留,直到你手动移除。"} -

-
-
- {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 ( - removeCityWithMotion(item, stableKey)} - onToggleCollapsed={() => { - setCollapsedCities((current) => { - const next = new Set(current); - if (next.has(stableKey)) { - next.delete(stableKey); - } else { - next.add(stableKey); - } - return next; - }); - }} - /> - ); - })} -
-
- ); -} - -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 ? "Deep Analysis" : "深度分析", - value: String(pinnedCount), - note: isEn ? "Cities selected from map clicks" : "地图点选后进入深度分析", - 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 ? "DEB final blended forecast" : "DEB 最终融合预测,不含市场价格", - tone: "cyan", - }, - { - label: isEn ? "Peak Window" : "峰值窗口", - value: peakWindow, - note: isEn ? "Key window for daily high judgment" : "用于判断是否接近今日最高温", - tone: "amber", - }, - ]; - - return ( -
- {cards.map((card) => ( -
-
{card.label}
-
{card.value}
-
{card.note}
-
- ))} -
- ); -} - function ScanTerminalScreen() { const store = useDashboardStore(); const { locale, toggleLocale } = useI18n(); @@ -1634,9 +63,11 @@ function ScanTerminalScreen() { const accountHref = store.proAccess.authenticated ? "/account" : "/auth/login?next=%2Faccount"; - const [terminalData] = useState(null); + const [terminalData, setTerminalData] = useState(null); + const [scanLoading, setScanLoading] = useState(false); + const [scanError, setScanError] = useState(null); const [selectedRowId, setSelectedRowId] = useState(null); - const [activeView, setActiveView] = useState("map"); + const [activeView, setActiveView] = useState("opportunities"); const [mapSelectedCityName, setMapSelectedCityName] = useState(null); const [aiPinnedCities, setAiPinnedCities] = useState([]); const [showScanPaywall, setShowScanPaywall] = useState(false); @@ -1656,6 +87,12 @@ function ScanTerminalScreen() { return timeSortedRows.find((row) => row.id === selectedRowId) || timeSortedRows[0] || null; }, [timeSortedRows, selectedRowId]); + useEffect(() => { + if (!timeSortedRows.length) return; + if (selectedRowId && timeSortedRows.some((row) => row.id === selectedRowId)) return; + setSelectedRowId(timeSortedRows[0].id); + }, [selectedRowId, timeSortedRows]); + const mapFocusedRow = useMemo(() => { return findRowForCity( timeSortedRows, @@ -1769,6 +206,65 @@ function ScanTerminalScreen() { } }, [activeView, isPro, store.proAccess.loading]); + useEffect(() => { + if (store.proAccess.loading) return; + if (!isPro) { + setScanLoading(false); + setScanError(null); + setTerminalData(null); + return; + } + let cancelled = false; + const controller = new AbortController(); + setScanLoading(true); + setScanError(null); + const params = new URLSearchParams({ + scan_mode: "tradable", + min_price: "0.05", + max_price: "0.95", + min_edge_pct: "2", + min_liquidity: "500", + market_type: "maxtemp", + time_range: "today", + limit: "36", + }); + fetch(`/api/scan/terminal?${params.toString()}`, { + cache: "no-store", + signal: controller.signal, + }) + .then(async (response) => { + if (!response.ok) { + let message = `HTTP ${response.status}`; + try { + const payload = await response.json(); + message = String(payload?.error || payload?.detail || message); + } catch { + // Keep HTTP status message. + } + throw new Error(message); + } + return response.json() as Promise; + }) + .then((payload) => { + if (cancelled) return; + setTerminalData(payload); + setScanError(null); + }) + .catch((error) => { + if (cancelled || controller.signal.aborted) return; + setScanError(error instanceof Error ? error.message : String(error)); + }) + .finally(() => { + if (!cancelled) { + setScanLoading(false); + } + }); + return () => { + cancelled = true; + controller.abort(); + }; + }, [isPro, store.proAccess.loading]); + useEffect(() => { setUserLocalTime(formatUserLocalTime()); const intervalId = window.setInterval(() => { @@ -1908,6 +404,7 @@ function ScanTerminalScreen() { const matchedRow = findRowForCity(timeSortedRows, cityName); setSelectedRowId(matchedRow?.id || null); addAiPinnedCity(cityName); + setActiveView("analysis"); }, [addAiPinnedCity, timeSortedRows]); useEffect(() => { @@ -1942,11 +439,50 @@ function ScanTerminalScreen() { void store.selectCity(cityName); }, [store]); + const handleOpenDecisionRow = useCallback((row: ScanOpportunityRow) => { + const cityName = row.city || row.city_display_name || row.display_name || ""; + if (!cityName) return; + setSelectedRowId(row.id); + addAiPinnedCity(cityName); + setActiveView("analysis"); + void store.selectCity(cityName); + }, [addAiPinnedCity, store]); + const openScanPaywall = useCallback(() => { setShowScanPaywall(true); }, []); const renderMainView = () => { + if (resolvedView === "opportunities") { + if (!isPro) { + return ( +
+ {isEn ? "Opportunity board is Pro" : "今日机会榜需 Pro 权限"} +

+ {isEn + ? "Map exploration and city briefing are still available." + : "地图探索和城市简报仍可使用。"} +

+ +
+ ); + } + return ( + setActiveView("map")} + /> + ); + } if (resolvedView === "map") { return (
@@ -1959,7 +495,7 @@ function ScanTerminalScreen() {
); } - if (resolvedView === "list") { + if (resolvedView === "analysis") { return (
-
- -
- {isEn ? "Preparing deep analysis" : "正在准备深度分析"} -
-
- {isEn ? "Checking access and loading city context." : "正在检查权限并载入城市上下文。"} -
- +
+
@@ -2033,11 +562,11 @@ function ScanTerminalScreen() {
- {isEn ? "Deep Analysis Terminal" : "深度分析台"} + {isEn ? "AI Weather Decision Terminal" : "AI 天气交易决策台"} {isEn - ? "Click cities on the map to build a deep analysis workspace" - : "点击地图城市加入深度分析工作区,按城市查看 DEB / 模型 / METAR"} + ? "Start from opportunities, then open city cards to verify weather evidence" + : "先看今日机会榜,再打开城市决策卡验证天气证据"}
@@ -2102,6 +631,15 @@ function ScanTerminalScreen() {
+ + + +
+
+ + + {detail && !collapsed ? ( +
+
+
+ {decisionView.kicker} + {decisionView.action} +

{localizedFinalJudgment || paceText}

+
+ {decisionView.reasons.map((reason, index) => ( + {reason} + ))} +
+

{decisionView.risk}

+
+
+ + {isEn ? "Polymarket price layer" : "Polymarket 价格层"} + + {marketDecisionView.title} +

{marketDecisionView.reason}

+
+
+ + {isEn ? "Bucket" : "温度桶"} {marketDecisionView.bucketLabel} + + + {isEn ? "YES" : "YES 买入"} {marketDecisionView.priceText} + + + {isEn ? "Edge" : "概率差"} {marketDecisionView.edgeText} + +
+ {marketDecisionView.marketUrl ? ( + + {isEn ? "Open market" : "打开市场"} + + ) : null} +
+
+
+ + {isEn ? "Expected high" : "预计高点"} + {decisionView.expectedHigh} + + + {isEn ? "Weather range" : "天气区间"} + {decisionView.targetRange} + + + {isEn ? "Confidence" : "信心"} + {decisionView.confidence} + + + {isEn ? "Observed" : "实测"} + + {currentTemp != null + ? formatTemperatureValue(currentTemp, tempSymbol, { digits: 1 }) + : "--"} + + + + {isEn ? "Path delta" : "路径偏差"} {paceView?.deltaText || "--"} + + + {isEn ? "Peak window" : "峰值窗口"} {peakWindow} + + + {isEn ? "Market implied" : "市场隐含"} {marketDecisionView.impliedText} + + + {isEn ? "Model prob" : "模型概率"} {marketDecisionView.modelText} + + + {isEn ? "Quote status" : "报价状态"} {marketDecisionView.status === "ready" ? (isEn ? "Ready" : "已同步") : marketDecisionView.status === "loading" ? (isEn ? "Loading" : "同步中") : (isEn ? "Unavailable" : "不可用")} + +
+
+ +
+ +
+
+ {isEn ? "Evidence · AI airport read" : "证据 · AI 机场报文解读"} +
+ {aiForecast.status === "loading" ? ( + <> +

+ {aiForecast.streamText || + (isEn + ? "Deepseek V4 pro is reading the latest airport bulletin..." + : "Deepseek V4 pro 正在解读最新机场报文...")} +

+ {aiForecast.streamText ? ( +

+ {isEn + ? "Streaming airport read; final forecast is still being completed..." + : "机场报文解读正在流式输出,最终预报结论仍在补全..."} +

+ ) : null} + + ) : aiForecast.status === "ready" && aiCityForecast ? ( + <> +

+ {localizedFinalJudgment || + (isEn ? "AI read returned without a final sentence." : "AI 已返回,但缺少最终判断。")} +

+
    + {aiBullets.map((line, index) => ( +
  • {line}
  • + ))} +
+

+ {report + ? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${airportStation} ${report}`.trim()}` + : isEn + ? "Raw METAR: unavailable." + : "原始 METAR:暂无。"} +

+ + ) : aiForecast.status === "ready" ? ( + <> +

+ {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 解读。")} +

+
    +
  • {localModelSupportNote}
  • +
  • + {report + ? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${airportStation} ${report}`.trim()}` + : isEn + ? "Raw METAR is unavailable." + : "暂无原始 METAR。"} +
  • +
+ + ) : aiForecast.status === "failed" ? ( + <> +

+ {isEn + ? "AI read failed. Model support and the raw METAR remain as fallback context." + : "AI 解读失败。下方保留多模型支撑和原始 METAR 作为兜底上下文。"} + {aiForecast.error ? ` ${aiForecast.error}` : ""} +

+
    +
  • {localModelSupportNote}
  • +
  • + {report + ? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${airportStation} ${report}`.trim()}` + : isEn + ? "Raw METAR is unavailable." + : "暂无原始 METAR。"} +
  • +
+ + ) : ( +

+ {isEn + ? "Waiting for AI to read the latest airport bulletin." + : "等待 AI 解读最新机场报文。"} +

+ )} +
+
+ +
+
+ {isEn ? "Evidence · multi-model support" : "证据 · 多模型支撑"} +
+ +
+ +
+ ) : !detail ? ( +
+ +
+ ) : null} + + ); +} + +export function AiPinnedForecastView({ + items, + rows, + detailsByName, + locale, + onRemoveCity, +}: { + items: AiPinnedCity[]; + rows: ScanOpportunityRow[]; + detailsByName: Record; + locale: string; + onRemoveCity: (cityName: string) => void; +}) { + const isEn = locale === "en-US"; + const [collapsedCities, setCollapsedCities] = useState>( + () => new Set(), + ); + const [removingCities, setRemovingCities] = useState>( + () => new Set(), + ); + const knownCityKeysRef = useRef>(new Set()); + const removeTimersRef = useRef>>(new Map()); + + useEffect(() => { + const activeKeys = new Set( + items.map((item) => normalizeCityKey(item.cityName) || item.cityName), + ); + setCollapsedCities((current) => { + const next = new Set(); + 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 ( +
+
+
+ {isEn ? "Click a city on the map" : "从分布视图点击城市"} +
+
+ {isEn + ? "Selected cities will appear here as deep analysis blocks." + : "被点击的城市会加入深度分析页,并保留为城市分析区块。"} +
+
+
+ ); + } + + return ( +
+
+
+ {isEn ? "Selected city workspace" : "城市分析工作区"} + + {isEn + ? `${items.length} cities under deep analysis` + : `${items.length} 个城市正在深度分析`} + +
+

+ {isEn + ? "Map clicks add cities here. City analysis stays here until you remove it." + : "地图点击会把城市加入这里;城市分析会保留,直到你手动移除。"} +

+
+
+ {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 ( + removeCityWithMotion(item, stableKey)} + onToggleCollapsed={() => { + setCollapsedCities((current) => { + const next = new Set(current); + if (next.has(stableKey)) { + next.delete(stableKey); + } else { + next.add(stableKey); + } + return next; + }); + }} + /> + ); + })} +
+
+ ); +} diff --git a/frontend/components/dashboard/scan-terminal/CalendarView.tsx b/frontend/components/dashboard/scan-terminal/CalendarView.tsx new file mode 100644 index 00000000..3d4b356b --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/CalendarView.tsx @@ -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 }>; + } + >(); + 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 ( +
+
+ {locale === "en-US" ? "No dated opportunities" : "当前没有日期机会"} +
+
+ ); + } + + return ( +
+ {groups.map((group) => ( +
+
+
+
{group.label}
+
+ {locale === "en-US" + ? "Ordered by DEB peak-window countdown" + : "按 DEB 峰值窗口倒计时排序"} +
+
+
+ {locale === "en-US" ? `${group.items.length} rows` : `${group.items.length} 条`} +
+
+
+ {group.items.map(({ row, meta }) => ( + + ))} +
+
+ ))} +
+ ); +} + diff --git a/frontend/components/dashboard/scan-terminal/LoadingSignal.tsx b/frontend/components/dashboard/scan-terminal/LoadingSignal.tsx new file mode 100644 index 00000000..faa70d0e --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/LoadingSignal.tsx @@ -0,0 +1,41 @@ +import clsx from "clsx"; + +export function LoadingSignal({ + title, + description, + compact = false, +}: { + title: string; + description?: string; + compact?: boolean; +}) { + return ( +
+ +
+ {title} + {description ? {description} : null} +
+ +
+ ); +} diff --git a/frontend/components/dashboard/scan-terminal/OpportunityOverview.tsx b/frontend/components/dashboard/scan-terminal/OpportunityOverview.tsx new file mode 100644 index 00000000..638af1e8 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/OpportunityOverview.tsx @@ -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 ( +
onSelectRow(row)} + > +
+
+ {phase.title} + {displayName} +
+ {decision.action} +
+
+ + {isEn ? "Forecast high" : "预测最高温"} + {predicted != null ? formatTemperatureValue(predicted, unit, { digits: 1 }) : "--"} + + + {isEn ? "Bucket" : "推荐温度桶"} + {getRowTemperatureBucket(row)} + + + {isEn ? "Edge" : "概率差"} + {formatRowSignedPercent(row.edge_percent ?? row.gap ?? row.signed_gap)} + +
+

{reason}

+
+ {isEn ? "Model" : "模型"} {formatRowProbability(modelProb)} + {isEn ? "Market" : "市场"} {formatRowProbability(marketProb)} + {isEn ? "YES" : "YES"} {formatRowPrice(price)} + {isEn ? "Confidence" : "信心"} {confidence} +
+ +
+ ); +} + +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 ( +
+ +
+ ); + } + + if (error || rows.length === 0) { + return ( +
+ {isEn ? "No opportunity snapshot yet" : "暂无机会快照"} +

+ {error || + (isEn + ? "Use the map to add cities, or refresh after the scan backend is ready." + : "可以先用地图添加城市;扫描后端就绪后会显示今日机会榜。")} +

+ +
+ ); + } + + return ( +
+
+
+ {isEn ? "Today AI opportunity board" : "今日 AI 机会榜"} + {isEn ? "Decide first, verify second" : "先看决策,再展开证据"} +

+ {isEn + ? "Cards translate weather, METAR and Polymarket pricing into action states." + : "把天气、METAR 与 Polymarket 报价先翻译成行动状态,再让你展开验证。"} +

+
+
+ {isEn ? "Candidates" : "候选"} {summary?.candidate_total ?? rows.length} + {isEn ? "Tradable" : "可交易市场"} {summary?.tradable_market_count ?? "--"} + {isEn ? "Avg edge" : "平均概率差"} {formatRowSignedPercent(summary?.avg_edge_percent)} + + {isEn ? "Updated" : "更新时间"}{" "} + + {terminalData?.generated_at + ? new Date(terminalData.generated_at).toLocaleTimeString(isEn ? "en-US" : "zh-CN", { + hour: "2-digit", + minute: "2-digit", + }) + : "--"} + + +
+
+ +
+ {visibleSections.map((section) => ( +
+
+
+ {section.title} +

{section.subtitle}

+
+ {section.rows.length} +
+
+ {section.rows.map((row) => ( + + ))} +
+
+ ))} +
+
+ ); +} + diff --git a/frontend/components/dashboard/scan-terminal/ai-city-stream.ts b/frontend/components/dashboard/scan-terminal/ai-city-stream.ts new file mode 100644 index 00000000..a9013444 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/ai-city-stream.ts @@ -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( + task: () => Promise, + signal: AbortSignal, + callbacks?: { + onQueued?: () => void; + onStart?: () => void; + }, +): Promise { + return new Promise((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) + ); +} diff --git a/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts b/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts new file mode 100644 index 00000000..7ced4671 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts @@ -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; + modelMax: number | null; + modelMin: number | null; + paceTone: string; + paceView: ReturnType | 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, + }; +} + diff --git a/frontend/components/dashboard/scan-terminal/city-detail-utils.ts b/frontend/components/dashboard/scan-terminal/city-detail-utils.ts new file mode 100644 index 00000000..2f0fea0a --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/city-detail-utils.ts @@ -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, + 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)); +} diff --git a/frontend/components/dashboard/scan-terminal/decision-utils.ts b/frontend/components/dashboard/scan-terminal/decision-utils.ts new file mode 100644 index 00000000..8b3373b9 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/decision-utils.ts @@ -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, + }, + ]; +} + diff --git a/frontend/components/dashboard/scan-terminal/types.ts b/frontend/components/dashboard/scan-terminal/types.ts new file mode 100644 index 00000000..4e6980e4 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/types.ts @@ -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; +}; + diff --git a/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts b/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts new file mode 100644 index 00000000..4e06c179 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts @@ -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({ + 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; + } + + 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; + 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( + 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 }; +} diff --git a/web/scan_terminal_service.py b/web/scan_terminal_service.py index 65491ff5..81399d7e 100644 --- a/web/scan_terminal_service.py +++ b/web/scan_terminal_service.py @@ -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: