2026-04-23 23:49:52 +08:00
"use client" ;
2026-04-26 00:24:11 +08:00
import type { ChartConfiguration } from "chart.js" ;
2026-04-23 23:49:52 +08:00
import clsx from "clsx" ;
2026-04-24 00:34:09 +08:00
import Link from "next/link" ;
2026-04-24 00:13:11 +08:00
import {
2026-04-26 00:24:11 +08:00
BarChart3 ,
2026-04-26 00:54:39 +08:00
ChevronDown ,
2026-04-25 03:59:02 +08:00
LogIn ,
2026-04-24 05:39:14 +08:00
Moon ,
2026-04-26 02:22:56 +08:00
RefreshCw ,
2026-04-24 05:39:14 +08:00
Sun ,
2026-04-24 00:34:09 +08:00
UserRound ,
2026-04-26 02:22:56 +08:00
X ,
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" ;
import detailChromeStyles from "./DetailPanelChrome.module.css" ;
import modalChromeStyles from "./ModalChrome.module.css" ;
2026-04-24 13:28:38 +08:00
import { DetailPanel as CityDetailPanel } from "@/components/dashboard/DetailPanel" ;
2026-04-24 06:31:29 +08:00
import { FutureForecastModal } from "@/components/dashboard/FutureForecastModal" ;
2026-04-24 03:30:55 +08:00
import { MapCanvas } from "@/components/dashboard/MapCanvas" ;
2026-04-26 00:24:11 +08:00
import { ModelForecast } from "@/components/dashboard/PanelSections" ;
2026-04-24 00:34:09 +08:00
import { getWindowPhaseMeta } from "@/components/dashboard/OpportunityTable" ;
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-26 00:24:11 +08:00
import { useChart } from "@/hooks/useChart" ;
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 ,
ScanTerminalResponse ,
} from "@/lib/dashboard-types" ;
2026-04-24 13:43:05 +08:00
import { getLocalizedCityName } from "@/lib/dashboard-home-copy" ;
2026-04-25 03:59:02 +08:00
import {
formatTemperatureValue ,
2026-04-26 00:24:11 +08:00
getModelView ,
getTemperatureChartData ,
getTodayPaceView ,
2026-04-25 03:59:02 +08:00
normalizeTemperatureLabel ,
} from "@/lib/dashboard-utils" ;
2026-04-25 04:25:17 +08:00
import {
getMarketFocus ,
getRowMarketRegion ,
getRowPeakSortValue ,
} from "@/lib/scan-market-focus" ;
2026-04-23 23:49:52 +08:00
2026-04-25 03:26:28 +08:00
type ContentView = "list" | "map" | "calendar" ;
2026-04-24 05:39:14 +08:00
type ThemeMode = "dark" | "light" ;
2026-04-26 00:24:11 +08:00
type AiPinnedCity = {
cityName : string ;
displayName? : string | null ;
addedAt : number ;
2026-04-25 01:09:08 +08:00
};
2026-04-26 01:55:30 +08:00
type AiCityForecastPayload = {
status? : string | null ;
reason? : string | null ;
2026-04-26 04:11:20 +08:00
reason_zh? : string | null ;
reason_en? : string | null ;
raw_reason? : string | null ;
2026-04-26 01:55:30 +08:00
model? : string | null ;
provider? : string | null ;
city_forecast ?: {
predicted_max? : number | string | null ;
range_low? : number | string | null ;
range_high? : number | string | null ;
unit? : string | null ;
confidence? : string | null ;
final_judgment_zh? : string | null ;
final_judgment_en? : string | null ;
metar_read_zh? : string | null ;
metar_read_en? : string | null ;
reasoning_zh? : string | null ;
reasoning_en? : string | null ;
risks_zh? : string [] | null ;
risks_en? : string [] | null ;
model_cluster_note_zh? : string | null ;
model_cluster_note_en? : string | null ;
} | null ;
};
type AiCityForecastState = {
status : "idle" | "loading" | "ready" | "failed" ;
payload? : AiCityForecastPayload | null ;
error? : string | null ;
};
2026-04-25 01:09:08 +08:00
2026-04-24 03:30:55 +08:00
function formatShortDate ( value? : string | null , locale = "zh-CN" ) {
const text = String ( value || "" ). trim ();
if ( ! text ) return "--" ;
const date = new Date ( ` ${ text } T00:00:00` );
if ( Number . isNaN ( date . getTime ())) return text ;
return locale === "en-US"
? date . toLocaleDateString ( "en-US" , { month : "short" , day : "numeric" })
: date . toLocaleDateString ( "zh-CN" , { month : "numeric" , day : "numeric" });
}
2026-04-25 03:59:02 +08:00
function formatCountdownMinutes ( value? : number | null , locale = "zh-CN" ) {
const numeric = Number ( value );
if ( ! Number . isFinite ( numeric )) return "--" ;
const minutes = Math . max ( 0 , Math . round ( Math . abs ( numeric )));
const hours = Math . floor ( minutes / 60 );
const remains = minutes % 60 ;
if ( locale === "en-US" ) {
if ( hours <= 0 ) return ` ${ remains } m` ;
if ( remains <= 0 ) return ` ${ hours } h` ;
return ` ${ hours } h ${ remains } m` ;
}
if ( hours <= 0 ) return ` ${ remains } 分钟` ;
if ( remains <= 0 ) return ` ${ hours } 小时` ;
return ` ${ hours } 小时 ${ remains } 分钟` ;
}
function getPeakWindowLabel ( row : ScanOpportunityRow ) {
const direct = String ( row . peak_window_label || "" ). trim ();
if ( direct ) return direct ;
const start = String ( row . peak_window_start || "" ). trim ();
const end = String ( row . peak_window_end || "" ). trim ();
if ( start && end ) return ` ${ start } - ${ end } ` ;
return "--" ;
}
function getPeakCountdownMeta ( row : ScanOpportunityRow , locale = "zh-CN" ) {
const isEn = locale === "en-US" ;
const phase = String ( row . window_phase || "" ). toLowerCase ();
const startDelta = Number ( row . minutes_until_peak_start );
const endDelta = Number ( row . minutes_until_peak_end );
const hasStart = Number . isFinite ( startDelta );
const hasEnd = Number . isFinite ( endDelta );
if ( phase === "active_peak" || ( hasStart && startDelta <= 0 && hasEnd && endDelta >= 0 )) {
return {
key : "active" ,
groupLabel : isEn ? "Peak window now" : "峰值窗口进行中" ,
tone : "active" ,
sort : 0 ,
title : isEn ? "At peak window" : "已进入峰值窗口" ,
detail :
hasEnd && endDelta >= 0
? isEn
? ` ${ formatCountdownMinutes ( endDelta , locale ) } left`
: `剩余 ${ formatCountdownMinutes ( endDelta , locale ) } `
: getPeakWindowLabel ( row ),
};
}
if ( hasStart && startDelta > 0 && startDelta <= 180 ) {
return {
key : "next" ,
groupLabel : isEn ? "Next 3 hours" : "未来 3 小时到峰值" ,
tone : "next" ,
sort : 1000 + startDelta ,
title : isEn
? ` ${ formatCountdownMinutes ( startDelta , locale ) } to peak`
: `还有 ${ formatCountdownMinutes ( startDelta , locale ) } 到峰值` ,
detail : getPeakWindowLabel ( row ),
};
}
if ( hasStart && startDelta > 0 && startDelta <= 1440 ) {
return {
key : "today" ,
groupLabel : isEn ? "Later today" : "今日稍后" ,
tone : "upcoming" ,
sort : 2000 + startDelta ,
title : isEn
? ` ${ formatCountdownMinutes ( startDelta , locale ) } to peak`
: `还有 ${ formatCountdownMinutes ( startDelta , locale ) } 到峰值` ,
detail : getPeakWindowLabel ( row ),
};
}
if ( hasStart && startDelta > 1440 ) {
return {
key : "later" ,
groupLabel : isEn ? "Later sessions" : "后续交易时段" ,
tone : "later" ,
sort : 3000 + startDelta ,
title : isEn
? ` ${ formatCountdownMinutes ( startDelta , locale ) } to peak`
: `还有 ${ formatCountdownMinutes ( startDelta , locale ) } 到峰值` ,
detail : getPeakWindowLabel ( row ),
};
}
return {
key : "past" ,
groupLabel : isEn ? "Past peak" : "峰值已过" ,
tone : "past" ,
sort : 9000 + Math . abs ( startDelta || 0 ),
title :
hasEnd && endDelta < 0
? isEn
? `Peak passed ${ formatCountdownMinutes ( endDelta , locale ) } ago`
: `峰值已过 ${ formatCountdownMinutes ( endDelta , locale ) } `
: isEn
? "Peak window passed"
: "峰值窗口已过" ,
detail : getPeakWindowLabel ( row ),
};
}
2026-04-24 06:31:29 +08:00
function formatUserLocalTime() {
const now = new Date ();
return ` ${ String ( now . getHours ()). padStart ( 2 , "0" ) } : ${ String (
now . getMinutes (),
). padStart ( 2 , "0" ) } ` ;
2026-04-24 05:39:14 +08:00
}
2026-04-24 05:09:19 +08:00
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 ;
2026-04-25 04:25:17 +08:00
if ( phase === "post_peak" ) return 2 ;
if ( phase === "early_today" ) return 3 ;
2026-04-24 05:09:19 +08:00
if ( phase === "tomorrow" ) return 4 ;
if ( phase === "week_ahead" ) return 5 ;
return 6 ;
}
function sortRowsByUserTime ( rows : ScanOpportunityRow []) {
2026-04-25 04:25:17 +08:00
const focus = getMarketFocus ( rows );
2026-04-24 05:09:19 +08:00
return [... rows ]. sort (( left , right ) => {
2026-04-25 04:25:17 +08:00
if ( focus ) {
const leftFocusRank = getRowMarketRegion ( left ) === focus . key ? 0 : 1 ;
const rightFocusRank = getRowMarketRegion ( right ) === focus . key ? 0 : 1 ;
if ( leftFocusRank !== rightFocusRank ) return leftFocusRank - rightFocusRank ;
}
const leftPeakSort = getRowPeakSortValue ( left );
const rightPeakSort = getRowPeakSortValue ( right );
if ( leftPeakSort . stage . rank !== rightPeakSort . stage . rank ) {
return leftPeakSort . stage . rank - rightPeakSort . stage . rank ;
}
if ( leftPeakSort . countdown !== rightPeakSort . countdown ) {
return leftPeakSort . countdown - rightPeakSort . countdown ;
}
2026-04-24 05:09:19 +08:00
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 );
});
}
2026-04-24 13:09:31 +08:00
function normalizeCityKey ( value? : string | null ) {
return String ( value || "" )
. trim ()
. toLowerCase ()
. replace ( /[\s_-]+/g , "" );
}
2026-04-26 00:24:11 +08:00
function prettifyCityName ( value? : string | null ) {
return String ( value || "" )
. trim ()
. replace ( /[-_]+/g , " " )
. replace ( /\b\w/g , ( match ) => match . toUpperCase ());
}
2026-04-24 13:09:31 +08:00
function rowMatchesCity ( row : ScanOpportunityRow , cityName : string ) {
const cityKey = normalizeCityKey ( cityName );
if ( ! cityKey ) return false ;
return [ row . city , row . city_display_name , row . display_name ]. some (
( value ) => normalizeCityKey ( value ) === cityKey ,
);
}
function findRowForCity ( rows : ScanOpportunityRow [], cityName? : string | null ) {
const normalized = normalizeCityKey ( cityName );
if ( ! normalized ) return null ;
return rows . find (( row ) => rowMatchesCity ( row , cityName || "" )) || null ;
}
2026-04-24 03:30:55 +08:00
function CalendarView ({
rows ,
locale ,
selectedRowId ,
onSelectRow ,
} : {
rows : ScanOpportunityRow [];
locale : string ;
selectedRowId : string | null ;
onSelectRow : ( row : ScanOpportunityRow ) => void ;
}) {
const groups = useMemo (() => {
2026-04-25 03:59:02 +08:00
const order = [ "active" , "next" , "today" , "later" , "past" ];
const byPhase = new Map <
string ,
{
label : string ;
sort : number ;
items : Array < { row : ScanOpportunityRow ; meta : ReturnType < typeof getPeakCountdownMeta > } > ;
}
> ();
2026-04-24 03:30:55 +08:00
rows . forEach (( row ) => {
2026-04-25 03:59:02 +08:00
const meta = getPeakCountdownMeta ( row , locale );
const current = byPhase . get ( meta . key ) || {
label : meta.groupLabel ,
sort : order.indexOf ( meta . key ) >= 0 ? order . indexOf ( meta . key ) : order . length ,
items : [],
};
current . items . push ({ row , meta });
byPhase . set ( meta . key , current );
2026-04-24 03:30:55 +08:00
});
2026-04-25 03:59:02 +08:00
return Array . from ( byPhase . entries ())
. sort (([, left ], [, right ]) => left . sort - right . sort )
. map (([ key , group ]) => ({
key ,
label : group.label ,
items : group.items.sort (( left , right ) => {
if ( left . meta . sort !== right . meta . sort ) return left . meta . sort - right . meta . sort ;
return Number ( right . row . edge_percent || 0 ) - Number ( left . row . edge_percent || 0 );
}),
}));
}, [ locale , rows ]);
2026-04-24 03:30:55 +08:00
if ( ! groups . length ) {
return (
< div className = "scan-empty-state compact" >
< div className = "scan-empty-title" >
{ locale === "en-US" ? "No dated opportunities" : "当前没有日期机会" }
</ div >
</ div >
);
}
return (
< div className = "scan-calendar-view" >
2026-04-25 03:59:02 +08:00
{ groups . map (( group ) => (
< section key = { group . key } className = "scan-calendar-group" >
2026-04-24 03:30:55 +08:00
< div className = "scan-calendar-group-head" >
2026-04-25 03:59:02 +08:00
< div >
< div className = "scan-calendar-date" >{ group . label }</ div >
< div className = "scan-calendar-subtitle" >
{ locale === "en-US"
? "Ordered by DEB peak-window countdown"
: "按 DEB 峰值窗口倒计时排序" }
</ div >
</ div >
2026-04-24 03:30:55 +08:00
< div className = "scan-calendar-count" >
2026-04-25 03:59:02 +08:00
{ locale === "en-US" ? ` ${ group . items . length } rows` : ` ${ group . items . length } 条` }
2026-04-24 03:30:55 +08:00
</ div >
</ div >
< div className = "scan-calendar-grid" >
2026-04-25 03:59:02 +08:00
{ group . items . map (({ row , meta }) => (
2026-04-24 03:30:55 +08:00
< button
key = { row . id }
type = "button"
2026-04-25 03:59:02 +08:00
className = { `scan-calendar-card peak- ${ meta . tone } ${ selectedRowId === row . id ? "selected" : "" } ` }
2026-04-24 03:30:55 +08:00
onClick = {() => onSelectRow ( row )}
>
2026-04-24 10:19:16 +08:00
{(() => {
const tempSymbol = row . temp_symbol || "°C" ;
const phaseMeta = getWindowPhaseMeta ( row , locale );
return (
<>
2026-04-24 03:30:55 +08:00
< div className = "scan-calendar-city" >
{ getLocalizedCityName (
row . city ,
row . city_display_name || row . display_name || row . city ,
locale ,
)}
</ div >
2026-04-25 03:59:02 +08:00
< div className = "scan-calendar-countdown" >
{ meta . title }
< small >{ meta . detail }</ small >
</ div >
2026-04-24 10:19:16 +08:00
< div className = "scan-calendar-action" >
2026-04-25 03:59:02 +08:00
< span >{ locale === "en-US" ? "DEB high" : "DEB 预测高点" }</ span >
< b >
{ row . deb_prediction != null
? formatTemperatureValue ( row . deb_prediction , tempSymbol )
: "--" }
</ b >
2026-04-24 03:30:55 +08:00
</ div >
< div className = "scan-calendar-meta" >
2026-04-25 03:59:02 +08:00
< span >
{ formatShortDate ( row . selected_date || row . local_date , locale )} · { row . local_time || "--" }
</ span >
2026-04-24 10:19:16 +08:00
< span >{ phaseMeta . label }</ span >
2026-04-24 03:30:55 +08:00
</ div >
2026-04-24 10:19:16 +08:00
</>
);
})()}
2026-04-24 03:30:55 +08:00
</ button >
))}
</ div >
</ section >
))}
</ div >
);
}
2026-04-26 00:24:11 +08:00
function findDetailForCity (
detailsByName : Record < string , CityDetail >,
cityName? : string | null ,
) {
const target = normalizeCityKey ( cityName );
if ( ! target ) return null ;
return (
Object . values ( detailsByName ). find (( detail ) =>
[ detail ? . name , detail ? . display_name ]. some (
( value ) => normalizeCityKey ( value ) === target ,
),
) || null
);
}
function AiCityTemperatureChart ({ detail } : { detail : CityDetail }) {
const { locale } = useI18n ();
const chartData = useMemo (
() => getTemperatureChartData ( detail , locale ),
[ detail , locale ],
);
const forecastLabel = chartData ? . datasets . hasMgmHourly
? locale === "en-US"
? "MGM forecast"
: "MGM 预测"
: locale === "en-US"
? "DEB forecast"
: "DEB 预测" ;
const observationLabel =
chartData ? . observationLabel ||
( locale === "en-US" ? "METAR obs" : "METAR 实况" );
const canvasRef = useChart (() => {
if ( ! chartData ) {
return {
data : { datasets : [], labels : [] },
type : "line" ,
} satisfies ChartConfiguration < "line" > ;
}
const forecastPoints = chartData . datasets . hasMgmHourly
? chartData.datasets.mgmHourlyPoints
: chartData.datasets.debPast.map (
( value , index ) => value ?? chartData . datasets . debFuture [ index ],
);
return {
data : {
datasets : [
{
borderColor : "#4DA3FF" ,
borderWidth : 2 ,
data : forecastPoints ,
fill : false ,
label : forecastLabel ,
pointRadius : 0 ,
spanGaps : true ,
tension : 0.32 ,
},
{
backgroundColor : "#22C55E" ,
borderColor : "#22C55E" ,
borderWidth : 0 ,
data : chartData.datasets.metarPoints ,
fill : false ,
label : observationLabel ,
pointHoverRadius : 5 ,
pointRadius : 3.5 ,
showLine : false ,
},
],
labels : chartData.times ,
},
options : {
interaction : { intersect : false , mode : "index" },
layout : { padding : { bottom : 2 , left : 0 , right : 8 , top : 8 } },
maintainAspectRatio : false ,
plugins : {
legend : { display : false },
tooltip : {
backgroundColor : "rgba(11, 18, 32, 0.96)" ,
borderColor : "rgba(77, 163, 255, 0.38)" ,
borderWidth : 1 ,
},
},
responsive : true ,
scales : {
x : {
grid : { color : "rgba(159, 178, 199, 0.08)" },
ticks : {
callback : ( _value , index ) =>
typeof index === "number" && index % 4 === 0
? chartData . times [ index ]
: "" ,
color : "#6B7A90" ,
font : { size : 10 },
maxTicksLimit : 6 ,
maxRotation : 0 ,
},
},
y : {
grid : { color : "rgba(159, 178, 199, 0.08)" },
max : chartData.max ,
min : chartData.min ,
ticks : {
callback : ( value ) =>
` ${ Number ( value ). toFixed ( chartData . yTickStep < 1 ? 1 : 0 ) }${ detail . temp_symbol || "°C" } ` ,
color : "#6B7A90" ,
font : { size : 10 },
maxTicksLimit : 5 ,
stepSize : chartData.yTickStep ,
},
},
},
},
type : "line" ,
} satisfies ChartConfiguration < "line" > ;
}, [ chartData , detail . temp_symbol , forecastLabel , observationLabel ]);
return (
< section className = "scan-ai-city-section chart" >
< div className = "scan-ai-city-section-title" >
< BarChart3 size = { 15 } />
< span >{ locale === "en-US" ? "Intraday path" : "今日日内分析" }</ span >
</ div >
< div className = "scan-ai-city-chart" >
< canvas ref = { canvasRef } />
</ div >
{ chartData ? (
< div className = "scan-ai-city-chart-legend" >
< span >< i className = "forecast" />{ forecastLabel }</ span >
< span >< i className = "observation" />{ observationLabel }</ span >
</ div >
) : null }
</ section >
);
}
function AiPinnedCityCard ({
item ,
detail ,
row ,
locale ,
2026-04-26 00:54:39 +08:00
collapsed ,
2026-04-26 02:22:56 +08:00
removing ,
2026-04-26 00:24:11 +08:00
onRemove ,
2026-04-26 00:54:39 +08:00
onToggleCollapsed ,
2026-04-26 00:24:11 +08:00
} : {
item : AiPinnedCity ;
detail : CityDetail | null ;
row : ScanOpportunityRow | null ;
locale : string ;
2026-04-26 00:54:39 +08:00
collapsed : boolean ;
2026-04-26 02:22:56 +08:00
removing? : boolean ;
2026-04-26 00:24:11 +08:00
onRemove : () => void ;
2026-04-26 00:54:39 +08:00
onToggleCollapsed : () => void ;
2026-04-26 00:24:11 +08:00
}) {
const isEn = locale === "en-US" ;
const displayName =
detail ? . display_name ||
row ? . city_display_name ||
row ? . display_name ||
item . displayName ||
item . cityName ;
const tempSymbol = detail ? . temp_symbol || row ? . temp_symbol || "°C" ;
const modelView = detail ? getModelView ( detail , detail . local_date ) : null ;
2026-04-26 04:11:20 +08:00
const modelEntries = modelView
? Object . entries ( modelView . models || {})
. map (([ name , value ]) => [ name , Number ( value )] as const )
. filter (([, value ]) => Number . isFinite ( value ))
2026-04-26 00:24:11 +08:00
: [];
2026-04-26 04:11:20 +08:00
const modelValues = modelEntries . map (([, value ]) => value );
2026-04-26 00:24:11 +08:00
const modelMin = modelValues . length ? Math . min (... modelValues ) : null ;
const modelMax = modelValues . length ? Math . max (... modelValues ) : null ;
const paceView = detail ? getTodayPaceView ( detail , locale as "zh-CN" | "en-US" ) : null ;
const peakWindow =
paceView ? . peakWindowText ||
( row ? getPeakWindowLabel ( row ) : null ) ||
"--" ;
const deb = detail ? . deb ? . prediction ?? row ? . deb_prediction ?? null ;
const currentTemp =
detail ? . airport_primary ? . temp ??
detail ? . airport_current ? . temp ??
detail ? . current ? . temp ??
row ? . current_temp ??
null ;
const modelRange =
modelMin != null && modelMax != null
? ` ${ formatTemperatureValue ( modelMin , tempSymbol , { digits : 1 } )} ~ ${ formatTemperatureValue ( modelMax , tempSymbol , { digits : 1 } )}`
: "--" ;
const paceTone = paceView ? . biasTone || "neutral" ;
const paceText =
paceView ? . summary ||
( isEn
? "Waiting for intraday observations to compare against the DEB path."
: "等待更多日内实测,用来对照 DEB 预测路径。" );
const report = detail ? . current ? . raw_metar || detail ? . airport_current ? . raw_metar || "" ;
2026-04-26 01:55:30 +08:00
const airportStation =
detail ? . risk ? . icao ||
detail ? . current ? . station_code ||
detail ? . airport_current ? . station_code ||
detail ? . airport_primary ? . station_code ||
2026-04-26 00:24:11 +08:00
"" ;
2026-04-26 01:55:30 +08:00
const [ aiForecast , setAiForecast ] = useState < AiCityForecastState >({
status : "idle" ,
});
2026-04-26 02:22:56 +08:00
const [ aiRefreshToken , setAiRefreshToken ] = useState ( 0 );
2026-04-26 01:55:30 +08:00
const detailCityName = detail ? . name || item . cityName ;
const aiForecastKey = detail
? ` ${ normalizeCityKey ( detailCityName ) } : ${ detail . local_date || "" } : ${ report || "" } `
: "" ;
useEffect (() => {
2026-04-26 02:22:56 +08:00
if ( ! aiForecastKey ) return ;
2026-04-26 01:55:30 +08:00
let cancelled = false ;
setAiForecast ({ status : "loading" });
fetch ( "/api/scan/terminal/ai-city" , {
method : "POST" ,
headers : {
Accept : "application/json" ,
"Content-Type" : "application/json" ,
},
cache : "no-store" ,
body : JSON.stringify ({
city : detailCityName ,
2026-04-26 02:22:56 +08:00
force_refresh : aiRefreshToken > 0 ,
2026-04-26 03:42:13 +08:00
locale ,
2026-04-26 01:55:30 +08:00
}),
})
. then ( async ( response ) => {
if ( ! response . ok ) {
2026-04-26 04:32:46 +08:00
let detail = "" ;
try {
const errorPayload = await response . json ();
const message = String ( errorPayload ? . error || "" ). trim ();
const rawDetail = String ( errorPayload ? . detail || "" ). trim ();
const elapsed = Number ( errorPayload ? . elapsed_ms );
const timeout = Number ( errorPayload ? . timeout_ms );
detail = [
message ,
rawDetail ,
Number . isFinite ( elapsed ) && Number . isFinite ( timeout )
? `elapsed ${ Math . round ( elapsed / 1000 ) } s / timeout ${ Math . round ( timeout / 1000 ) } s`
: "" ,
]
. filter ( Boolean )
. join ( " · " );
} catch {
detail = "" ;
}
throw new Error ( detail ? `HTTP ${ response . status } · ${ detail } ` : `HTTP ${ response . status } ` );
2026-04-26 01:55:30 +08:00
}
return response . json () as Promise < AiCityForecastPayload >;
})
. then (( payload ) => {
if ( ! cancelled ) {
setAiForecast ({ payload , status : "ready" });
}
})
. catch (( error ) => {
if ( ! cancelled ) {
setAiForecast ({ error : String ( error ), status : "failed" });
}
});
return () => {
cancelled = true ;
};
2026-04-26 03:42:13 +08:00
}, [ aiForecastKey , aiRefreshToken , detailCityName , locale ]);
2026-04-26 01:55:30 +08:00
const aiCityForecast = aiForecast . payload ? . city_forecast || null ;
const localizedFinalJudgment =
( isEn ? aiCityForecast?.final_judgment_en : aiCityForecast?.final_judgment_zh ) ||
( isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh ) ||
"" ;
const localizedMetarRead =
( isEn ? aiCityForecast?.metar_read_en : aiCityForecast?.metar_read_zh ) ||
"" ;
const localizedReasoning =
( isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh ) ||
"" ;
const localizedModelNote =
( isEn
? aiCityForecast?.model_cluster_note_en
: aiCityForecast?.model_cluster_note_zh ) || "" ;
2026-04-26 04:11:20 +08:00
const modelPreview = modelEntries
. slice ( 0 , 4 )
. map (([ name , value ]) => ` ${ name } ${ formatTemperatureValue ( value , tempSymbol , { digits : 1 } )}` )
. join ( isEn ? " / " : " / " );
const localModelSupportNote = modelEntries . length
? isEn
? modelEntries . length <= 2
? `Model support is sparse: only ${ modelEntries . length } sources are available ${ modelPreview ? ` ( ${ modelPreview } )` : "" } , so the read should lean more on DEB path and METAR.`
: `Model support: ${ modelEntries . length } sources cluster between ${ modelRange } ; ${ modelPreview } .`
: modelEntries . length <= 2
? `多模型支撑偏少:当前只有 ${ modelEntries . length } 个模型 ${ modelPreview ? `( ${ modelPreview } ) ` : "" } ,需要更重视 DEB 路径和 METAR 实测。`
: `多模型支撑: ${ modelEntries . length } 个模型集中在 ${ modelRange } ,代表模型为 ${ modelPreview } 。`
: isEn
? "Model support is unavailable, so this city must rely on DEB path and METAR observations."
: "暂无可用多模型支撑,需要主要参考 DEB 路径和 METAR 实测。" ;
2026-04-26 01:55:30 +08:00
const localizedRisksRaw =
( isEn ? aiCityForecast?.risks_en : aiCityForecast?.risks_zh ) || [];
const localizedRisks = Array . isArray ( localizedRisksRaw )
? localizedRisksRaw
: localizedRisksRaw
? [ String ( localizedRisksRaw )]
: [];
const aiBullets = [
localizedMetarRead ,
localizedReasoning !== localizedFinalJudgment ? localizedReasoning : "" ,
2026-04-26 04:11:20 +08:00
localizedModelNote || localModelSupportNote ,
2026-04-26 01:55:30 +08:00
... localizedRisks ,
]. filter (( line ) => String ( line || "" ). trim ());
2026-04-26 04:11:20 +08:00
const fallbackAiReason =
( isEn ? aiForecast.payload?.reason_en : aiForecast.payload?.reason_zh ) ||
aiForecast . payload ? . reason ||
"" ;
2026-04-26 00:24:11 +08:00
2026-04-26 00:54:39 +08:00
const collapseId = `ai-city-body- ${ normalizeCityKey ( item . cityName ) || item . addedAt } ` ;
2026-04-26 00:24:11 +08:00
return (
2026-04-26 02:22:56 +08:00
< article className = { clsx ( "scan-ai-city-card" , collapsed && "collapsed" , removing && "removing" )}>
2026-04-26 00:24:11 +08:00
< header className = "scan-ai-city-hero" >
< div >
< span className = "scan-ai-city-kicker" >
2026-04-26 02:22:56 +08:00
{ isEn ? "Deep analysis" : "城市深度分析" }
2026-04-26 00:24:11 +08:00
</ span >
< h3 >{ displayName }</ h3 >
< div className = "scan-ai-city-pills" >
< span >{ detail ? . local_time || row ? . local_time || "--" }</ span >
< span >
DEB { " " }
{ deb != null
? formatTemperatureValue ( deb , tempSymbol , { digits : 1 })
: "--" }
</ span >
< span >{ isEn ? "Model" : "模型" } { modelRange }</ span >
< span >{ isEn ? "Peak" : "峰值" } { peakWindow }</ span >
</ div >
</ div >
< div className = "scan-ai-city-hero-side" >
< span >{ isEn ? "Expected high" : "预计最高温" }</ span >
< strong >
{ paceView ? . paceAdjustedHigh != null
? formatTemperatureValue ( paceView . paceAdjustedHigh , tempSymbol , { digits : 1 })
: deb != null
? formatTemperatureValue ( deb , tempSymbol , { digits : 1 })
: "--" }
</ strong >
2026-04-26 00:54:39 +08:00
< div className = "scan-ai-city-actions" >
< button
type = "button"
2026-04-26 02:22:56 +08:00
className = "scan-ai-city-icon-button"
2026-04-26 02:47:24 +08:00
onClick = {( event ) => {
event . preventDefault ();
event . stopPropagation ();
setAiRefreshToken (( current ) => current + 1 );
}}
2026-04-26 02:22:56 +08:00
aria-label = { isEn ? `Refresh ${ displayName } analysis` : `刷新 ${ displayName } 深度分析` }
title = { isEn ? "Refresh analysis" : "刷新深度分析" }
disabled = { aiForecast . status === "loading" }
2026-04-26 00:54:39 +08:00
>
2026-04-26 02:22:56 +08:00
< RefreshCw size = { 15 } className = { aiForecast . status === "loading" ? "spin" : undefined } />
</ button >
< button
type = "button"
className = "scan-ai-city-icon-button danger"
2026-04-26 02:47:24 +08:00
onClick = {( event ) => {
event . preventDefault ();
event . stopPropagation ();
onRemove ();
}}
2026-04-26 02:22:56 +08:00
aria-label = { isEn ? `Remove ${ displayName } ` : `移除 ${ displayName } ` }
title = { isEn ? "Remove city" : "移除城市" }
disabled = { removing }
>
< X size = { 15 } />
2026-04-26 00:54:39 +08:00
</ button >
< button
type = "button"
className = "scan-ai-city-collapse"
onClick = { onToggleCollapsed }
aria-expanded = { ! collapsed }
aria-controls = { collapseId }
>
< ChevronDown size = { 15 } />
{ collapsed ? ( isEn ? "Expand" : "展开" ) : ( isEn ? "Collapse" : "收起" )}
</ button >
</ div >
2026-04-26 00:24:11 +08:00
</ div >
</ header >
2026-04-26 00:54:39 +08:00
{ detail && ! collapsed ? (
< div className = "scan-ai-city-body" id = { collapseId }>
2026-04-26 00:24:11 +08:00
< section className = { clsx ( "scan-ai-decision-band" , paceTone )}>
< div >
< span >{ isEn ? "Final read" : "最终判断" }</ span >
< strong >
{ paceTone === "warm"
? isEn
? "Running above DEB path"
: "实测高于 DEB 路径"
: paceTone === "cold"
? isEn
? "Running below DEB path"
: "实测低于 DEB 路径"
: isEn
? "Tracking DEB path"
: "基本贴合 DEB 路径" }
</ strong >
< p >{ paceText }</ p >
</ div >
< div className = "scan-ai-decision-metrics" >
< span >
{ isEn ? "Observed" : "实测" }{ " " }
< b >
{ currentTemp != null
? formatTemperatureValue ( currentTemp , tempSymbol , { digits : 1 })
: "--" }
</ b >
</ span >
< span >
{ isEn ? "Path delta" : "路径偏差" } < b >{ paceView ? . deltaText || "--" }</ b >
</ span >
< span >
{ isEn ? "Model support" : "模型支持" } < b >{ modelValues . length }</ b >
</ span >
</ div >
</ section >
< div className = "scan-ai-city-analysis-grid" >
< AiCityTemperatureChart detail = { detail } />
< section className = "scan-ai-city-section" >
< div className = "scan-ai-city-section-title" >
2026-04-26 01:55:30 +08:00
{ isEn ? "AI airport weather read" : "AI 机场报文解读" }
2026-04-26 00:24:11 +08:00
</ div >
2026-04-26 01:55:30 +08:00
{ aiForecast . status === "loading" ? (
< p >
{ isEn
2026-04-26 02:22:56 +08:00
? "Deepseek V4 pro is reading the latest airport bulletin..."
: "Deepseek V4 pro 正在解读最新机场报文..." }
2026-04-26 01:55:30 +08:00
</ p >
) : aiForecast . status === "ready" && aiCityForecast ? (
<>
< p className = "scan-ai-weather-summary" >
{ localizedFinalJudgment ||
( isEn ? "AI read returned without a final sentence." : "AI 已返回,但缺少最终判断。" )}
</ p >
< ul className = "scan-ai-weather-bullets" >
{ aiBullets . map (( line , index ) => (
< li key = { ` ${ line } - ${ index } ` }>{ line }</ li >
))}
</ ul >
< p className = "scan-ai-raw-metar" >
{ report
? ` ${ isEn ? "Raw METAR" : "原始 METAR" } : ${ ` ${ airportStation } ${ report } ` . trim () } `
: isEn
? "Raw METAR: unavailable."
: "原始 METAR:暂无。" }
</ p >
</>
) : aiForecast . status === "ready" ? (
2026-04-26 04:11:20 +08:00
<>
< p >
{ aiForecast . payload ? . status === "timeout"
? isEn
? "Deepseek V4-Pro timed out. You can retry; city data and the right briefing were not refreshed."
: "Deepseek V4-Pro 本次解读超时,可稍后重试;城市数据和右侧简报不会被刷新。"
: fallbackAiReason ||
( isEn
? "AI read is unavailable for this city right now."
: "该城市暂时没有可用的 AI 解读。" )}
</ p >
< ul className = "scan-ai-weather-bullets" >
< li >{ localModelSupportNote }</ li >
< li >
{ report
? ` ${ isEn ? "Raw METAR" : "原始 METAR" } : ${ ` ${ airportStation } ${ report } ` . trim () } `
: isEn
? "Raw METAR is unavailable."
: "暂无原始 METAR。" }
</ li >
</ ul >
</>
2026-04-26 01:55:30 +08:00
) : aiForecast . status === "failed" ? (
2026-04-26 04:11:20 +08:00
<>
< p >
{ isEn
? "AI read failed. Model support and the raw METAR remain as fallback context."
: "AI 解读失败。下方保留多模型支撑和原始 METAR 作为兜底上下文。" }
{ aiForecast . error ? ` ${ aiForecast . error } ` : "" }
</ p >
< ul className = "scan-ai-weather-bullets" >
< li >{ localModelSupportNote }</ li >
< li >
{ report
? ` ${ isEn ? "Raw METAR" : "原始 METAR" } : ${ ` ${ airportStation } ${ report } ` . trim () } `
: isEn
? "Raw METAR is unavailable."
: "暂无原始 METAR。" }
</ li >
</ ul >
</>
2026-04-26 01:55:30 +08:00
) : (
< p >
{ isEn
? "Waiting for AI to read the latest airport bulletin."
: "等待 AI 解读最新机场报文。" }
</ p >
)}
2026-04-26 00:24:11 +08:00
</ section >
</ div >
< section className = "scan-ai-city-section models" >
< div className = "scan-ai-city-section-title" >
{ isEn ? "Multi-model support" : "多模型支撑" }
</ div >
< ModelForecast detail = { detail } targetDate = { detail . local_date } hideTitle />
</ section >
</ div >
2026-04-26 00:54:39 +08:00
) : ! detail ? (
2026-04-26 00:24:11 +08:00
< div className = "scan-ai-city-loading" >
{ isEn ? "Loading today analysis for this city..." : "正在加载该城市的今日日内分析..." }
</ div >
2026-04-26 00:54:39 +08:00
) : null }
2026-04-26 00:24:11 +08:00
</ article >
);
}
function AiPinnedForecastView ({
items ,
rows ,
detailsByName ,
locale ,
onRemoveCity ,
} : {
items : AiPinnedCity [];
rows : ScanOpportunityRow [];
detailsByName : Record < string , CityDetail >;
locale : string ;
onRemoveCity : ( cityName : string ) => void ;
}) {
const isEn = locale === "en-US" ;
2026-04-26 00:54:39 +08:00
const [ collapsedCities , setCollapsedCities ] = useState < Set < string >>(
() => new Set (),
);
2026-04-26 02:22:56 +08:00
const [ removingCities , setRemovingCities ] = useState < Set < string >>(
() => new Set (),
);
const knownCityKeysRef = useRef < Set < string >>( new Set ());
const removeTimersRef = useRef < Map < string , ReturnType < typeof setTimeout >>>( new Map ());
useEffect (() => {
const activeKeys = new Set (
items . map (( item ) => normalizeCityKey ( item . cityName ) || item . cityName ),
);
setCollapsedCities (( current ) => {
const next = new Set < string >();
let changed = false ;
current . forEach (( key ) => {
if ( activeKeys . has ( key )) {
next . add ( key );
} else {
changed = true ;
}
});
items . forEach (( item ) => {
const stableKey = normalizeCityKey ( item . cityName ) || item . cityName ;
if ( ! knownCityKeysRef . current . has ( stableKey )) {
next . add ( stableKey );
changed = true ;
}
});
return changed ? next : current ;
});
knownCityKeysRef . current = activeKeys ;
}, [ items ]);
useEffect (() => {
return () => {
removeTimersRef . current . forEach (( timer ) => clearTimeout ( timer ));
removeTimersRef . current . clear ();
};
}, []);
const removeCityWithMotion = useCallback (
( item : AiPinnedCity , stableKey : string ) => {
if ( removeTimersRef . current . has ( stableKey )) return ;
setRemovingCities (( current ) => {
const next = new Set ( current );
next . add ( stableKey );
return next ;
});
const timer = setTimeout (() => {
onRemoveCity ( item . cityName );
setRemovingCities (( current ) => {
const next = new Set ( current );
next . delete ( stableKey );
return next ;
});
removeTimersRef . current . delete ( stableKey );
}, 260 );
removeTimersRef . current . set ( stableKey , timer );
},
[ onRemoveCity ],
);
2026-04-26 00:24:11 +08:00
if ( ! items . length ) {
return (
< div className = "scan-ai-workspace empty" >
< div className = "scan-empty-state" >
< div className = "scan-empty-title" >
{ isEn ? "Click a city on the map" : "从分布视图点击城市" }
</ div >
< div className = "scan-empty-copy" >
{ isEn
2026-04-26 02:22:56 +08:00
? "Selected cities will appear here as deep analysis blocks."
: "被点击的城市会加入深度分析页,并保留为城市分析区块。" }
2026-04-26 00:24:11 +08:00
</ div >
</ div >
</ div >
);
}
return (
< div className = "scan-ai-workspace" >
< div className = "scan-ai-workspace-head" >
< div >
2026-04-26 02:22:56 +08:00
< span >{ isEn ? "Selected city workspace" : "城市分析工作区" }</ span >
2026-04-26 00:24:11 +08:00
< strong >
{ isEn
2026-04-26 02:22:56 +08:00
? ` ${ items . length } cities under deep analysis`
: ` ${ items . length } 个城市正在深度分析` }
2026-04-26 00:24:11 +08:00
</ strong >
</ div >
< p >
{ isEn
2026-04-26 02:22:56 +08:00
? "Map clicks add cities here. City analysis stays here until you remove it."
: "地图点击会把城市加入这里;城市分析会保留,直到你手动移除。" }
2026-04-26 00:24:11 +08:00
</ p >
</ div >
< div className = "scan-ai-city-stack" >
{ items . map (( item ) => {
const detail = findDetailForCity ( detailsByName , item . cityName );
const row = findRowForCity ( rows , item . cityName );
const key = normalizeCityKey ( item . cityName );
2026-04-26 00:54:39 +08:00
const stableKey = key || item . cityName ;
2026-04-26 02:22:56 +08:00
const isKnownCity = knownCityKeysRef . current . has ( stableKey );
2026-04-26 00:24:11 +08:00
return (
< AiPinnedCityCard
2026-04-26 00:54:39 +08:00
key = { stableKey }
2026-04-26 00:24:11 +08:00
item = { item }
detail = { detail }
row = { row }
locale = { locale }
2026-04-26 02:22:56 +08:00
collapsed = { ! isKnownCity || collapsedCities . has ( stableKey )}
removing = { removingCities . has ( stableKey )}
onRemove = {() => removeCityWithMotion ( item , stableKey )}
2026-04-26 00:54:39 +08:00
onToggleCollapsed = {() => {
setCollapsedCities (( current ) => {
const next = new Set ( current );
if ( next . has ( stableKey )) {
next . delete ( stableKey );
} else {
next . add ( stableKey );
}
return next ;
});
}}
2026-04-26 00:24:11 +08:00
/>
);
})}
</ div >
</ div >
);
}
function AiForecastKPIBar ({
pinnedCount ,
activeCityName ,
activeDetail ,
activeRow ,
locale ,
} : {
pinnedCount : number ;
activeCityName? : string | null ;
activeDetail? : CityDetail | null ;
activeRow? : ScanOpportunityRow | null ;
locale : string ;
}) {
const isEn = locale === "en-US" ;
const tempSymbol = activeDetail ? . temp_symbol || activeRow ? . temp_symbol || "°C" ;
const displayName =
activeDetail ? . display_name ||
activeRow ? . city_display_name ||
activeRow ? . display_name ||
activeCityName ||
"--" ;
const deb = activeDetail ? . deb ? . prediction ?? activeRow ? . deb_prediction ?? null ;
const paceView = activeDetail
? getTodayPaceView ( activeDetail , locale as "zh-CN" | "en-US" )
: null ;
const peakWindow =
paceView ? . peakWindowText ||
( activeRow ? getPeakWindowLabel ( activeRow ) : null ) ||
"--" ;
const cards = [
{
2026-04-26 02:22:56 +08:00
label : isEn ? "Deep Analysis" : "深度分析" ,
2026-04-26 00:24:11 +08:00
value : String ( pinnedCount ),
2026-04-26 02:22:56 +08:00
note : isEn ? "Cities selected from map clicks" : "地图点选后进入深度分析" ,
2026-04-26 00:24:11 +08:00
tone : "green" ,
},
{
label : isEn ? "Current City" : "当前城市" ,
value : displayName ,
note : isEn ? "City briefing stays in the right rail" : "右侧城市简报同步显示,不自动切页" ,
tone : "blue" ,
},
{
label : isEn ? "Forecast Center" : "预测中枢" ,
value :
deb != null
? formatTemperatureValue ( deb , tempSymbol , { digits : 1 })
: "--" ,
note : isEn ? "DEB final blended forecast" : "DEB 最终融合预测,不含市场价格" ,
tone : "cyan" ,
},
{
label : isEn ? "Peak Window" : "峰值窗口" ,
value : peakWindow ,
note : isEn ? "Key window for daily high judgment" : "用于判断是否接近今日最高温" ,
tone : "amber" ,
},
];
return (
< section className = "scan-kpi-bar" >
{ cards . map (( card ) => (
< article key = { card . label } className = { `scan-kpi-card ${ card . tone } ` }>
< div className = "scan-kpi-label" >{ card . label }</ div >
< div className = "scan-kpi-value" >{ card . value }</ div >
< div className = "scan-kpi-note" >{ card . note }</ div >
</ article >
))}
</ section >
);
}
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-26 00:24:11 +08:00
const [ terminalData ] = useState < ScanTerminalResponse | null >( null );
2026-04-23 23:49:52 +08:00
const [ selectedRowId , setSelectedRowId ] = useState < string | null >( null );
2026-04-25 06:42:24 +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-26 00:24:11 +08:00
const [ aiPinnedCities , setAiPinnedCities ] = useState < AiPinnedCity [] >([]);
2026-04-25 00:19:30 +08:00
const [ showScanPaywall , setShowScanPaywall ] = useState ( false );
2026-04-24 05:39:14 +08:00
const [ userLocalTime , setUserLocalTime ] = useState ( "--" );
const [ themeMode , setThemeMode ] = useState < ThemeMode >( "dark" );
2026-04-26 00:24:11 +08:00
const lastMapSelectedCityRef = useRef < string >( "" );
2026-04-25 01:09:08 +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-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-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-26 00:24:11 +08:00
const kpiCityName =
mapSelectedCityName ||
store . selectedCity ||
aiPinnedCities [ 0 ] ? . cityName ||
null ;
const kpiDetail =
findDetailForCity ( store . cityDetailsByName , kpiCityName ) ||
( store . selectedDetail &&
normalizeCityKey ( store . selectedDetail . name ) === normalizeCityKey ( kpiCityName )
? store.selectedDetail
: null );
const kpiRow = findRowForCity ( timeSortedRows , kpiCityName );
2026-04-24 05:39:14 +08:00
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-23 23:49:52 +08:00
useEffect (() => {
2026-04-26 00:24:11 +08:00
if ( ! store . proAccess . loading && ! isPro && activeView === "calendar" ) {
2026-04-25 00:08:21 +08:00
setActiveView ( "map" );
}
2026-04-25 03:26:28 +08:00
}, [ activeView , isPro , store . proAccess . loading ]);
2026-04-23 23:49:52 +08:00
useEffect (() => {
2026-04-24 06:31:29 +08:00
setUserLocalTime ( formatUserLocalTime ());
2026-04-24 05:39:14 +08:00
const intervalId = window . setInterval (() => {
2026-04-24 06:31:29 +08:00
setUserLocalTime ( formatUserLocalTime ());
}, 10 _000 );
2026-04-24 05:39:14 +08:00
return () => window . clearInterval ( intervalId );
2026-04-24 06:31:29 +08:00
}, []);
2026-04-24 05:39:14 +08:00
useEffect (() => {
const stored = window . localStorage . getItem ( "polyweather_scan_theme" );
if ( stored === "light" ) {
setThemeMode ( "light" );
}
}, []);
useEffect (() => {
window . localStorage . setItem ( "polyweather_scan_theme" , themeMode );
}, [ themeMode ]);
2026-04-23 23:49:52 +08:00
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-24 06:31:29 +08:00
if ( ! store . cityDetailsByName [ activeDetailRow . city ]) {
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
2026-04-26 00:24:11 +08:00
const addAiPinnedCity = useCallback (( cityName : string ) => {
const cleanName = String ( cityName || "" ). trim ();
const key = normalizeCityKey ( cleanName );
if ( ! key ) return ;
const matchedRow = findRowForCity ( timeSortedRows , cleanName );
const prettyName = prettifyCityName ( cleanName );
const displayName =
matchedRow ? . city_display_name ||
matchedRow ? . display_name ||
getLocalizedCityName ( cleanName , prettyName || cleanName , locale ) ||
prettyName ||
cleanName ;
setAiPinnedCities (( current ) => {
const existing = current . findIndex (
( item ) => normalizeCityKey ( item . cityName ) === key ,
);
const nextItem = {
cityName : matchedRow?.city || cleanName ,
displayName ,
addedAt : Date.now (),
};
if ( existing >= 0 ) {
const next = [... current ];
next [ existing ] = { ... next [ existing ], ... nextItem };
return [
next [ existing ],
... next . filter (( _ , index ) => index !== existing ),
];
}
return [ nextItem , ... current ]. slice ( 0 , 8 );
});
void store . ensureCityDetail ( cleanName , false , "full" ). catch (() => {});
}, [ locale , store . ensureCityDetail , timeSortedRows ]);
const removeAiPinnedCity = useCallback (( cityName : string ) => {
const key = normalizeCityKey ( cityName );
setAiPinnedCities (( current ) =>
current . filter (( item ) => normalizeCityKey ( item . cityName ) !== key ),
);
}, []);
useEffect (() => {
aiPinnedCities . forEach (( item ) => {
if ( ! findDetailForCity ( store . cityDetailsByName , item . cityName )) {
void store . ensureCityDetail ( item . cityName , false , "full" ). catch (() => {});
}
});
}, [ aiPinnedCities , 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-04-24 05:39:14 +08:00
setSelectedRowId ( matchedRow ? . id || null );
2026-04-26 00:24:11 +08:00
addAiPinnedCity ( cityName );
}, [ addAiPinnedCity , timeSortedRows ]);
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-04-25 06:42:24 +08:00
const selectedCityKey = normalizeCityKey ( store . selectedCity );
const rowCityKey = normalizeCityKey ( cityName );
const hasCachedDetail =
Boolean ( 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 );
2026-04-24 13:28:38 +08:00
}, [ store ]);
2026-04-24 03:30:55 +08:00
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 00:24:11 +08:00
if ( resolvedView === "list" ) {
return (
< AiPinnedForecastView
items = { aiPinnedCities }
rows = { timeSortedRows }
detailsByName = { store . cityDetailsByName }
locale = { locale }
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
}
if ( resolvedView === "calendar" ) {
return (
< CalendarView
2026-04-24 05:09:19 +08:00
rows = { timeSortedRows }
2026-04-24 03:30:55 +08:00
locale = { locale }
selectedRowId = { selectedRowId }
2026-04-24 05:39:14 +08:00
onSelectRow = { handleSelectRow }
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 (
< div className = { clsx ( styles . root , detailChromeStyles . root , modalChromeStyles . root )}>
< div className = { clsx ( "scan-terminal" , themeMode === "light" && "light" )}>
< main className = "scan-data-grid" >
2026-04-26 02:47:24 +08:00
< div className = "scan-loading-state" role = "status" aria-live = "polite" >
< div className = "scan-loading-orb" aria-hidden = "true" >
< span />
< i />
</ div >
< div className = "scan-loading-title" >
{ isEn ? "Preparing deep analysis" : "正在准备深度分析" }
</ div >
< div className = "scan-loading-copy" >
{ isEn ? "Checking access and loading city context." : "正在检查权限并载入城市上下文。" }
</ div >
< div className = "scan-loading-steps" aria-hidden = "true" >
< span />
< span />
< span />
2026-04-24 23:32:32 +08:00
</ div >
</ div >
</ main >
</ div >
</ div >
);
}
2026-04-23 23:49:52 +08:00
return (
2026-04-24 00:13:11 +08:00
< div className = { clsx ( styles . root , detailChromeStyles . root , modalChromeStyles . root )}>
2026-04-24 05:39:14 +08:00
< div className = { clsx ( "scan-terminal" , 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-04-24 10:19:16 +08:00
< div className = "scan-topbar-title" >
2026-04-26 02:22:56 +08:00
< strong >{ isEn ? "Deep Analysis Terminal" : "深度分析台" }</ strong >
2026-04-24 10:19:16 +08:00
< span >
2026-04-26 00:24:11 +08:00
{ isEn
2026-04-26 02:22:56 +08:00
? "Click cities on the map to build a deep analysis workspace"
: "点击地图城市加入深度分析工作区,按城市查看 DEB / 模型 / METAR" }
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-04-23 23:49:52 +08:00
</ div >
</ div >
2026-04-26 00:24:11 +08:00
< AiForecastKPIBar
pinnedCount = { aiPinnedCities . length }
activeCityName = { kpiCityName }
activeDetail = { kpiDetail }
activeRow = { kpiRow }
locale = { locale }
/>
2026-04-23 23:49:52 +08:00
2026-04-24 00:13:11 +08:00
< section className = "scan-list-section" >
< div className = "scan-list-header" >
< div className = "scan-list-tabs" >
2026-04-24 03:30:55 +08:00
< button
type = "button"
className = { resolvedView === "map" ? "active" : "" }
onClick = {() => {
2026-04-26 00:24:11 +08:00
lastMapSelectedCityRef . current = normalizeCityKey ( store . selectedCity );
2026-04-24 03:30:55 +08:00
setActiveView ( "map" );
}}
>
{ isEn ? "Distribution View" : "分布视图" }
</ button >
2026-04-24 22:37:56 +08:00
< button
type = "button"
className = { resolvedView === "list" ? "active" : "" }
onClick = {() => {
setActiveView ( "list" );
}}
>
2026-04-26 02:22:56 +08:00
{ isEn ? "Deep Analysis" : "深度分析" }
2026-04-24 22:37:56 +08:00
</ button >
2026-04-24 03:30:55 +08:00
< button
type = "button"
className = { resolvedView === "calendar" ? "active" : "" }
2026-04-26 00:45:16 +08:00
title = { ! isPro ? ( isEn ? "Pro forecast calendar required" : "日历预测需 Pro" ) : undefined }
2026-04-24 03:30:55 +08:00
onClick = {() => {
2026-04-25 00:19:30 +08:00
if ( ! isPro ) {
openScanPaywall ();
return ;
}
2026-04-24 03:30:55 +08:00
setActiveView ( "calendar" );
}}
>
{ isEn ? "Calendar View" : "日历视图" }
</ button >
2026-04-24 00:13:11 +08:00
</ div >
2026-04-24 10:19:16 +08:00
< div className = "scan-list-status" >
{ terminalData ? . stale ? (
< span className = "scan-status-chip stale" >
{ isEn ? "Delayed snapshot" : "延迟快照" }
</ span >
) : 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-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-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 >
);
}