Add map city detail panel and guard forecast modal

This commit is contained in:
2569718930@qq.com
2026-04-24 04:34:33 +08:00
parent 34594e6661
commit 1f87e19cb0
2 changed files with 148 additions and 19 deletions
@@ -711,15 +711,18 @@ export function FutureForecastModal() {
const dateStr = store.futureModalDate; const dateStr = store.futureModalDate;
const isPro = store.proAccess.subscriptionActive; const isPro = store.proAccess.subscriptionActive;
const isProLoading = store.proAccess.loading; const isProLoading = store.proAccess.loading;
const hasModalContext = Boolean(detail && dateStr);
const [showDeferredTodaySections, setShowDeferredTodaySections] = const [showDeferredTodaySections, setShowDeferredTodaySections] =
useState(false); useState(false);
const [freshMarketScan, setFreshMarketScan] = useState<MarketScan | null>( const [freshMarketScan, setFreshMarketScan] = useState<MarketScan | null>(
null, null,
); );
if (!detail || !dateStr) return null;
useEffect(() => { useEffect(() => {
if (!hasModalContext) {
setShowDeferredTodaySections(false);
return;
}
setShowDeferredTodaySections(false); setShowDeferredTodaySections(false);
if (typeof window === "undefined") { if (typeof window === "undefined") {
setShowDeferredTodaySections(true); setShowDeferredTodaySections(true);
@@ -750,23 +753,23 @@ export function FutureForecastModal() {
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }
}; };
}, [dateStr, detail]); }, [dateStr, detail, hasModalContext]);
const isToday = const isToday =
store.forecastModalMode === "today" || store.forecastModalMode === "today" ||
(store.forecastModalMode == null && dateStr === detail.local_date); (store.forecastModalMode == null && dateStr === detail?.local_date);
const detailDepth = detail.detail_depth || "full"; const detailDepth = detail?.detail_depth || "full";
const isFullDetailReady = detailDepth === "full"; const isFullDetailReady = detailDepth === "full";
const isStructureSyncing = const isStructureSyncing =
store.loadingState.futureDeep || !isFullDetailReady; store.loadingState.futureDeep || !isFullDetailReady;
const isAnyLayerSyncing = isStructureSyncing; const isAnyLayerSyncing = isStructureSyncing;
const isTodayBlockingRefresh = isToday && isStructureSyncing; const isTodayBlockingRefresh = isToday && isStructureSyncing;
const activeMarketScan = freshMarketScan || detail.market_scan || null; const activeMarketScan = freshMarketScan || detail?.market_scan || null;
useEffect(() => { useEffect(() => {
setFreshMarketScan(null); setFreshMarketScan(null);
if (!isToday || !isFullDetailReady || !isPro) return; if (!hasModalContext || !isToday || !isFullDetailReady || !isPro) return;
const cityName = String(detail.name || detail.display_name || "").trim(); const cityName = String(detail?.name || detail?.display_name || "").trim();
if (!cityName || !dateStr) return; if (!cityName || !dateStr) return;
let cancelled = false; let cancelled = false;
@@ -777,7 +780,7 @@ export function FutureForecastModal() {
.getCityMarketScan(cityName, { .getCityMarketScan(cityName, {
force: false, force: false,
lite: false, lite: false,
marketSlug: detail.market_scan?.primary_market?.slug || null, marketSlug: detail?.market_scan?.primary_market?.slug || null,
targetDate: dateStr, targetDate: dateStr,
}) })
.then((payload) => { .then((payload) => {
@@ -810,16 +813,19 @@ export function FutureForecastModal() {
}; };
}, [ }, [
dateStr, dateStr,
detail.display_name, detail?.display_name,
detail.local_date, detail?.local_date,
detail.market_scan?.primary_market?.slug, detail?.market_scan?.primary_market?.slug,
detail.name, detail?.name,
detail.updated_at, detail?.updated_at,
hasModalContext,
isFullDetailReady, isFullDetailReady,
isPro, isPro,
isToday, isToday,
]); ]);
if (!detail || !dateStr) return null;
const view = getFutureModalView(detail, dateStr, locale); const view = getFutureModalView(detail, dateStr, locale);
const scorePosition = `${50 + view.front.score / 2}%`; const scorePosition = `${50 + view.front.score / 2}%`;
const barStyle = { const barStyle = {
@@ -34,6 +34,7 @@ import {
import { I18nProvider, useI18n } from "@/hooks/useI18n"; import { I18nProvider, useI18n } from "@/hooks/useI18n";
import { dashboardClient } from "@/lib/dashboard-client"; import { dashboardClient } from "@/lib/dashboard-client";
import type { import type {
CityDetail,
DistributionPreviewPoint, DistributionPreviewPoint,
MarketScan, MarketScan,
PrimarySignal, PrimarySignal,
@@ -238,6 +239,122 @@ function buildComparisonBuckets(
]; ];
} }
function MapCityDetailPanel({ detail }: { detail: CityDetail | null }) {
const { locale } = useI18n();
const isEn = locale === "en-US";
if (!detail) {
return (
<aside className="scan-detail-panel">
<div className="scan-empty-state">
<div className="scan-empty-title">
{isEn ? "Pick a city" : "选择一个城市"}
</div>
<div className="scan-empty-copy">
{isEn ? "The right rail will show city context from the map." : "右侧会展示地图里当前城市的信息。"}
</div>
</div>
</aside>
);
}
const cityName = getLocalizedCityName(
detail.name,
detail.display_name || detail.name,
locale,
);
const tempSymbol = detail.temp_symbol || "°C";
const currentTemp = detail.current?.temp;
const dayHigh =
detail.current?.max_so_far ??
detail.airport_current?.max_so_far ??
detail.airport_primary?.max_so_far ??
null;
const airportLabel = getLocalizedAirportName(
detail.name,
detail.risk?.airport || detail.risk?.icao || "",
locale,
);
const peakLabel =
Number.isFinite(Number(detail.peak?.first_h)) && Number.isFinite(Number(detail.peak?.last_h))
? `${String(Number(detail.peak?.first_h)).padStart(2, "0")}:00-${String(
Number(detail.peak?.last_h) + 1,
).padStart(2, "0")}:00`
: "--";
return (
<aside className="scan-detail-panel">
<div className="scan-detail-header">
<div className="scan-detail-top">
<div className="scan-detail-title-wrap">
<div className="scan-detail-city-name">{cityName}</div>
<div className="scan-detail-city-sub">
{airportLabel || (isEn ? "Airport anchor pending" : "机场锚点待确认")}
</div>
</div>
</div>
</div>
<div className="scan-detail-volume-row">
<div>
<div className="scan-detail-volume-big">
{currentTemp != null
? formatTemperatureValue(currentTemp, tempSymbol)
: "--"}
</div>
<div className="scan-detail-volume-caption">
{isEn ? "Current observed temperature" : "当前实测温度"}
</div>
</div>
</div>
<section className="scan-detail-section">
<div className="scan-detail-section-title">
{isEn ? "Current Context" : "当前概况"}
</div>
<div className="scan-kv-list">
<div className="scan-kv">
<span>{isEn ? "Local Time" : "当前时间"}</span>
<strong>{detail.local_time || "--"}</strong>
</div>
<div className="scan-kv">
<span>{isEn ? "Current Temp" : "当前温度"}</span>
<strong>
{currentTemp != null
? formatTemperatureValue(currentTemp, tempSymbol)
: "--"}
</strong>
</div>
<div className="scan-kv">
<span>{isEn ? "Day High (So Far)" : "今日最高(至今)"}</span>
<strong>
{dayHigh != null
? formatTemperatureValue(dayHigh, tempSymbol)
: "--"}
</strong>
</div>
<div className="scan-kv">
<span>{isEn ? "DEB Forecast" : "DEB 预测"}</span>
<strong>
{detail.deb?.prediction != null
? formatTemperatureValue(detail.deb.prediction, tempSymbol)
: "--"}
</strong>
</div>
<div className="scan-kv">
<span>{isEn ? "Peak Window" : "预计峰值"}</span>
<strong>{peakLabel}</strong>
</div>
<div className="scan-kv">
<span>{isEn ? "Airport" : "机场锚点"}</span>
<strong>{airportLabel || "--"}</strong>
</div>
</div>
</section>
</aside>
);
}
function DetailPanel({ function DetailPanel({
row, row,
marketScan, marketScan,
@@ -788,6 +905,8 @@ function ScanTerminalScreen() {
}, [selectedRow, detailByRowId]); }, [selectedRow, detailByRowId]);
const selectedDetail = selectedRow ? detailByRowId[selectedRow.id] : null; const selectedDetail = selectedRow ? detailByRowId[selectedRow.id] : null;
const mapFocusedDetail = store.selectedDetail;
const mapFocusedCity = store.selectedCity;
const resolvedView: ContentView = const resolvedView: ContentView =
activeSection === "markets" activeSection === "markets"
? "map" ? "map"
@@ -982,11 +1101,15 @@ function ScanTerminalScreen() {
</section> </section>
</main> </main>
<DetailPanel {resolvedView === "map" && mapFocusedCity ? (
row={selectedRow} <MapCityDetailPanel detail={mapFocusedDetail} />
marketScan={selectedDetail} ) : (
loading={detailLoadingId === selectedRow?.id} <DetailPanel
/> row={selectedRow}
marketScan={selectedDetail}
loading={detailLoadingId === selectedRow?.id}
/>
)}
</div> </div>
</div> </div>
); );