2026-04-23 23:49:52 +08:00
"use client" ;
import clsx from "clsx" ;
2026-04-28 20:19:17 +08:00
import dynamic from "next/dynamic" ;
2026-04-24 00:34:09 +08:00
import Link from "next/link" ;
2026-04-24 00:13:11 +08:00
import {
2026-04-25 03:59:02 +08:00
LogIn ,
2026-05-10 16:19:21 +08:00
MessageCircle ,
2026-04-24 05:39:14 +08:00
Moon ,
2026-04-27 00:30:51 +08:00
RefreshCw ,
2026-04-24 05:39:14 +08:00
Sun ,
2026-04-24 00:34:09 +08:00
UserRound ,
2026-04-24 00:13:11 +08:00
} from "lucide-react" ;
2026-04-23 23:49:52 +08:00
import {
2026-04-24 05:39:14 +08:00
useCallback ,
2026-04-23 23:49:52 +08:00
useEffect ,
useMemo ,
2026-04-25 02:41:38 +08:00
useRef ,
2026-04-23 23:49:52 +08:00
useState ,
} from "react" ;
import styles from "./Dashboard.module.css" ;
2026-05-10 14:21:10 +08:00
import { scanRootClass } from "./scan-root-styles" ;
2026-04-25 00:19:30 +08:00
import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall" ;
2026-04-24 00:34:09 +08:00
import {
DashboardStoreProvider ,
useDashboardStore ,
} from "@/hooks/useDashboardStore" ;
2026-04-23 23:49:52 +08:00
import { I18nProvider , useI18n } from "@/hooks/useI18n" ;
import type {
2026-04-26 00:24:11 +08:00
CityDetail ,
2026-04-23 23:49:52 +08:00
ScanOpportunityRow ,
} from "@/lib/dashboard-types" ;
2026-04-26 09:13:18 +08:00
import { AiPinnedForecastView } from "@/components/dashboard/scan-terminal/AiPinnedForecastView" ;
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal" ;
2026-05-10 16:19:21 +08:00
import { WelcomeOverlay } from "@/components/dashboard/scan-terminal/WelcomeOverlay" ;
2026-04-28 09:06:55 +08:00
import { findDetailForCity } from "@/components/dashboard/scan-terminal/city-detail-utils" ;
2026-04-25 04:25:17 +08:00
import {
2026-04-26 09:13:18 +08:00
findRowForCity ,
normalizeCityKey ,
rowMatchesCity ,
sortRowsByUserTime ,
} from "@/components/dashboard/scan-terminal/decision-utils" ;
2026-04-28 09:06:55 +08:00
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" ;
2026-04-26 09:13:18 +08:00
2026-05-10 15:48:18 +08:00
type ContentView = "analysis" | "map" ;
2026-04-27 00:30:51 +08:00
2026-04-28 20:19:17 +08:00
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 },
);
2026-04-23 23:49:52 +08:00
function ScanTerminalScreen() {
2026-04-24 00:34:09 +08:00
const store = useDashboardStore ();
const { locale , toggleLocale } = useI18n ();
2026-04-23 23:49:52 +08:00
const isEn = locale === "en-US" ;
2026-04-25 00:08:21 +08:00
const isPro = store . proAccess . subscriptionActive ;
2026-04-24 00:34:09 +08:00
const accountHref = store . proAccess . authenticated
? "/account"
: "/auth/login?next=%2Faccount" ;
2026-04-28 09:06:55 +08:00
const {
refreshScanTerminalManually ,
scanError ,
scanLoading ,
terminalData ,
} = useScanTerminalQuery ({
isPro ,
proAccessLoading : store.proAccess.loading ,
});
2026-04-23 23:49:52 +08:00
const [ selectedRowId , setSelectedRowId ] = useState < string | null >( null );
2026-05-06 15:26:37 +08:00
const [ activeView , setActiveView ] = useState < ContentView >( "map" );
2026-04-24 05:09:19 +08:00
const [ mapSelectedCityName , setMapSelectedCityName ] = useState < string | null >( null );
2026-04-25 00:19:30 +08:00
const [ showScanPaywall , setShowScanPaywall ] = useState ( false );
2026-05-06 18:59:20 +08:00
const [ showAnnouncement , setShowAnnouncement ] = useState ( false );
useEffect (() => {
const key = "polyweather_v156_announcement_seen_at" ;
const seen = localStorage . getItem ( key );
const now = Date . now ();
if ( ! seen ) {
localStorage . setItem ( key , String ( now ));
setShowAnnouncement ( true );
return ;
}
const elapsed = now - Number ( seen );
setShowAnnouncement ( elapsed < 3 * 24 * 60 * 60 * 1000 );
}, []);
2026-04-28 09:06:55 +08:00
const userLocalTime = useUserLocalClock ();
const { setThemeMode , themeMode } = useScanTerminalTheme ();
2026-04-26 00:24:11 +08:00
const lastMapSelectedCityRef = useRef < string >( "" );
2026-04-28 14:45:34 +08:00
const scanTerminalRootClassName = clsx (
styles . root ,
2026-05-10 14:21:10 +08:00
scanRootClass ,
2026-04-28 14:45:34 +08:00
themeMode === "light" && "light" ,
);
2026-04-26 10:22:11 +08:00
2026-04-24 05:09:19 +08:00
const timeSortedRows = useMemo (
2026-04-26 00:24:11 +08:00
() => sortRowsByUserTime ( terminalData ? . rows || []),
[ terminalData ? . rows ],
2026-04-24 05:09:19 +08:00
);
2026-04-28 09:06:55 +08:00
const {
addAiPinnedCity ,
aiPinnedCities ,
refreshAiPinnedCityDetail ,
removeAiPinnedCity ,
} = useAiPinnedCityWorkspace ({
locale ,
store ,
timeSortedRows ,
});
2026-04-23 23:49:52 +08:00
const selectedRow = useMemo (() => {
2026-04-24 05:09:19 +08:00
if ( ! timeSortedRows . length ) return null ;
return timeSortedRows . find (( row ) => row . id === selectedRowId ) || timeSortedRows [ 0 ] || null ;
}, [ timeSortedRows , selectedRowId ]);
2026-04-23 23:49:52 +08:00
2026-04-26 09:13:18 +08:00
useEffect (() => {
if ( ! timeSortedRows . length ) return ;
if ( selectedRowId && timeSortedRows . some (( row ) => row . id === selectedRowId )) return ;
setSelectedRowId ( timeSortedRows [ 0 ]. id );
}, [ selectedRowId , timeSortedRows ]);
2026-04-24 05:39:14 +08:00
const mapFocusedRow = useMemo (() => {
2026-04-24 13:09:31 +08:00
return findRowForCity (
timeSortedRows ,
mapSelectedCityName || store . selectedCity ,
2026-04-24 05:39:14 +08:00
);
}, [ mapSelectedCityName , store . selectedCity , timeSortedRows ]);
2026-04-24 13:09:31 +08:00
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 ,
2026-04-24 13:43:05 +08:00
market_question : isEn ? "City briefing" : "城市简报" ,
2026-04-24 13:09:31 +08:00
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 ,
]);
2026-04-24 10:19:16 +08:00
const resolvedView : ContentView = activeView ;
2026-04-24 05:09:19 +08:00
const mapFocusedCity = mapSelectedCityName || store . selectedCity ;
2026-04-24 13:09:31 +08:00
const activeDetailRow =
resolvedView === "map" && mapFocusedCity
? mapFocusedRow || mapFallbackRow
: selectedRow ;
2026-04-26 00:24:11 +08:00
const scanStatus = terminalData ? . status || "ready" ;
const staleReason = terminalData ? . stale_reason || null ;
2026-04-24 05:39:14 +08:00
useEffect (() => {
if ( ! activeDetailRow ) return ;
2026-04-26 08:12:31 +08:00
if ( ! findDetailForCity ( store . cityDetailsByName , activeDetailRow . city )) {
2026-04-24 06:31:29 +08:00
void store . ensureCityDetail ( activeDetailRow . city , false , "panel" ). catch (() => {});
}
2026-04-24 13:35:36 +08:00
}, [ activeDetailRow , store . cityDetailsByName , store . ensureCityDetail ]);
2026-04-24 05:39:14 +08:00
const handleMapCitySelect = useCallback (( cityName : string ) => {
setMapSelectedCityName ( cityName );
2026-04-26 00:24:11 +08:00
lastMapSelectedCityRef . current = normalizeCityKey ( cityName );
2026-04-24 13:09:31 +08:00
const matchedRow = findRowForCity ( timeSortedRows , cityName );
2026-05-10 17:00:12 +08:00
if ( matchedRow ) store . preloadCityFromRow ( matchedRow );
2026-04-24 05:39:14 +08:00
setSelectedRowId ( matchedRow ? . id || null );
2026-04-26 00:24:11 +08:00
addAiPinnedCity ( cityName );
2026-04-26 09:13:18 +08:00
setActiveView ( "analysis" );
2026-05-10 17:00:12 +08:00
}, [ addAiPinnedCity , store , timeSortedRows ]);
2026-04-26 00:24:11 +08:00
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 ]);
2026-04-24 05:39:14 +08:00
const handleSelectRow = useCallback (( row : ScanOpportunityRow ) => {
2026-04-25 06:42:24 +08:00
const cityName = row . city || row . city_display_name || row . display_name || "" ;
if ( ! cityName ) return ;
2026-04-24 05:39:14 +08:00
setSelectedRowId ( row . id );
2026-05-10 17:00:12 +08:00
store . preloadCityFromRow ( row );
2026-04-25 06:42:24 +08:00
const selectedCityKey = normalizeCityKey ( store . selectedCity );
const rowCityKey = normalizeCityKey ( cityName );
const hasCachedDetail =
2026-04-26 08:12:31 +08:00
Boolean ( findDetailForCity ( store . cityDetailsByName , cityName )) ||
2026-04-25 06:42:24 +08:00
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 );
2026-04-24 13:28:38 +08:00
}, [ store ]);
2026-04-24 03:30:55 +08:00
2026-04-26 09:13:18 +08:00
const handleOpenDecisionRow = useCallback (( row : ScanOpportunityRow ) => {
const cityName = row . city || row . city_display_name || row . display_name || "" ;
if ( ! cityName ) return ;
setSelectedRowId ( row . id );
2026-05-10 17:00:12 +08:00
store . preloadCityFromRow ( row );
2026-04-26 09:13:18 +08:00
addAiPinnedCity ( cityName );
setActiveView ( "analysis" );
void store . selectCity ( cityName );
}, [ addAiPinnedCity , store ]);
2026-04-25 00:19:30 +08:00
const openScanPaywall = useCallback (() => {
setShowScanPaywall ( true );
}, []);
2026-04-24 03:30:55 +08:00
const renderMainView = () => {
if ( resolvedView === "map" ) {
2026-04-24 05:09:19 +08:00
return (
< div className = "scan-map-view" >
< div className = "scan-map-shell" >
2026-04-24 13:28:38 +08:00
< MapCanvas
onCitySelect = { handleMapCitySelect }
selectionMode = "select"
/>
2026-04-24 05:09:19 +08:00
</ div >
2026-04-25 00:08:21 +08:00
</ div >
);
}
2026-04-26 09:13:18 +08:00
if ( resolvedView === "analysis" ) {
2026-04-26 00:24:11 +08:00
return (
< AiPinnedForecastView
items = { aiPinnedCities }
rows = { timeSortedRows }
detailsByName = { store . cityDetailsByName }
locale = { locale }
2026-04-27 00:19:52 +08:00
onRefreshCityDetail = { refreshAiPinnedCityDetail }
2026-04-26 00:24:11 +08:00
onRemoveCity = { removeAiPinnedCity }
/>
);
}
2026-04-25 00:08:21 +08:00
if ( ! isPro ) {
return (
< div className = "scan-table-shell empty" >
< div className = "scan-empty-state" >
< div className = "scan-empty-title" >
{ isEn ? "Scan is available on Pro" : "扫描功能需 Pro 权限" }
</ div >
< div className = "scan-empty-copy" >
{ isEn
? "Distribution view and city briefing remain available."
: "分布视图和右侧城市简报仍可查看。" }
</ div >
2026-04-24 05:09:19 +08:00
</ div >
</ div >
);
2026-04-24 03:30:55 +08:00
}
2026-04-26 00:24:11 +08:00
return null ;
2026-04-24 03:30:55 +08:00
};
2026-04-23 23:49:52 +08:00
2026-04-24 23:32:32 +08:00
if ( store . proAccess . loading ) {
return (
2026-04-28 14:45:34 +08:00
< div className = { scanTerminalRootClassName }>
2026-04-24 23:32:32 +08:00
< div className = { clsx ( "scan-terminal" , themeMode === "light" && "light" )}>
< main className = "scan-data-grid" >
2026-05-10 16:13:34 +08:00
< div className = "scan-topbar" >
< div className = "scan-topbar-title" >
< strong >{ isEn ? "AI Weather Decision Terminal" : "AI 天气交易决策台" }</ strong >
< span >
{ isEn
? "Start from the map, then open city cards to verify weather evidence"
: "从地图选城市,再打开决策卡验证天气证据" }
</ span >
</ div >
< div className = "scan-topbar-actions" >
< span className = "scan-topbar-time" >{ userLocalTime }</ span >
</ div >
</ div >
2026-04-26 09:13:18 +08:00
< div className = "scan-loading-state" >
< LoadingSignal
title = { isEn ? "Preparing decision workspace" : "正在准备决策工作台" }
description = {
isEn
2026-05-10 16:13:34 +08:00
? "Checking access, city context and today's tradable weather windows."
2026-04-26 09:13:18 +08:00
: "正在检查权限、城市上下文和今日可交易天气窗口。"
}
/>
2026-04-24 23:32:32 +08:00
</ div >
</ main >
</ div >
</ div >
);
}
2026-04-23 23:49:52 +08:00
return (
2026-04-28 14:45:34 +08:00
< div className = { scanTerminalRootClassName }>
2026-04-29 20:07:31 +08:00
< div
className = { clsx (
"scan-terminal" ,
resolvedView === "map" && "map-view-active" ,
2026-05-06 15:26:37 +08:00
resolvedView !== "map" && "focus-view-active" ,
2026-04-30 08:05:10 +08:00
resolvedView === "analysis" && "analysis-view-active" ,
2026-04-29 20:07:31 +08:00
themeMode === "light" && "light" ,
)}
>
2026-04-23 23:49:52 +08:00
< main className = "scan-data-grid" >
2026-04-24 00:13:11 +08:00
< div className = "scan-topbar" >
2026-05-10 15:13:32 +08:00
< div className = "scan-topbar-title" >
2026-05-10 14:21:10 +08:00
< strong >{ isEn ? "AI Weather Decision Terminal" : "AI 天气交易决策台" }</ strong >
2026-04-24 10:19:16 +08:00
< span >
2026-04-26 00:24:11 +08:00
{ isEn
2026-05-06 15:26:37 +08:00
? "Start from the map, then open city cards to verify weather evidence"
: "从地图选城市,再打开决策卡验证天气证据" }
2026-04-24 10:19:16 +08:00
</ span >
2026-04-23 23:49:52 +08:00
</ div >
2026-04-24 00:13:11 +08:00
< div className = "scan-topbar-actions" >
2026-04-24 00:34:09 +08:00
< button
type = "button"
className = "scan-locale-switch"
aria-label = { isEn ? "Switch to Chinese" : "切换到英文" }
title = { isEn ? "Switch to Chinese" : "切换到英文" }
onClick = { toggleLocale }
>
< span className = { clsx ( locale === "zh-CN" && "active" )}> 中文 </ span >
< span className = { clsx ( locale === "en-US" && "active" )}> EN </ span >
</ button >
2026-04-24 00:13:11 +08:00
< span className = "scan-topbar-time" >
2026-04-24 05:39:14 +08:00
{ userLocalTime }
2026-04-23 23:49:52 +08:00
</ span >
2026-04-25 03:59:02 +08:00
{ isPro ? null : store . proAccess . authenticated ? (
2026-04-25 00:19:30 +08:00
< button
type = "button"
className = "scan-primary-button"
onClick = { openScanPaywall }
>
2026-04-25 00:08:21 +08:00
< UserRound size = { 14 } />
2026-04-25 03:59:02 +08:00
{ isEn ? "Upgrade Pro" : "升级 Pro" }
2026-04-25 00:19:30 +08:00
</ button >
2026-04-25 03:59:02 +08:00
) : (
< Link href = { accountHref } className = "scan-primary-button" >
< LogIn size = { 14 } />
{ isEn ? "Sign in" : "登录" }
</ Link >
2026-04-25 00:08:21 +08:00
)}
2026-04-24 05:39:14 +08:00
< 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 >
2026-04-25 03:59:02 +08:00
{ store . proAccess . authenticated ? (
< Link
href = { accountHref }
className = "scan-account-button"
aria-label = { isEn ? "Account" : "账户" }
title = { isEn ? "Account" : "账户" }
>
< UserRound size = { 15 } />
</ Link >
) : null }
2026-05-10 16:19:21 +08:00
< a
href = "https://t.me/+nMG7SjziUKYyZmM1"
target = "_blank"
rel = "noopener noreferrer"
className = "scan-account-button"
aria-label = { isEn ? "Feedback" : "反馈" }
title = { isEn ? "Join Telegram for feedback" : "加入 Telegram 反馈" }
>
< MessageCircle size = { 15 } />
</ a >
2026-04-23 23:49:52 +08:00
</ div >
</ div >
2026-05-06 18:59:20 +08:00
{ showAnnouncement ? (
< section className = "scan-upgrade-announcement" aria-label = { isEn ? "Upgrade announcement" : "升级公告" }>
< div className = "scan-upgrade-announcement-copy" >
< span >{ isEn ? "v1.5.6 upgrade" : "v1.5.6 升级公告" }</ span >
< strong >
{ isEn ? "Scan terminal is upgraded to v1.5.6" : "决策终端已升级到 v1.5.6" }
</ strong >
< p >
{ isEn
? "City decision cards have been redesigned with a compact hero layout and consistent DEB data source. Sticky headers are removed for smoother scrolling."
: "城市决策卡 hero 布局重新设计,三指标并列对比更直观;DEB 数据源统一不再出现不一致;去除顶部固定效果滚动更流畅。" }
</ p >
</ div >
< ul >
< li >{ isEn ? "Redesigned decision card hero" : "重设计城市决策卡 hero 布局" }</ li >
< li >{ isEn ? "Unified DEB data source" : "统一 DEB 数据源" }</ li >
< li >{ isEn ? "Light theme coverage" : "亮色主题补全覆盖" }</ li >
< li >{ isEn ? "HKO observatory AI read" : "香港天文台观测 AI 解读" }</ li >
< li >{ isEn ? "Smoother scrolling experience" : "滚动体验优化" }</ li >
</ ul >
2026-05-10 16:13:34 +08:00
< button
type = "button"
className = "scan-announcement-dismiss"
aria-label = { isEn ? "Dismiss" : "关闭" }
onClick = {() => {
localStorage . setItem ( "polyweather_v156_announcement_seen_at" , String ( Date . now () + 90 * 24 * 60 * 60 * 1000 ));
setShowAnnouncement ( false );
}}
>
✕
</ button >
2026-05-06 18:59:20 +08:00
</ section >
) : null }
2026-04-24 00:13:11 +08:00
< section className = "scan-list-section" >
< div className = "scan-list-header" >
2026-05-10 14:21:10 +08:00
< div className = "scan-list-tabs" role = "tablist" aria-label = { isEn ? "Content view" : "内容视图" }>
2026-04-24 03:30:55 +08:00
< button
type = "button"
2026-05-10 14:21:10 +08:00
role = "tab"
aria-selected = { resolvedView === "map" }
2026-04-24 22:37:56 +08:00
className = { resolvedView === "map" ? "active" : "" }
2026-04-24 03:30:55 +08:00
onClick = {() => {
2026-04-26 00:24:11 +08:00
lastMapSelectedCityRef . current = normalizeCityKey ( store . selectedCity );
2026-04-24 22:37:56 +08:00
setActiveView ( "map" );
2026-04-24 03:30:55 +08:00
}}
>
2026-04-24 22:37:56 +08:00
{ isEn ? "Distribution View" : "分布视图" }
2026-04-24 00:13:11 +08:00
</ button >
2026-04-24 03:30:55 +08:00
< button
type = "button"
2026-05-10 14:21:10 +08:00
role = "tab"
aria-selected = { resolvedView === "analysis" }
2026-04-26 09:13:18 +08:00
className = { resolvedView === "analysis" ? "active" : "" }
2026-04-24 03:30:55 +08:00
onClick = {() => {
2026-04-26 09:13:18 +08:00
setActiveView ( "analysis" );
2026-04-24 03:30:55 +08:00
}}
>
2026-04-26 09:13:18 +08:00
{ isEn ? "Decision Cards" : "城市决策卡" }
2026-04-24 03:30:55 +08:00
</ button >
2026-04-24 00:13:11 +08:00
</ div >
2026-04-24 10:19:16 +08:00
< div className = "scan-list-status" >
2026-04-27 00:30:51 +08:00
{ terminalData ? . generated_at ? (
< span className = "scan-status-chip live" >
{ isEn ? "Updated" : "已更新" }{ " " }
{ new Date ( terminalData . generated_at ). toLocaleTimeString (
isEn ? "en-US" : "zh-CN" ,
{
hour : "2-digit" ,
minute : "2-digit" ,
},
)}
</ span >
) : null }
2026-04-24 10:19:16 +08:00
{ terminalData ? . stale ? (
< span className = "scan-status-chip stale" >
{ isEn ? "Delayed snapshot" : "延迟快照" }
</ span >
) : null }
2026-04-27 00:30:51 +08:00
{ isPro ? (
< button
type = "button"
className = "scan-status-chip refresh"
onClick = { refreshScanTerminalManually }
disabled = { scanLoading }
title = {
isEn
2026-05-10 15:48:18 +08:00
? "Force refresh decision cards"
: "强制刷新决策卡"
2026-04-27 00:30:51 +08:00
}
>
< RefreshCw size = { 14 } className = { scanLoading ? "spin" : undefined } />
{ isEn ? "Refresh" : "刷新" }
</ button >
) : null }
2026-04-24 00:13:11 +08:00
</ div >
2026-04-23 23:49:52 +08:00
</ div >
2026-04-24 00:13:11 +08:00
2026-04-24 10:19:16 +08:00
{ scanStatus === "failed" && ! terminalData ? (
2026-04-24 00:13:11 +08:00
< div className = "scan-empty-state" >
< div className = "scan-empty-title" >
{ isEn ? "Scan failed" : "扫描失败" }
</ div >
2026-04-24 10:19:16 +08:00
< div className = "scan-empty-copy" >{ staleReason }</ div >
2026-05-10 16:13:34 +08:00
< button
type = "button"
className = "scan-retry-button"
onClick = {() => refreshScanTerminalManually ()}
>
< RefreshCw size = { 13 } />
{ isEn ? "Retry" : "重试" }
</ button >
2026-04-24 00:13:11 +08:00
</ div >
) : (
2026-04-26 00:24:11 +08:00
renderMainView ()
2026-04-24 00:13:11 +08:00
)}
</ section >
2026-04-23 23:49:52 +08:00
</ main >
2026-04-24 13:35:36 +08:00
< CityDetailPanel variant = "rail" />
2026-04-23 23:49:52 +08:00
</ div >
2026-05-10 16:19:21 +08:00
< WelcomeOverlay locale = { locale } onDismiss = {() => {}} />
2026-04-24 13:47:12 +08:00
< FutureForecastModal />
2026-04-25 00:19:30 +08:00
{ showScanPaywall ? (
< div
className = "modal-overlay"
role = "dialog"
aria-modal = "true"
aria-label = { isEn ? "Unlock market scan" : "解锁市场扫描" }
onClick = {( event ) => {
if ( event . target === event . currentTarget ) {
setShowScanPaywall ( false );
}
}}
>
< ProFeaturePaywall
feature = "scan"
onClose = {() => setShowScanPaywall ( false )}
/>
</ div >
) : null }
2026-04-23 23:49:52 +08:00
</ div >
);
}
export function ScanTerminalDashboard() {
return (
< I18nProvider >
< DashboardStoreProvider >
< ScanTerminalScreen />
</ DashboardStoreProvider >
</ I18nProvider >
);
}