feat: implement ScanTerminalDashboard with map visualization and opportunity tracking components
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user