From 5cc08249e22c22f0dd9369923b63ea3ed4147652 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Fri, 24 Apr 2026 05:09:19 +0800 Subject: [PATCH] feat: implement ScanTerminalDashboard with map visualization and opportunity tracking components --- .../components/dashboard/Dashboard.module.css | 52 ++++- frontend/components/dashboard/MapCanvas.tsx | 7 +- .../components/dashboard/OpportunityTable.tsx | 7 +- .../dashboard/ScanTerminalDashboard.tsx | 213 +++++++++++++++--- 4 files changed, 232 insertions(+), 47 deletions(-) diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index 5ed33430..45c0ab13 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -9510,8 +9510,8 @@ .root :global(.scan-table-header) { display: grid; - grid-template-columns: 54px minmax(220px, 1.15fr) minmax(170px, 0.82fr) minmax(250px, 1.15fr) minmax(180px, 0.92fr) 120px 112px; - gap: 12px; + grid-template-columns: 46px minmax(180px, 1.08fr) minmax(132px, 0.72fr) minmax(220px, 1.08fr) minmax(150px, 0.82fr) 92px 82px; + gap: 10px; align-items: center; padding: 14px 18px; background: rgba(11, 23, 40, 0.98); @@ -9527,6 +9527,21 @@ flex: 1; min-height: 0; overflow-y: auto; + overflow-x: hidden; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.root :global(.scan-table-body::-webkit-scrollbar), +.root :global(.scan-calendar-view::-webkit-scrollbar), +.root :global(.scan-detail-panel::-webkit-scrollbar) { + display: none; +} + +.root :global(.scan-calendar-view), +.root :global(.scan-detail-panel) { + scrollbar-width: none; + -ms-overflow-style: none; } .root :global(.scan-map-view), @@ -9709,8 +9724,8 @@ .root :global(.scan-table-row) { display: grid; - grid-template-columns: 54px minmax(220px, 1.15fr) minmax(170px, 0.82fr) minmax(250px, 1.15fr) minmax(180px, 0.92fr) 120px 112px; - gap: 12px; + grid-template-columns: 46px minmax(180px, 1.08fr) minmax(132px, 0.72fr) minmax(220px, 1.08fr) minmax(150px, 0.82fr) 92px 82px; + gap: 10px; align-items: center; min-height: 132px; padding: 14px 18px; @@ -10045,6 +10060,35 @@ border-color: rgba(23, 217, 139, 0.4); } +.root :global(.scan-detail-inline-analysis) { + display: flex; + flex-direction: column; + gap: 12px; +} + +.root :global(.scan-detail-inline-analysis .models-section) { + margin: 0; + padding: 0; + border: 0; + background: transparent; +} + +.root :global(.scan-detail-inline-analysis .model-bars) { + padding: 14px; + border-radius: 16px; + border: 1px solid rgba(90, 123, 166, 0.14); + background: rgba(10, 20, 35, 0.72); +} + +.root :global(.scan-inline-analysis-state) { + padding: 14px; + border-radius: 14px; + border: 1px solid rgba(90, 123, 166, 0.12); + color: #9bb0cb; + font-size: 13px; + background: rgba(10, 20, 35, 0.52); +} + .root :global(.scan-detail-section) { padding: 16px 0; border-bottom: 1px solid rgba(90, 123, 166, 0.12); diff --git a/frontend/components/dashboard/MapCanvas.tsx b/frontend/components/dashboard/MapCanvas.tsx index 706ec0c3..f791875f 100644 --- a/frontend/components/dashboard/MapCanvas.tsx +++ b/frontend/components/dashboard/MapCanvas.tsx @@ -5,7 +5,11 @@ import "leaflet/dist/leaflet.css"; import { useDashboardStore } from "@/hooks/useDashboardStore"; import { useLeafletMap } from "@/hooks/useLeafletMap"; -export function MapCanvas() { +export function MapCanvas({ + onCitySelect, +}: { + onCitySelect?: (cityName: string) => void; +} = {}) { const store = useDashboardStore(); const { containerRef } = useLeafletMap({ cities: store.cities, @@ -16,6 +20,7 @@ export function MapCanvas() { onMapInteractionChange: store.setMapInteractionActive, onRegisterStopMotion: store.registerMapStopMotion, onSelectCity: (cityName) => { + onCitySelect?.(cityName); void store.focusCity(cityName); }, selectedCity: store.selectedCity, diff --git a/frontend/components/dashboard/OpportunityTable.tsx b/frontend/components/dashboard/OpportunityTable.tsx index 9fd7858f..9def9905 100644 --- a/frontend/components/dashboard/OpportunityTable.tsx +++ b/frontend/components/dashboard/OpportunityTable.tsx @@ -1,7 +1,6 @@ "use client"; import React from "react"; -import { Star } from "lucide-react"; import { useI18n } from "@/hooks/useI18n"; import type { DistributionPreviewPoint, @@ -255,7 +254,7 @@ export function OpportunityTable({ >
- {row.rank || index + 1} + {index + 1}
@@ -303,10 +302,6 @@ export function OpportunityTable({
- -
- -
); })} diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index bf40382e..5b158b26 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -24,9 +24,9 @@ import { } from "@/components/dashboard/ScanFilterPanel"; import { MapCanvas } from "@/components/dashboard/MapCanvas"; import { getWindowPhaseMeta } from "@/components/dashboard/OpportunityTable"; +import { ModelForecast } from "@/components/dashboard/PanelSections"; import { ScanKPIBar } from "@/components/dashboard/ScanKPIBar"; import { OpportunityTable } from "@/components/dashboard/OpportunityTable"; -import { FutureForecastModal } from "@/components/dashboard/FutureForecastModal"; import { DashboardStoreProvider, useDashboardStore, @@ -35,6 +35,7 @@ import { I18nProvider, useI18n } from "@/hooks/useI18n"; import { dashboardClient } from "@/lib/dashboard-client"; import type { CityDetail, + CitySummary, DistributionPreviewPoint, MarketScan, PrimarySignal, @@ -148,6 +149,51 @@ function formatShortDate(value?: string | null, locale = "zh-CN") { : date.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" }); } +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 === "early_today") return 2; + if (phase === "post_peak") return 3; + if (phase === "tomorrow") return 4; + if (phase === "week_ahead") return 5; + return 6; +} + +function sortRowsByUserTime(rows: ScanOpportunityRow[]) { + return [...rows].sort((left, right) => { + 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 getSideRow( marketScan: MarketScan | null | undefined, selectedRow: ScanOpportunityRow, @@ -239,11 +285,23 @@ function buildComparisonBuckets( ]; } -function MapCityDetailPanel({ detail }: { detail: CityDetail | null }) { +function MapCityDetailPanel({ + cityName: selectedCityName, + detail, + summary, + loading, +}: { + cityName: string | null; + detail: CityDetail | null; + summary: CitySummary | null; + loading?: boolean; +}) { const { locale } = useI18n(); const isEn = locale === "en-US"; + const pendingCityLabel = + selectedCityName || detail?.display_name || summary?.display_name || "--"; - if (!detail) { + if (!selectedCityName && !detail && !summary) { return (