615 lines
20 KiB
TypeScript
615 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import clsx from "clsx";
|
|
import dynamic from "next/dynamic";
|
|
import { RefreshCw } from "lucide-react";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import styles from "./Dashboard.module.css";
|
|
import { scanRootClass } from "./scan-root-styles";
|
|
import {
|
|
DashboardStoreProvider,
|
|
useDashboardStore,
|
|
useProAccess,
|
|
} from "@/hooks/useDashboardStore";
|
|
import { I18nProvider, useI18n } from "@/hooks/useI18n";
|
|
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
|
import { AiPinnedForecastView } from "@/components/dashboard/scan-terminal/AiPinnedForecastView";
|
|
import { MobileCityPicker } from "@/components/dashboard/scan-terminal/MobileCityPicker";
|
|
import { WelcomeOverlay } from "@/components/dashboard/scan-terminal/WelcomeOverlay";
|
|
import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
|
|
import {
|
|
ScanPaywallModal,
|
|
ScanTerminalLoadingScreen,
|
|
ScanTerminalTopBar,
|
|
type ScanTerminalContentView,
|
|
} from "@/components/dashboard/scan-terminal/ScanTerminalShellParts";
|
|
import { findDetailForCity } from "@/components/dashboard/scan-terminal/city-detail-utils";
|
|
import {
|
|
findRowForCity,
|
|
normalizeCityKey,
|
|
rowMatchesCity,
|
|
sortRowsByUserTime,
|
|
} from "@/components/dashboard/scan-terminal/decision-utils";
|
|
import { useAiPinnedCityWorkspace } from "@/components/dashboard/scan-terminal/use-ai-pinned-city-workspace";
|
|
import { useScanTerminalQuery } from "@/components/dashboard/scan-terminal/use-scan-terminal-query";
|
|
import {
|
|
useScanTerminalTheme,
|
|
useUserLocalClock,
|
|
} from "@/components/dashboard/scan-terminal/use-scan-terminal-ui-state";
|
|
import { useRelativeTime } from "@/hooks/useRelativeTime";
|
|
|
|
const CityDetailPanel = dynamic(
|
|
() =>
|
|
import("@/components/dashboard/DetailPanel").then(
|
|
(module) => module.DetailPanel,
|
|
),
|
|
{ ssr: false },
|
|
);
|
|
|
|
const FutureForecastModal = dynamic(
|
|
() =>
|
|
import("@/components/dashboard/FutureForecastModal").then(
|
|
(module) => module.FutureForecastModal,
|
|
),
|
|
{ ssr: false },
|
|
);
|
|
|
|
const MapCanvas = dynamic(
|
|
() =>
|
|
import("@/components/dashboard/MapCanvas").then(
|
|
(module) => module.MapCanvas,
|
|
),
|
|
{ ssr: false },
|
|
);
|
|
|
|
function ScanTerminalScreen() {
|
|
const store = useDashboardStore();
|
|
const { proAccess } = useProAccess();
|
|
const { locale, toggleLocale } = useI18n();
|
|
const isEn = locale === "en-US";
|
|
const isPro = proAccess.subscriptionActive;
|
|
const accountHref = proAccess.authenticated
|
|
? "/account"
|
|
: "/auth/login?next=%2Faccount";
|
|
const { refreshScanTerminalManually, scanError, scanLoading, terminalData } =
|
|
useScanTerminalQuery({
|
|
isPro,
|
|
proAccessLoading: proAccess.loading,
|
|
});
|
|
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
|
|
const [activeView, setActiveView] = useState<ScanTerminalContentView>("map");
|
|
const [mapSelectedCityName, setMapSelectedCityName] = useState<string | null>(
|
|
null,
|
|
);
|
|
const [showScanPaywall, setShowScanPaywall] = useState(false);
|
|
const [isMobileViewport, setIsMobileViewport] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === "undefined" || !window.matchMedia) return;
|
|
const media = window.matchMedia("(max-width: 768px)");
|
|
const syncMobileViewport = () => {
|
|
setIsMobileViewport(media.matches);
|
|
if (media.matches) {
|
|
setActiveView((current) => (current === "map" ? "city-list" : current));
|
|
} else {
|
|
setActiveView((current) => (current === "city-list" ? "map" : current));
|
|
}
|
|
};
|
|
syncMobileViewport();
|
|
media.addEventListener("change", syncMobileViewport);
|
|
return () => media.removeEventListener("change", syncMobileViewport);
|
|
}, []);
|
|
|
|
const userLocalTime = useUserLocalClock();
|
|
const { setThemeMode, themeMode } = useScanTerminalTheme();
|
|
const lastMapSelectedCityRef = useRef<string>("");
|
|
const lastFetchedAtRef = useRef<number>(0);
|
|
const serverAgeText = useRelativeTime(terminalData?.generated_at ?? null);
|
|
const localAgeText = useRelativeTime(
|
|
lastFetchedAtRef.current
|
|
? new Date(lastFetchedAtRef.current).toISOString()
|
|
: null,
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (terminalData?.generated_at) {
|
|
lastFetchedAtRef.current = Date.now();
|
|
}
|
|
}, [terminalData?.generated_at]);
|
|
|
|
const scanTerminalRootClassName = clsx(
|
|
styles.root,
|
|
scanRootClass,
|
|
themeMode === "light" && "light",
|
|
);
|
|
|
|
const timeSortedRows = useMemo(
|
|
() => sortRowsByUserTime(terminalData?.rows || []),
|
|
[terminalData?.rows],
|
|
);
|
|
|
|
const cityListRows = useMemo(() => {
|
|
if (timeSortedRows.length > 0) return timeSortedRows;
|
|
return store.cities.map((city, index) => {
|
|
const cityKey = normalizeCityKey(city.name);
|
|
const summary =
|
|
store.citySummariesByName[cityKey] ??
|
|
Object.values(store.citySummariesByName).find(
|
|
(s) => normalizeCityKey(s?.name) === cityKey,
|
|
) ??
|
|
null;
|
|
return {
|
|
id: `city-fallback:${cityKey}:${index}`,
|
|
city: cityKey,
|
|
city_display_name: city.display_name || city.name,
|
|
display_name: city.display_name || city.name,
|
|
temp_symbol: city.temp_unit === "fahrenheit" ? "°F" : "°C",
|
|
current_temp: summary?.current?.temp ?? null,
|
|
current_max_so_far: summary?.current?.temp ?? null,
|
|
deb_prediction: summary?.deb?.prediction ?? null,
|
|
airport: city.airport || null,
|
|
local_time: summary?.local_time ?? null,
|
|
risk_level: city.risk_level || "low",
|
|
market_slug: null,
|
|
market_question: null,
|
|
target_label: null,
|
|
side: null,
|
|
edge_percent: null,
|
|
final_score: null,
|
|
window_phase: null,
|
|
tradable: false,
|
|
active: false,
|
|
closed: false,
|
|
accepting_orders: false,
|
|
} satisfies ScanOpportunityRow;
|
|
});
|
|
}, [timeSortedRows, store.cities, store.citySummariesByName]);
|
|
const {
|
|
addAiPinnedCity,
|
|
aiPinnedCities,
|
|
refreshAiPinnedCityDetail,
|
|
removeAiPinnedCity,
|
|
} = useAiPinnedCityWorkspace({
|
|
locale,
|
|
store,
|
|
timeSortedRows,
|
|
});
|
|
const selectedRow = useMemo(() => {
|
|
if (!timeSortedRows.length) return null;
|
|
return (
|
|
timeSortedRows.find((row) => row.id === selectedRowId) ||
|
|
timeSortedRows[0] ||
|
|
null
|
|
);
|
|
}, [timeSortedRows, selectedRowId]);
|
|
|
|
useEffect(() => {
|
|
if (!timeSortedRows.length) return;
|
|
if (selectedRowId && timeSortedRows.some((row) => row.id === selectedRowId))
|
|
return;
|
|
setSelectedRowId(timeSortedRows[0].id);
|
|
}, [selectedRowId, timeSortedRows]);
|
|
|
|
const mapFocusedRow = useMemo(() => {
|
|
return findRowForCity(
|
|
timeSortedRows,
|
|
mapSelectedCityName || store.selectedCity,
|
|
);
|
|
}, [mapSelectedCityName, store.selectedCity, timeSortedRows]);
|
|
const mapFallbackRow = useMemo(() => {
|
|
const rawCityName = mapSelectedCityName || store.selectedCity;
|
|
const cityKey = normalizeCityKey(rawCityName);
|
|
if (!cityKey || mapFocusedRow) return null;
|
|
const selectedDetail =
|
|
store.selectedDetail &&
|
|
normalizeCityKey(store.selectedDetail.name) === cityKey
|
|
? store.selectedDetail
|
|
: Object.values(store.cityDetailsByName).find(
|
|
(detail) => normalizeCityKey(detail?.name) === cityKey,
|
|
) || null;
|
|
const selectedSummary =
|
|
Object.values(store.citySummariesByName).find(
|
|
(summary) => normalizeCityKey(summary?.name) === cityKey,
|
|
) || null;
|
|
const selectedCityItem =
|
|
store.cities.find(
|
|
(city) =>
|
|
normalizeCityKey(city.name) === cityKey ||
|
|
normalizeCityKey(city.display_name) === cityKey,
|
|
) || null;
|
|
const canonicalCity =
|
|
selectedDetail?.name ||
|
|
selectedSummary?.name ||
|
|
selectedCityItem?.name ||
|
|
String(rawCityName || "").trim();
|
|
if (!canonicalCity) return null;
|
|
|
|
const tempSymbol =
|
|
selectedDetail?.temp_symbol ||
|
|
selectedSummary?.temp_symbol ||
|
|
(selectedCityItem?.temp_unit === "fahrenheit" ? "°F" : "°C");
|
|
const displayName =
|
|
selectedDetail?.display_name ||
|
|
selectedSummary?.display_name ||
|
|
selectedCityItem?.display_name ||
|
|
canonicalCity;
|
|
const currentTemp =
|
|
selectedDetail?.current?.temp ?? selectedSummary?.current?.temp ?? null;
|
|
|
|
return {
|
|
id: `map-city:${canonicalCity}`,
|
|
city: canonicalCity,
|
|
city_display_name: displayName,
|
|
display_name: displayName,
|
|
selected_date: selectedDetail?.local_date || null,
|
|
local_date: selectedDetail?.local_date || null,
|
|
local_time:
|
|
selectedDetail?.local_time || selectedSummary?.local_time || null,
|
|
temp_symbol: tempSymbol,
|
|
current_temp: currentTemp,
|
|
current_max_so_far:
|
|
selectedDetail?.current?.max_so_far ?? currentTemp ?? null,
|
|
deb_prediction:
|
|
selectedDetail?.deb?.prediction ??
|
|
selectedSummary?.deb?.prediction ??
|
|
null,
|
|
airport:
|
|
selectedDetail?.risk?.airport ||
|
|
selectedCityItem?.airport ||
|
|
selectedCityItem?.settlement_station_label ||
|
|
null,
|
|
risk_level:
|
|
selectedDetail?.risk?.level ||
|
|
selectedSummary?.risk?.level ||
|
|
selectedCityItem?.risk_level ||
|
|
"low",
|
|
market_slug: null,
|
|
market_question: isEn ? "City briefing" : "城市简报",
|
|
target_label: isEn ? "City snapshot" : "城市概况",
|
|
side: null,
|
|
edge_percent: null,
|
|
final_score: null,
|
|
window_phase: "city_snapshot",
|
|
tradable: false,
|
|
active: false,
|
|
closed: false,
|
|
accepting_orders: false,
|
|
} satisfies ScanOpportunityRow;
|
|
}, [
|
|
isEn,
|
|
mapFocusedRow,
|
|
mapSelectedCityName,
|
|
store.cityDetailsByName,
|
|
store.citySummariesByName,
|
|
store.cities,
|
|
store.selectedCity,
|
|
store.selectedDetail,
|
|
]);
|
|
|
|
const resolvedView: ScanTerminalContentView = activeView;
|
|
const mapFocusedCity = mapSelectedCityName || store.selectedCity;
|
|
const activeDetailRow =
|
|
resolvedView === "map" && mapFocusedCity
|
|
? mapFocusedRow || mapFallbackRow
|
|
: selectedRow;
|
|
const scanStatus = terminalData?.status || "ready";
|
|
const staleReason = terminalData?.stale_reason || null;
|
|
const proPreviewItems = isEn
|
|
? [
|
|
"Real-time METAR & official station data for 52 cities",
|
|
"Multi-model DEB blend vs market-implied temperature",
|
|
"Probability buckets mapped to Polymarket contracts",
|
|
"Live observation deviation & mispricing signals",
|
|
"City decision cards for current & future settlement dates",
|
|
]
|
|
: [
|
|
"52 城实时机场报文与官方观测站数据",
|
|
"多模型 DEB 融合预测 vs 市场隐含温度",
|
|
"概率分布桶对照 Polymarket 合约",
|
|
"实时观测偏差与错价信号",
|
|
"当前日与未来结算日的城市决策卡",
|
|
"未来日期城市决策卡",
|
|
];
|
|
|
|
useEffect(() => {
|
|
if (!activeDetailRow) return;
|
|
if (!findDetailForCity(store.cityDetailsByName, activeDetailRow.city)) {
|
|
void store
|
|
.ensureCityDetail(activeDetailRow.city, false, "panel")
|
|
.catch(() => {});
|
|
}
|
|
}, [activeDetailRow, store.cityDetailsByName, store.ensureCityDetail]);
|
|
|
|
const handleMapCitySelect = useCallback(
|
|
(cityName: string) => {
|
|
setMapSelectedCityName(cityName);
|
|
lastMapSelectedCityRef.current = normalizeCityKey(cityName);
|
|
const matchedRow = findRowForCity(timeSortedRows, cityName);
|
|
if (matchedRow) {
|
|
store.preloadCityFromRow(matchedRow);
|
|
setSelectedRowId(matchedRow.id);
|
|
} else {
|
|
void store.ensureCityDetail(cityName, false, "panel").catch(() => {});
|
|
setSelectedRowId(null);
|
|
}
|
|
void store.selectCity(cityName);
|
|
addAiPinnedCity(cityName);
|
|
setActiveView("analysis");
|
|
},
|
|
[store, timeSortedRows, addAiPinnedCity],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (activeView !== "map") return;
|
|
const selectedCity = String(store.selectedCity || "").trim();
|
|
const selectedKey = normalizeCityKey(selectedCity);
|
|
if (!selectedKey || selectedKey === lastMapSelectedCityRef.current) return;
|
|
lastMapSelectedCityRef.current = selectedKey;
|
|
setMapSelectedCityName(selectedCity);
|
|
const matchedRow = findRowForCity(timeSortedRows, selectedCity);
|
|
setSelectedRowId(matchedRow?.id || null);
|
|
addAiPinnedCity(selectedCity);
|
|
}, [activeView, addAiPinnedCity, store.selectedCity, timeSortedRows]);
|
|
|
|
const handleSelectRow = useCallback(
|
|
(row: ScanOpportunityRow) => {
|
|
const cityName =
|
|
row.city || row.city_display_name || row.display_name || "";
|
|
if (!cityName) return;
|
|
setSelectedRowId(row.id);
|
|
store.preloadCityFromRow(row);
|
|
const selectedCityKey = normalizeCityKey(store.selectedCity);
|
|
const rowCityKey = normalizeCityKey(cityName);
|
|
const hasCachedDetail =
|
|
Boolean(findDetailForCity(store.cityDetailsByName, cityName)) ||
|
|
Object.values(store.cityDetailsByName).some((detail) =>
|
|
rowMatchesCity(row, detail?.name || detail?.display_name || ""),
|
|
);
|
|
if (store.isPanelOpen && selectedCityKey === rowCityKey) {
|
|
if (!hasCachedDetail) {
|
|
void store.ensureCityDetail(cityName, false, "panel").catch(() => {});
|
|
}
|
|
return;
|
|
}
|
|
void store.selectCity(cityName);
|
|
},
|
|
[store],
|
|
);
|
|
|
|
const handleOpenDecisionRow = useCallback(
|
|
(row: ScanOpportunityRow) => {
|
|
const cityName =
|
|
row.city || row.city_display_name || row.display_name || "";
|
|
if (!cityName) return;
|
|
setSelectedRowId(row.id);
|
|
store.preloadCityFromRow(row);
|
|
addAiPinnedCity(cityName);
|
|
setActiveView("analysis");
|
|
void store.selectCity(cityName);
|
|
},
|
|
[addAiPinnedCity, store],
|
|
);
|
|
|
|
const openScanPaywall = useCallback(() => {
|
|
setShowScanPaywall(true);
|
|
}, []);
|
|
|
|
const renderMainView = () => {
|
|
if (resolvedView === "city-list") {
|
|
return (
|
|
<MobileCityPicker
|
|
isEn={isEn}
|
|
rows={cityListRows}
|
|
onSelectCity={handleOpenDecisionRow}
|
|
/>
|
|
);
|
|
}
|
|
// Keep MapCanvas always mounted — hiding with CSS avoids Leaflet
|
|
// reinitialization that causes a white background on tab switches.
|
|
// The analysis view overlays on top when active.
|
|
return (
|
|
<>
|
|
<div
|
|
className="scan-map-view"
|
|
style={{ display: resolvedView === "map" ? undefined : "none" }}
|
|
>
|
|
<div className="scan-map-shell">
|
|
<MapCanvas
|
|
onCitySelect={handleMapCitySelect}
|
|
selectionMode="select"
|
|
/>
|
|
</div>
|
|
</div>
|
|
{resolvedView === "analysis" ? (
|
|
isPro ? (
|
|
<AiPinnedForecastView
|
|
items={aiPinnedCities}
|
|
rows={timeSortedRows}
|
|
detailsByName={store.cityDetailsByName}
|
|
locale={locale}
|
|
onRefreshCityDetail={refreshAiPinnedCityDetail}
|
|
onRemoveCity={removeAiPinnedCity}
|
|
/>
|
|
) : (
|
|
<div className="scan-ai-workspace empty">
|
|
<ProFeaturePaywall feature="scan" />
|
|
</div>
|
|
)
|
|
) : null}
|
|
</>
|
|
);
|
|
};
|
|
|
|
if (proAccess.loading) {
|
|
return (
|
|
<ScanTerminalLoadingScreen
|
|
isEn={isEn}
|
|
rootClassName={scanTerminalRootClassName}
|
|
themeMode={themeMode}
|
|
userLocalTime={userLocalTime}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={scanTerminalRootClassName}>
|
|
<div
|
|
className={clsx(
|
|
"scan-terminal",
|
|
resolvedView === "city-list" && "city-list-view-active",
|
|
resolvedView === "map" && "map-view-active",
|
|
resolvedView !== "map" && "focus-view-active",
|
|
resolvedView === "analysis" && "analysis-view-active",
|
|
themeMode === "light" && "light",
|
|
)}
|
|
>
|
|
<main className="scan-data-grid">
|
|
<ScanTerminalTopBar
|
|
accountHref={accountHref}
|
|
isAuthenticated={proAccess.authenticated}
|
|
isEn={isEn}
|
|
isPro={isPro}
|
|
locale={locale}
|
|
onOpenScanPaywall={openScanPaywall}
|
|
setThemeMode={setThemeMode}
|
|
themeMode={themeMode}
|
|
toggleLocale={toggleLocale}
|
|
userLocalTime={userLocalTime}
|
|
/>
|
|
|
|
|
|
|
|
<section className="scan-list-section">
|
|
<div className="scan-list-header">
|
|
<div
|
|
className="scan-list-tabs"
|
|
role="tablist"
|
|
aria-label={isEn ? "Content view" : "内容视图"}
|
|
>
|
|
{isMobileViewport ? (
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={resolvedView === "city-list"}
|
|
className={resolvedView === "city-list" ? "active" : ""}
|
|
onClick={() => {
|
|
setActiveView("city-list");
|
|
}}
|
|
>
|
|
{isEn ? "City List" : "城市列表"}
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={resolvedView === "map"}
|
|
className={resolvedView === "map" ? "active" : ""}
|
|
onClick={() => {
|
|
lastMapSelectedCityRef.current = normalizeCityKey(
|
|
store.selectedCity,
|
|
);
|
|
setActiveView("map");
|
|
}}
|
|
>
|
|
{isEn ? "Distribution View" : "分布视图"}
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={resolvedView === "analysis"}
|
|
className={resolvedView === "analysis" ? "active" : ""}
|
|
onClick={() => {
|
|
setActiveView("analysis");
|
|
}}
|
|
>
|
|
{isEn ? "Decision Cards" : "城市决策卡"}
|
|
{!isPro && (
|
|
<span className="scan-lock-icon" style={{ marginLeft: "4.5px", fontSize: "13.5px" }}>
|
|
🔒
|
|
</span>
|
|
)}
|
|
</button>
|
|
</div>
|
|
<div className="scan-list-status">
|
|
{terminalData?.generated_at ? (
|
|
<span
|
|
className={clsx(
|
|
"scan-status-chip",
|
|
terminalData?.stale ? "stale" : "live",
|
|
)}
|
|
>
|
|
{isEn ? "Updated" : "已更新"} {serverAgeText || ""}
|
|
</span>
|
|
) : null}
|
|
{terminalData?.stale && localAgeText ? (
|
|
<span className="scan-status-chip stale">
|
|
{isEn ? "Local fetch " : "本地下发 "}
|
|
{localAgeText}
|
|
</span>
|
|
) : null}
|
|
{isPro ? (
|
|
<button
|
|
type="button"
|
|
className="scan-status-chip refresh"
|
|
onClick={refreshScanTerminalManually}
|
|
disabled={scanLoading}
|
|
title={
|
|
isEn ? "Force refresh decision cards" : "强制刷新决策卡"
|
|
}
|
|
>
|
|
<RefreshCw
|
|
size={14}
|
|
className={scanLoading ? "spin" : undefined}
|
|
/>
|
|
{isEn ? "Refresh" : "刷新"}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{scanStatus === "failed" && !terminalData ? (
|
|
<div className="scan-empty-state">
|
|
<div className="scan-empty-title">
|
|
{isEn ? "Scan failed" : "扫描失败"}
|
|
</div>
|
|
<div className="scan-empty-copy">{staleReason}</div>
|
|
<button
|
|
type="button"
|
|
className="scan-retry-button"
|
|
onClick={() => refreshScanTerminalManually()}
|
|
>
|
|
<RefreshCw size={13} />
|
|
{isEn ? "Retry" : "重试"}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
renderMainView()
|
|
)}
|
|
</section>
|
|
</main>
|
|
|
|
<CityDetailPanel variant="rail" />
|
|
</div>
|
|
<WelcomeOverlay locale={locale} onDismiss={() => {}} />
|
|
<FutureForecastModal />
|
|
{showScanPaywall ? (
|
|
<ScanPaywallModal
|
|
isEn={isEn}
|
|
onClose={() => setShowScanPaywall(false)}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ScanTerminalDashboard() {
|
|
return (
|
|
<I18nProvider>
|
|
<DashboardStoreProvider>
|
|
<ScanTerminalScreen />
|
|
</DashboardStoreProvider>
|
|
</I18nProvider>
|
|
);
|
|
}
|