feat: implement scan terminal dashboard with filtering, opportunity table, and detail panel components
This commit is contained in:
@@ -9007,8 +9007,8 @@
|
||||
.root :global(.scan-sidebar-brand) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 10px 18px;
|
||||
gap: 0;
|
||||
padding: 8px 4px 18px;
|
||||
border-bottom: 1px solid rgba(118, 146, 188, 0.12);
|
||||
}
|
||||
|
||||
@@ -9026,7 +9026,7 @@
|
||||
.root :global(.scan-sidebar-brand-name) {
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
letter-spacing: 0;
|
||||
color: #eef5ff;
|
||||
}
|
||||
|
||||
@@ -9328,6 +9328,7 @@
|
||||
|
||||
.root :global(.scan-ghost-button),
|
||||
.root :global(.scan-cta-ghost),
|
||||
.root :global(.scan-theme-button),
|
||||
.root :global(.scan-sort-pill),
|
||||
.root :global(.scan-icon-pill),
|
||||
.root :global(.scan-detail-action-button),
|
||||
@@ -9372,6 +9373,15 @@
|
||||
background: rgba(12, 26, 44, 0.96);
|
||||
}
|
||||
|
||||
.root :global(.scan-theme-button) {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
color: #d7e5f7;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.root :global(.scan-ghost-button .spin) {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@@ -9736,6 +9746,11 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.root :global(.scan-table-row > *),
|
||||
.root :global(.scan-table-header > *) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-table-row:hover) {
|
||||
transform: none;
|
||||
background: linear-gradient(180deg, rgba(11, 24, 40, 0.96), rgba(8, 18, 31, 0.96));
|
||||
@@ -9797,6 +9812,10 @@
|
||||
.root :global(.scan-city-sub) {
|
||||
font-size: 14px;
|
||||
color: #a5bad5;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.root :global(.scan-city-volume) {
|
||||
@@ -9854,12 +9873,14 @@
|
||||
.root :global(.scan-distribution-preview) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-distribution-card) {
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
padding: 10px 6px;
|
||||
padding: 9px 5px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(87, 119, 166, 0.1);
|
||||
background: rgba(15, 28, 46, 0.72);
|
||||
@@ -9871,7 +9892,8 @@
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.root :global(.scan-distribution-card.featured strong) {
|
||||
@@ -9885,8 +9907,9 @@
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-main) {
|
||||
font-size: 18px;
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-main.buy) {
|
||||
@@ -10367,6 +10390,129 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light) {
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(65, 120, 190, 0.14), transparent 34%),
|
||||
linear-gradient(180deg, #f4f8fd 0%, #e9f0f8 100%);
|
||||
color: #102033;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-filter-panel),
|
||||
.root :global(.scan-terminal.light .scan-data-grid),
|
||||
.root :global(.scan-terminal.light .scan-detail-panel) {
|
||||
border-color: rgba(35, 72, 118, 0.14);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(242, 247, 253, 0.94));
|
||||
box-shadow: 0 16px 34px rgba(40, 70, 110, 0.12);
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-sidebar-brand-name),
|
||||
.root :global(.scan-terminal.light .scan-topbar-tab.active),
|
||||
.root :global(.scan-terminal.light .scan-hero h1),
|
||||
.root :global(.scan-terminal.light .scan-city-name),
|
||||
.root :global(.scan-terminal.light .scan-detail-city-name),
|
||||
.root :global(.scan-terminal.light .scan-empty-title),
|
||||
.root :global(.scan-terminal.light .scan-detail-section-title),
|
||||
.root :global(.scan-terminal.light .scan-detail-volume-big),
|
||||
.root :global(.scan-terminal.light .scan-kv strong),
|
||||
.root :global(.scan-terminal.light .scan-time-main),
|
||||
.root :global(.scan-terminal.light .scan-calendar-date),
|
||||
.root :global(.scan-terminal.light .scan-summary-value),
|
||||
.root :global(.scan-terminal.light .scan-distribution-card strong),
|
||||
.root :global(.scan-terminal.light .scan-table-header),
|
||||
.root :global(.scan-terminal.light .scan-list-tabs button.active) {
|
||||
color: #122033;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-filter-heading),
|
||||
.root :global(.scan-terminal.light .scan-topbar-actions),
|
||||
.root :global(.scan-terminal.light .scan-topbar-time),
|
||||
.root :global(.scan-terminal.light .scan-hero p),
|
||||
.root :global(.scan-terminal.light .scan-city-sub),
|
||||
.root :global(.scan-terminal.light .scan-time-remaining),
|
||||
.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-kv span:first-child),
|
||||
.root :global(.scan-terminal.light .scan-chart-label),
|
||||
.root :global(.scan-terminal.light .scan-trade-sub),
|
||||
.root :global(.scan-terminal.light .scan-trade-note) {
|
||||
color: #58708f;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-list-tabs button),
|
||||
.root :global(.scan-terminal.light .scan-mode-tab-sub),
|
||||
.root :global(.scan-terminal.light .scan-distribution-card),
|
||||
.root :global(.scan-terminal.light .scan-chart-legend),
|
||||
.root :global(.scan-terminal.light .scan-kpi-note) {
|
||||
color: #647a98;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-mode-tab),
|
||||
.root :global(.scan-terminal.light .scan-table-shell),
|
||||
.root :global(.scan-terminal.light .scan-table-header),
|
||||
.root :global(.scan-terminal.light .scan-calendar-group),
|
||||
.root :global(.scan-terminal.light .scan-calendar-card),
|
||||
.root :global(.scan-terminal.light .scan-summary-card),
|
||||
.root :global(.scan-terminal.light .scan-settings-card),
|
||||
.root :global(.scan-terminal.light .scan-distribution-card),
|
||||
.root :global(.scan-terminal.light .scan-trade-card),
|
||||
.root :global(.scan-terminal.light .scan-detail-inline-analysis .model-bars),
|
||||
.root :global(.scan-terminal.light .scan-inline-analysis-state) {
|
||||
border-color: rgba(35, 72, 118, 0.12);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-mode-tab.active),
|
||||
.root :global(.scan-terminal.light .scan-table-row.selected) {
|
||||
background: linear-gradient(180deg, rgba(228, 252, 242, 0.92), rgba(241, 250, 247, 0.96));
|
||||
border-color: rgba(10, 160, 100, 0.34);
|
||||
box-shadow: inset 0 0 0 1px rgba(10, 160, 100, 0.18);
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-table-row) {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(247, 250, 254, 0.9));
|
||||
border-bottom-color: rgba(35, 72, 118, 0.08);
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-table-row:hover) {
|
||||
background: linear-gradient(180deg, rgba(248, 252, 255, 0.96), rgba(238, 247, 255, 0.96));
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-locale-switch),
|
||||
.root :global(.scan-terminal.light .scan-ghost-button),
|
||||
.root :global(.scan-terminal.light .scan-theme-button),
|
||||
.root :global(.scan-terminal.light .scan-sort-pill),
|
||||
.root :global(.scan-terminal.light .scan-icon-pill),
|
||||
.root :global(.scan-terminal.light .scan-account-button),
|
||||
.root :global(.scan-terminal.light .scan-detail-action-button) {
|
||||
border-color: rgba(35, 72, 118, 0.14);
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
color: #1f3654;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-cta-ghost),
|
||||
.root :global(.scan-terminal.light .scan-detail-analysis-button) {
|
||||
border-color: rgba(7, 160, 100, 0.24);
|
||||
background: rgba(11, 188, 116, 0.12);
|
||||
color: #057a4d;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-score-ring::after) {
|
||||
background: #f5f9fe;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-score-ring span) {
|
||||
color: #122033;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-trade-card p) {
|
||||
color: #405977;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-map-shell) {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
@media (max-width: 1480px) {
|
||||
.root :global(.scan-terminal) {
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
|
||||
@@ -193,7 +193,7 @@ function ScoreRing({ score }: { score?: number | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function OpportunityTable({
|
||||
export const OpportunityTable = React.memo(function OpportunityTable({
|
||||
rows,
|
||||
selectedRowId,
|
||||
onSelectRow,
|
||||
@@ -308,4 +308,4 @@ export function OpportunityTable({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import React from "react";
|
||||
import {
|
||||
CircleDot,
|
||||
@@ -77,15 +76,6 @@ export function ScanFilterPanel({
|
||||
return (
|
||||
<aside className="scan-filter-panel">
|
||||
<div className="scan-sidebar-brand">
|
||||
<div className="scan-sidebar-brand-mark">
|
||||
<Image
|
||||
src="/favicon-32x32.png"
|
||||
alt=""
|
||||
width={24}
|
||||
height={24}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="scan-sidebar-brand-name">PolyWeather</div>
|
||||
</div>
|
||||
|
||||
@@ -6,10 +6,13 @@ import {
|
||||
Bell,
|
||||
Menu,
|
||||
RefreshCw,
|
||||
Moon,
|
||||
Sun,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
startTransition,
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -34,8 +37,6 @@ import {
|
||||
import { I18nProvider, useI18n } from "@/hooks/useI18n";
|
||||
import { dashboardClient } from "@/lib/dashboard-client";
|
||||
import type {
|
||||
CityDetail,
|
||||
CitySummary,
|
||||
DistributionPreviewPoint,
|
||||
MarketScan,
|
||||
PrimarySignal,
|
||||
@@ -74,6 +75,7 @@ const NAV_ITEMS = [
|
||||
|
||||
type TopSection = "terminal" | "markets" | "analysis" | "portfolio" | "monitor" | "settings";
|
||||
type ContentView = "list" | "map" | "calendar";
|
||||
type ThemeMode = "dark" | "light";
|
||||
const TOP_SECTION_ORDER: TopSection[] = [
|
||||
"terminal",
|
||||
"markets",
|
||||
@@ -149,6 +151,14 @@ function formatShortDate(value?: string | null, locale = "zh-CN") {
|
||||
: date.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" });
|
||||
}
|
||||
|
||||
function formatUserLocalTime(locale = "zh-CN") {
|
||||
return new Intl.DateTimeFormat(locale === "en-US" ? "en-US" : "zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(new Date());
|
||||
}
|
||||
|
||||
function getLocalDateIndex(value?: string | null) {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) return 0;
|
||||
@@ -285,147 +295,6 @@ function buildComparisonBuckets(
|
||||
];
|
||||
}
|
||||
|
||||
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 (!selectedCityName && !detail && !summary) {
|
||||
return (
|
||||
<aside className="scan-detail-panel">
|
||||
<div className="scan-empty-state">
|
||||
<div className="scan-empty-title">
|
||||
{isEn ? "Pick a city" : "选择一个城市"}
|
||||
</div>
|
||||
<div className="scan-empty-copy">
|
||||
{isEn ? "The right rail will show city context from the map." : "右侧会展示地图里当前城市的信息。"}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading && !detail) {
|
||||
return (
|
||||
<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,
|
||||
);
|
||||
const tempSymbol = detail?.temp_symbol || summary?.temp_symbol || "°C";
|
||||
const currentTemp = detail?.current?.temp ?? summary?.current?.temp ?? null;
|
||||
const dayHigh =
|
||||
detail?.current?.max_so_far ??
|
||||
detail?.airport_current?.max_so_far ??
|
||||
detail?.airport_primary?.max_so_far ??
|
||||
null;
|
||||
const airportLabel = getLocalizedAirportName(
|
||||
detail?.name || summary?.name || selectedCityName || "",
|
||||
detail?.risk?.airport || detail?.risk?.icao || summary?.icao || "",
|
||||
locale,
|
||||
);
|
||||
const peakLabel =
|
||||
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(
|
||||
Number(detail?.peak?.last_h) + 1,
|
||||
).padStart(2, "0")}:00`
|
||||
: "--";
|
||||
|
||||
return (
|
||||
<aside className="scan-detail-panel">
|
||||
<div className="scan-detail-header">
|
||||
<div className="scan-detail-top">
|
||||
<div className="scan-detail-title-wrap">
|
||||
<div className="scan-detail-city-name">{displayCityName}</div>
|
||||
<div className="scan-detail-city-sub">
|
||||
{airportLabel || (isEn ? "Airport anchor pending" : "机场锚点待确认")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="scan-detail-volume-row">
|
||||
<div>
|
||||
<div className="scan-detail-volume-big">
|
||||
{currentTemp != null
|
||||
? formatTemperatureValue(currentTemp, tempSymbol)
|
||||
: "--"}
|
||||
</div>
|
||||
<div className="scan-detail-volume-caption">
|
||||
{isEn ? "Current observed temperature" : "当前实测温度"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="scan-detail-section">
|
||||
<div className="scan-detail-section-title">
|
||||
{isEn ? "Current Context" : "当前概况"}
|
||||
</div>
|
||||
<div className="scan-kv-list">
|
||||
<div className="scan-kv">
|
||||
<span>{isEn ? "Local Time" : "当前时间"}</span>
|
||||
<strong>{detail?.local_time || summary?.local_time || "--"}</strong>
|
||||
</div>
|
||||
<div className="scan-kv">
|
||||
<span>{isEn ? "Current Temp" : "当前温度"}</span>
|
||||
<strong>
|
||||
{currentTemp != null
|
||||
? formatTemperatureValue(currentTemp, tempSymbol)
|
||||
: "--"}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="scan-kv">
|
||||
<span>{isEn ? "Day High (So Far)" : "今日最高(至今)"}</span>
|
||||
<strong>
|
||||
{dayHigh != null
|
||||
? formatTemperatureValue(dayHigh, tempSymbol)
|
||||
: "--"}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="scan-kv">
|
||||
<span>{isEn ? "DEB Forecast" : "DEB 预测"}</span>
|
||||
<strong>
|
||||
{detail?.deb?.prediction != null
|
||||
? formatTemperatureValue(detail.deb.prediction, tempSymbol)
|
||||
: "--"}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="scan-kv">
|
||||
<span>{isEn ? "Peak Window" : "预计峰值"}</span>
|
||||
<strong>{peakLabel}</strong>
|
||||
</div>
|
||||
<div className="scan-kv">
|
||||
<span>{isEn ? "Airport" : "机场锚点"}</span>
|
||||
<strong>{airportLabel || "--"}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailPanel({
|
||||
row,
|
||||
marketScan,
|
||||
@@ -949,6 +818,8 @@ function ScanTerminalScreen() {
|
||||
const [activeSection, setActiveSection] = useState<TopSection>("terminal");
|
||||
const [activeView, setActiveView] = useState<ContentView>("list");
|
||||
const [mapSelectedCityName, setMapSelectedCityName] = useState<string | null>(null);
|
||||
const [userLocalTime, setUserLocalTime] = useState("--");
|
||||
const [themeMode, setThemeMode] = useState<ThemeMode>("dark");
|
||||
const deferredRows = useDeferredValue(terminalData?.rows || []);
|
||||
const timeSortedRows = useMemo(
|
||||
() => sortRowsByUserTime(deferredRows),
|
||||
@@ -960,6 +831,18 @@ function ScanTerminalScreen() {
|
||||
return timeSortedRows.find((row) => row.id === selectedRowId) || timeSortedRows[0] || null;
|
||||
}, [timeSortedRows, selectedRowId]);
|
||||
|
||||
const mapFocusedRow = useMemo(() => {
|
||||
const cityName = String(mapSelectedCityName || store.selectedCity || "").toLowerCase();
|
||||
if (!cityName) return null;
|
||||
return (
|
||||
timeSortedRows.find(
|
||||
(row) =>
|
||||
row.city.toLowerCase() === cityName ||
|
||||
String(row.city_display_name || row.display_name || "").toLowerCase() === cityName,
|
||||
) || null
|
||||
);
|
||||
}, [mapSelectedCityName, store.selectedCity, timeSortedRows]);
|
||||
|
||||
const fetchTerminal = async (filters: FilterState, force = false) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -1018,27 +901,53 @@ function ScanTerminalScreen() {
|
||||
}, [activeFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRow) return;
|
||||
void fetchDetail(selectedRow);
|
||||
}, [selectedRow, detailByRowId]);
|
||||
setUserLocalTime(formatUserLocalTime(locale));
|
||||
const intervalId = window.setInterval(() => {
|
||||
setUserLocalTime(formatUserLocalTime(locale));
|
||||
}, 30_000);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem("polyweather_scan_theme");
|
||||
if (stored === "light") {
|
||||
setThemeMode("light");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem("polyweather_scan_theme", themeMode);
|
||||
}, [themeMode]);
|
||||
|
||||
const selectedDetail = selectedRow ? detailByRowId[selectedRow.id] : null;
|
||||
const mapFocusedCity = mapSelectedCityName || 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 =
|
||||
activeSection === "markets"
|
||||
? "map"
|
||||
: activeSection === "analysis"
|
||||
? "calendar"
|
||||
: activeView;
|
||||
const activeDetailRow = resolvedView === "map" && mapFocusedCity ? mapFocusedRow : selectedRow;
|
||||
const selectedDetail = activeDetailRow ? detailByRowId[activeDetailRow.id] : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeDetailRow) return;
|
||||
void fetchDetail(activeDetailRow);
|
||||
}, [activeDetailRow, detailByRowId]);
|
||||
|
||||
const handleMapCitySelect = useCallback((cityName: string) => {
|
||||
setMapSelectedCityName(cityName);
|
||||
const matchedRow =
|
||||
timeSortedRows.find(
|
||||
(row) =>
|
||||
row.city.toLowerCase() === cityName.toLowerCase() ||
|
||||
String(row.city_display_name || row.display_name || "").toLowerCase() === cityName.toLowerCase(),
|
||||
) || null;
|
||||
setSelectedRowId(matchedRow?.id || null);
|
||||
}, [timeSortedRows]);
|
||||
|
||||
const handleSelectRow = useCallback((row: ScanOpportunityRow) => {
|
||||
setSelectedRowId(row.id);
|
||||
}, []);
|
||||
|
||||
const renderMainView = () => {
|
||||
if (activeSection === "portfolio") {
|
||||
@@ -1054,7 +963,7 @@ function ScanTerminalScreen() {
|
||||
return (
|
||||
<div className="scan-map-view">
|
||||
<div className="scan-map-shell">
|
||||
<MapCanvas onCitySelect={setMapSelectedCityName} />
|
||||
<MapCanvas onCitySelect={handleMapCitySelect} />
|
||||
</div>
|
||||
<div className="scan-map-caption">
|
||||
{locale === "en-US"
|
||||
@@ -1070,7 +979,7 @@ function ScanTerminalScreen() {
|
||||
rows={timeSortedRows}
|
||||
locale={locale}
|
||||
selectedRowId={selectedRowId}
|
||||
onSelectRow={(row) => setSelectedRowId(row.id)}
|
||||
onSelectRow={handleSelectRow}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1079,7 +988,7 @@ function ScanTerminalScreen() {
|
||||
<OpportunityTable
|
||||
rows={timeSortedRows}
|
||||
selectedRowId={selectedRowId}
|
||||
onSelectRow={(row) => setSelectedRowId(row.id)}
|
||||
onSelectRow={handleSelectRow}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -1087,7 +996,7 @@ function ScanTerminalScreen() {
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.root, detailChromeStyles.root, modalChromeStyles.root)}>
|
||||
<div className="scan-terminal">
|
||||
<div className={clsx("scan-terminal", themeMode === "light" && "light")}>
|
||||
<ScanFilterPanel
|
||||
value={draftFilters}
|
||||
onChange={setDraftFilters}
|
||||
@@ -1126,8 +1035,17 @@ function ScanTerminalScreen() {
|
||||
<span className={clsx(locale === "en-US" && "active")}>EN</span>
|
||||
</button>
|
||||
<span className="scan-topbar-time">
|
||||
{selectedRow?.local_time || terminalData?.generated_at?.replace("T", " ").slice(11, 19) || "--"}
|
||||
{userLocalTime}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="scan-theme-button"
|
||||
aria-label={themeMode === "light" ? "切换到暗色模式" : "切换到明亮模式"}
|
||||
title={themeMode === "light" ? "切换到暗色模式" : "切换到明亮模式"}
|
||||
onClick={() => setThemeMode((current) => (current === "light" ? "dark" : "light"))}
|
||||
>
|
||||
{themeMode === "light" ? <Moon size={15} /> : <Sun size={15} />}
|
||||
</button>
|
||||
<button type="button" className="scan-ghost-button" onClick={() => void fetchTerminal(activeFilters, true)}>
|
||||
<RefreshCw size={14} className={loading ? "spin" : undefined} />
|
||||
{isEn ? "Refresh" : "筛选"}
|
||||
@@ -1238,20 +1156,11 @@ function ScanTerminalScreen() {
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{resolvedView === "map" && mapFocusedCity ? (
|
||||
<MapCityDetailPanel
|
||||
cityName={mapFocusedCity}
|
||||
detail={mapFocusedDetail}
|
||||
summary={mapFocusedSummary}
|
||||
loading={Boolean(mapFocusedCity && !mapFocusedDetail && store.loadingState.cityDetail)}
|
||||
/>
|
||||
) : (
|
||||
<DetailPanel
|
||||
row={selectedRow}
|
||||
marketScan={selectedDetail}
|
||||
loading={detailLoadingId === selectedRow?.id}
|
||||
/>
|
||||
)}
|
||||
<DetailPanel
|
||||
row={activeDetailRow}
|
||||
marketScan={selectedDetail}
|
||||
loading={detailLoadingId === activeDetailRow?.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user