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 (