feat: implement ScanTerminalDashboard with map visualization and opportunity tracking components

This commit is contained in:
2569718930@qq.com
2026-04-24 05:09:19 +08:00
parent 1f87e19cb0
commit 5cc08249e2
4 changed files with 232 additions and 47 deletions
@@ -9510,8 +9510,8 @@
.root :global(.scan-table-header) { .root :global(.scan-table-header) {
display: grid; display: grid;
grid-template-columns: 54px minmax(220px, 1.15fr) minmax(170px, 0.82fr) minmax(250px, 1.15fr) minmax(180px, 0.92fr) 120px 112px; grid-template-columns: 46px minmax(180px, 1.08fr) minmax(132px, 0.72fr) minmax(220px, 1.08fr) minmax(150px, 0.82fr) 92px 82px;
gap: 12px; gap: 10px;
align-items: center; align-items: center;
padding: 14px 18px; padding: 14px 18px;
background: rgba(11, 23, 40, 0.98); background: rgba(11, 23, 40, 0.98);
@@ -9527,6 +9527,21 @@
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow-y: auto; 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), .root :global(.scan-map-view),
@@ -9709,8 +9724,8 @@
.root :global(.scan-table-row) { .root :global(.scan-table-row) {
display: grid; display: grid;
grid-template-columns: 54px minmax(220px, 1.15fr) minmax(170px, 0.82fr) minmax(250px, 1.15fr) minmax(180px, 0.92fr) 120px 112px; grid-template-columns: 46px minmax(180px, 1.08fr) minmax(132px, 0.72fr) minmax(220px, 1.08fr) minmax(150px, 0.82fr) 92px 82px;
gap: 12px; gap: 10px;
align-items: center; align-items: center;
min-height: 132px; min-height: 132px;
padding: 14px 18px; padding: 14px 18px;
@@ -10045,6 +10060,35 @@
border-color: rgba(23, 217, 139, 0.4); 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) { .root :global(.scan-detail-section) {
padding: 16px 0; padding: 16px 0;
border-bottom: 1px solid rgba(90, 123, 166, 0.12); border-bottom: 1px solid rgba(90, 123, 166, 0.12);
+6 -1
View File
@@ -5,7 +5,11 @@ import "leaflet/dist/leaflet.css";
import { useDashboardStore } from "@/hooks/useDashboardStore"; import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useLeafletMap } from "@/hooks/useLeafletMap"; import { useLeafletMap } from "@/hooks/useLeafletMap";
export function MapCanvas() { export function MapCanvas({
onCitySelect,
}: {
onCitySelect?: (cityName: string) => void;
} = {}) {
const store = useDashboardStore(); const store = useDashboardStore();
const { containerRef } = useLeafletMap({ const { containerRef } = useLeafletMap({
cities: store.cities, cities: store.cities,
@@ -16,6 +20,7 @@ export function MapCanvas() {
onMapInteractionChange: store.setMapInteractionActive, onMapInteractionChange: store.setMapInteractionActive,
onRegisterStopMotion: store.registerMapStopMotion, onRegisterStopMotion: store.registerMapStopMotion,
onSelectCity: (cityName) => { onSelectCity: (cityName) => {
onCitySelect?.(cityName);
void store.focusCity(cityName); void store.focusCity(cityName);
}, },
selectedCity: store.selectedCity, selectedCity: store.selectedCity,
@@ -1,7 +1,6 @@
"use client"; "use client";
import React from "react"; import React from "react";
import { Star } from "lucide-react";
import { useI18n } from "@/hooks/useI18n"; import { useI18n } from "@/hooks/useI18n";
import type { import type {
DistributionPreviewPoint, DistributionPreviewPoint,
@@ -255,7 +254,7 @@ export function OpportunityTable({
> >
<div className="scan-rank-cell"> <div className="scan-rank-cell">
<div className={`scan-rank-circle ${scoreClass}`}> <div className={`scan-rank-circle ${scoreClass}`}>
{row.rank || index + 1} {index + 1}
</div> </div>
</div> </div>
@@ -303,10 +302,6 @@ export function OpportunityTable({
<div className="scan-score-cell"> <div className="scan-score-cell">
<ScoreRing score={row.final_score} /> <ScoreRing score={row.final_score} />
</div> </div>
<div className="scan-row-fav">
<Star size={16} />
</div>
</button> </button>
); );
})} })}
@@ -24,9 +24,9 @@ import {
} from "@/components/dashboard/ScanFilterPanel"; } from "@/components/dashboard/ScanFilterPanel";
import { MapCanvas } from "@/components/dashboard/MapCanvas"; import { MapCanvas } from "@/components/dashboard/MapCanvas";
import { getWindowPhaseMeta } from "@/components/dashboard/OpportunityTable"; import { getWindowPhaseMeta } from "@/components/dashboard/OpportunityTable";
import { ModelForecast } from "@/components/dashboard/PanelSections";
import { ScanKPIBar } from "@/components/dashboard/ScanKPIBar"; import { ScanKPIBar } from "@/components/dashboard/ScanKPIBar";
import { OpportunityTable } from "@/components/dashboard/OpportunityTable"; import { OpportunityTable } from "@/components/dashboard/OpportunityTable";
import { FutureForecastModal } from "@/components/dashboard/FutureForecastModal";
import { import {
DashboardStoreProvider, DashboardStoreProvider,
useDashboardStore, useDashboardStore,
@@ -35,6 +35,7 @@ import { I18nProvider, useI18n } from "@/hooks/useI18n";
import { dashboardClient } from "@/lib/dashboard-client"; import { dashboardClient } from "@/lib/dashboard-client";
import type { import type {
CityDetail, CityDetail,
CitySummary,
DistributionPreviewPoint, DistributionPreviewPoint,
MarketScan, MarketScan,
PrimarySignal, PrimarySignal,
@@ -148,6 +149,51 @@ function formatShortDate(value?: string | null, locale = "zh-CN") {
: date.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" }); : 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( function getSideRow(
marketScan: MarketScan | null | undefined, marketScan: MarketScan | null | undefined,
selectedRow: ScanOpportunityRow, 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 { locale } = useI18n();
const isEn = locale === "en-US"; const isEn = locale === "en-US";
const pendingCityLabel =
selectedCityName || detail?.display_name || summary?.display_name || "--";
if (!detail) { if (!selectedCityName && !detail && !summary) {
return ( return (
<aside className="scan-detail-panel"> <aside className="scan-detail-panel">
<div className="scan-empty-state"> <div className="scan-empty-state">
@@ -258,27 +316,40 @@ function MapCityDetailPanel({ detail }: { detail: CityDetail | null }) {
); );
} }
const cityName = getLocalizedCityName( if (loading && !detail) {
detail.name, return (
detail.display_name || detail.name, <aside className="scan-detail-panel">
<div className="scan-empty-state">
<div className="scan-empty-title">
{isEn ? "Loading city context" : "正在加载城市信息"}
</div>
<div className="scan-empty-copy">{pendingCityLabel}</div>
</div>
</aside>
);
}
const displayCityName = getLocalizedCityName(
detail?.name || summary?.name || selectedCityName || "",
detail?.display_name || summary?.display_name || selectedCityName || "",
locale, locale,
); );
const tempSymbol = detail.temp_symbol || "°C"; const tempSymbol = detail?.temp_symbol || summary?.temp_symbol || "°C";
const currentTemp = detail.current?.temp; const currentTemp = detail?.current?.temp ?? summary?.current?.temp ?? null;
const dayHigh = const dayHigh =
detail.current?.max_so_far ?? detail?.current?.max_so_far ??
detail.airport_current?.max_so_far ?? detail?.airport_current?.max_so_far ??
detail.airport_primary?.max_so_far ?? detail?.airport_primary?.max_so_far ??
null; null;
const airportLabel = getLocalizedAirportName( const airportLabel = getLocalizedAirportName(
detail.name, detail?.name || summary?.name || selectedCityName || "",
detail.risk?.airport || detail.risk?.icao || "", detail?.risk?.airport || detail?.risk?.icao || summary?.icao || "",
locale, locale,
); );
const peakLabel = const peakLabel =
Number.isFinite(Number(detail.peak?.first_h)) && Number.isFinite(Number(detail.peak?.last_h)) Number.isFinite(Number(detail?.peak?.first_h)) && Number.isFinite(Number(detail?.peak?.last_h))
? `${String(Number(detail.peak?.first_h)).padStart(2, "0")}:00-${String( ? `${String(Number(detail?.peak?.first_h)).padStart(2, "0")}:00-${String(
Number(detail.peak?.last_h) + 1, Number(detail?.peak?.last_h) + 1,
).padStart(2, "0")}:00` ).padStart(2, "0")}:00`
: "--"; : "--";
@@ -287,7 +358,7 @@ function MapCityDetailPanel({ detail }: { detail: CityDetail | null }) {
<div className="scan-detail-header"> <div className="scan-detail-header">
<div className="scan-detail-top"> <div className="scan-detail-top">
<div className="scan-detail-title-wrap"> <div className="scan-detail-title-wrap">
<div className="scan-detail-city-name">{cityName}</div> <div className="scan-detail-city-name">{displayCityName}</div>
<div className="scan-detail-city-sub"> <div className="scan-detail-city-sub">
{airportLabel || (isEn ? "Airport anchor pending" : "机场锚点待确认")} {airportLabel || (isEn ? "Airport anchor pending" : "机场锚点待确认")}
</div> </div>
@@ -315,7 +386,7 @@ function MapCityDetailPanel({ detail }: { detail: CityDetail | null }) {
<div className="scan-kv-list"> <div className="scan-kv-list">
<div className="scan-kv"> <div className="scan-kv">
<span>{isEn ? "Local Time" : "当前时间"}</span> <span>{isEn ? "Local Time" : "当前时间"}</span>
<strong>{detail.local_time || "--"}</strong> <strong>{detail?.local_time || summary?.local_time || "--"}</strong>
</div> </div>
<div className="scan-kv"> <div className="scan-kv">
<span>{isEn ? "Current Temp" : "当前温度"}</span> <span>{isEn ? "Current Temp" : "当前温度"}</span>
@@ -336,7 +407,7 @@ function MapCityDetailPanel({ detail }: { detail: CityDetail | null }) {
<div className="scan-kv"> <div className="scan-kv">
<span>{isEn ? "DEB Forecast" : "DEB 预测"}</span> <span>{isEn ? "DEB Forecast" : "DEB 预测"}</span>
<strong> <strong>
{detail.deb?.prediction != null {detail?.deb?.prediction != null
? formatTemperatureValue(detail.deb.prediction, tempSymbol) ? formatTemperatureValue(detail.deb.prediction, tempSymbol)
: "--"} : "--"}
</strong> </strong>
@@ -405,11 +476,30 @@ function DetailPanel({
); );
const scoreClass = scoreTone(displayRow.final_score); const scoreClass = scoreTone(displayRow.final_score);
const phaseMeta = getWindowPhaseMeta(displayRow, locale); const phaseMeta = getWindowPhaseMeta(displayRow, locale);
const analysisDetail =
store.selectedDetail?.name?.toLowerCase() === row.city.toLowerCase()
? store.selectedDetail
: store.cityDetailsByName[row.city] || null;
const [showInlineAnalysis, setShowInlineAnalysis] = useState(false);
const analysisLoading = Boolean(
showInlineAnalysis &&
row.city &&
!analysisDetail &&
store.loadingState.cityDetail,
);
useEffect(() => {
setShowInlineAnalysis(false);
}, [row.id]);
const openTodayAnalysis = async () => { const openTodayAnalysis = async () => {
if (!row.city) return; if (!row.city) return;
if (showInlineAnalysis && analysisDetail) {
setShowInlineAnalysis(false);
return;
}
setShowInlineAnalysis(true);
await store.selectCity(row.city); await store.selectCity(row.city);
await store.openTodayModal();
}; };
return ( return (
@@ -457,6 +547,29 @@ function DetailPanel({
</button> </button>
</div> </div>
{showInlineAnalysis && (
<section className="scan-detail-section scan-detail-inline-analysis">
<div className="scan-detail-section-title">
{isEn ? "Today's Intraday Analysis" : "今日日内分析"}
</div>
{analysisLoading ? (
<div className="scan-inline-analysis-state">
{isEn ? "Loading model spread card..." : "正在加载模型分歧卡片..."}
</div>
) : analysisDetail ? (
<ModelForecast
detail={analysisDetail}
hideTitle
targetDate={row.selected_date || null}
/>
) : (
<div className="scan-inline-analysis-state">
{isEn ? "No intraday model card available yet." : "当前还没有可展示的日内模型卡片。"}
</div>
)}
</section>
)}
<section className="scan-detail-section"> <section className="scan-detail-section">
<div className="scan-detail-section-title"> <div className="scan-detail-section-title">
{isEn ? "Current Context" : "当前概况"} {isEn ? "Current Context" : "当前概况"}
@@ -835,12 +948,17 @@ function ScanTerminalScreen() {
const [detailLoadingId, setDetailLoadingId] = useState<string | null>(null); const [detailLoadingId, setDetailLoadingId] = useState<string | null>(null);
const [activeSection, setActiveSection] = useState<TopSection>("terminal"); const [activeSection, setActiveSection] = useState<TopSection>("terminal");
const [activeView, setActiveView] = useState<ContentView>("list"); const [activeView, setActiveView] = useState<ContentView>("list");
const [mapSelectedCityName, setMapSelectedCityName] = useState<string | null>(null);
const deferredRows = useDeferredValue(terminalData?.rows || []); const deferredRows = useDeferredValue(terminalData?.rows || []);
const timeSortedRows = useMemo(
() => sortRowsByUserTime(deferredRows),
[deferredRows],
);
const selectedRow = useMemo(() => { const selectedRow = useMemo(() => {
if (!deferredRows.length) return null; if (!timeSortedRows.length) return null;
return deferredRows.find((row) => row.id === selectedRowId) || deferredRows[0] || null; return timeSortedRows.find((row) => row.id === selectedRowId) || timeSortedRows[0] || null;
}, [deferredRows, selectedRowId]); }, [timeSortedRows, selectedRowId]);
const fetchTerminal = async (filters: FilterState, force = false) => { const fetchTerminal = async (filters: FilterState, force = false) => {
setLoading(true); setLoading(true);
@@ -854,7 +972,7 @@ function ScanTerminalScreen() {
if (current && response.rows.some((row) => row.id === current)) { if (current && response.rows.some((row) => row.id === current)) {
return current; return current;
} }
return response.top_signal?.id || response.rows[0]?.id || null; return sortRowsByUserTime(response.rows)[0]?.id || response.top_signal?.id || null;
}); });
}); });
} catch (fetchError) { } catch (fetchError) {
@@ -905,8 +1023,16 @@ function ScanTerminalScreen() {
}, [selectedRow, detailByRowId]); }, [selectedRow, detailByRowId]);
const selectedDetail = selectedRow ? detailByRowId[selectedRow.id] : null; const selectedDetail = selectedRow ? detailByRowId[selectedRow.id] : null;
const mapFocusedDetail = store.selectedDetail; const mapFocusedCity = mapSelectedCityName || store.selectedCity;
const mapFocusedCity = store.selectedCity; const mapFocusedDetail =
mapFocusedCity && store.selectedDetail?.name?.toLowerCase() === mapFocusedCity.toLowerCase()
? store.selectedDetail
: mapFocusedCity
? store.cityDetailsByName[mapFocusedCity] || null
: null;
const mapFocusedSummary = mapFocusedCity
? store.citySummariesByName[mapFocusedCity] || null
: null;
const resolvedView: ContentView = const resolvedView: ContentView =
activeSection === "markets" activeSection === "markets"
? "map" ? "map"
@@ -916,21 +1042,32 @@ function ScanTerminalScreen() {
const renderMainView = () => { const renderMainView = () => {
if (activeSection === "portfolio") { if (activeSection === "portfolio") {
return <PortfolioView rows={deferredRows} locale={locale} />; return <PortfolioView rows={timeSortedRows} locale={locale} />;
} }
if (activeSection === "monitor") { if (activeSection === "monitor") {
return <MonitorView rows={deferredRows} locale={locale} />; return <MonitorView rows={timeSortedRows} locale={locale} />;
} }
if (activeSection === "settings") { if (activeSection === "settings") {
return <SettingsView locale={locale} />; return <SettingsView locale={locale} />;
} }
if (resolvedView === "map") { if (resolvedView === "map") {
return <OverviewMapView locale={locale} />; return (
<div className="scan-map-view">
<div className="scan-map-shell">
<MapCanvas onCitySelect={setMapSelectedCityName} />
</div>
<div className="scan-map-caption">
{locale === "en-US"
? `Monitoring ${store.cities.length} cities on the original map canvas.`
: `正在用原地图画布监控 ${store.cities.length} 个城市。`}
</div>
</div>
);
} }
if (resolvedView === "calendar") { if (resolvedView === "calendar") {
return ( return (
<CalendarView <CalendarView
rows={deferredRows} rows={timeSortedRows}
locale={locale} locale={locale}
selectedRowId={selectedRowId} selectedRowId={selectedRowId}
onSelectRow={(row) => setSelectedRowId(row.id)} onSelectRow={(row) => setSelectedRowId(row.id)}
@@ -940,7 +1077,7 @@ function ScanTerminalScreen() {
return ( return (
<> <>
<OpportunityTable <OpportunityTable
rows={deferredRows} rows={timeSortedRows}
selectedRowId={selectedRowId} selectedRowId={selectedRowId}
onSelectRow={(row) => setSelectedRowId(row.id)} onSelectRow={(row) => setSelectedRowId(row.id)}
/> />
@@ -1073,14 +1210,14 @@ function ScanTerminalScreen() {
{isEn {isEn
? resolvedView === "calendar" ? resolvedView === "calendar"
? "Sort: Date" ? "Sort: Date"
: resolvedView === "map" : resolvedView === "map"
? "Sort: City Map" ? "Sort: City Map"
: "Sort: Score" : "Sort: Your Time"
: resolvedView === "calendar" : resolvedView === "calendar"
? "排序:日期" ? "排序:日期"
: resolvedView === "map" : resolvedView === "map"
? "排序:地图" ? "排序:地图"
: "排序:综合得分"} : "排序:用户时区时间"}
</div> </div>
<button type="button" className="scan-icon-pill" aria-label="menu"> <button type="button" className="scan-icon-pill" aria-label="menu">
<Menu size={16} /> <Menu size={16} />
@@ -1102,7 +1239,12 @@ function ScanTerminalScreen() {
</main> </main>
{resolvedView === "map" && mapFocusedCity ? ( {resolvedView === "map" && mapFocusedCity ? (
<MapCityDetailPanel detail={mapFocusedDetail} /> <MapCityDetailPanel
cityName={mapFocusedCity}
detail={mapFocusedDetail}
summary={mapFocusedSummary}
loading={Boolean(mapFocusedCity && !mapFocusedDetail && store.loadingState.cityDetail)}
/>
) : ( ) : (
<DetailPanel <DetailPanel
row={selectedRow} row={selectedRow}
@@ -1120,7 +1262,6 @@ export function ScanTerminalDashboard() {
<I18nProvider> <I18nProvider>
<DashboardStoreProvider> <DashboardStoreProvider>
<ScanTerminalScreen /> <ScanTerminalScreen />
<FutureForecastModal />
</DashboardStoreProvider> </DashboardStoreProvider>
</I18nProvider> </I18nProvider>
); );