feat: implement scan terminal dashboard with filtering, opportunity table, and detail panel components

This commit is contained in:
2569718930@qq.com
2026-04-24 05:39:14 +08:00
parent 5cc08249e2
commit 92537b4637
4 changed files with 239 additions and 194 deletions
@@ -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>
);