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 isPro = store.proAccess.subscriptionActive;
const isProLoading = store.proAccess.loading;
const hasModalContext = Boolean(detail && dateStr);
const [showDeferredTodaySections, setShowDeferredTodaySections] =
useState(false);
const [freshMarketScan, setFreshMarketScan] = useState<MarketScan | null>(
null,
);
if (!detail || !dateStr) return null;
useEffect(() => {
if (!hasModalContext) {
setShowDeferredTodaySections(false);
return;
}
setShowDeferredTodaySections(false);
if (typeof window === "undefined") {
setShowDeferredTodaySections(true);
@@ -750,23 +753,23 @@ export function FutureForecastModal() {
clearTimeout(timeoutId);
}
};
}, [dateStr, detail]);
}, [dateStr, detail, hasModalContext]);
const isToday =
store.forecastModalMode === "today" ||
(store.forecastModalMode == null && dateStr === detail.local_date);
const detailDepth = detail.detail_depth || "full";
(store.forecastModalMode == null && dateStr === detail?.local_date);
const detailDepth = detail?.detail_depth || "full";
const isFullDetailReady = detailDepth === "full";
const isStructureSyncing =
store.loadingState.futureDeep || !isFullDetailReady;
const isAnyLayerSyncing = isStructureSyncing;
const isTodayBlockingRefresh = isToday && isStructureSyncing;
const activeMarketScan = freshMarketScan || detail.market_scan || null;
const activeMarketScan = freshMarketScan || detail?.market_scan || null;
useEffect(() => {
setFreshMarketScan(null);
if (!isToday || !isFullDetailReady || !isPro) return;
const cityName = String(detail.name || detail.display_name || "").trim();
if (!hasModalContext || !isToday || !isFullDetailReady || !isPro) return;
const cityName = String(detail?.name || detail?.display_name || "").trim();
if (!cityName || !dateStr) return;
let cancelled = false;
@@ -777,7 +780,7 @@ export function FutureForecastModal() {
.getCityMarketScan(cityName, {
force: false,
lite: false,
marketSlug: detail.market_scan?.primary_market?.slug || null,
marketSlug: detail?.market_scan?.primary_market?.slug || null,
targetDate: dateStr,
})
.then((payload) => {
@@ -810,16 +813,19 @@ export function FutureForecastModal() {
};
}, [
dateStr,
detail.display_name,
detail.local_date,
detail.market_scan?.primary_market?.slug,
detail.name,
detail.updated_at,
detail?.display_name,
detail?.local_date,
detail?.market_scan?.primary_market?.slug,
detail?.name,
detail?.updated_at,
hasModalContext,
isFullDetailReady,
isPro,
isToday,
]);
if (!detail || !dateStr) return null;
const view = getFutureModalView(detail, dateStr, locale);
const scorePosition = `${50 + view.front.score / 2}%`;
const barStyle = {
@@ -34,6 +34,7 @@ import {
import { I18nProvider, useI18n } from "@/hooks/useI18n";
import { dashboardClient } from "@/lib/dashboard-client";
import type {
CityDetail,
DistributionPreviewPoint,
MarketScan,
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({
row,
marketScan,
@@ -788,6 +905,8 @@ function ScanTerminalScreen() {
}, [selectedRow, detailByRowId]);
const selectedDetail = selectedRow ? detailByRowId[selectedRow.id] : null;
const mapFocusedDetail = store.selectedDetail;
const mapFocusedCity = store.selectedCity;
const resolvedView: ContentView =
activeSection === "markets"
? "map"
@@ -982,11 +1101,15 @@ function ScanTerminalScreen() {
</section>
</main>
<DetailPanel
row={selectedRow}
marketScan={selectedDetail}
loading={detailLoadingId === selectedRow?.id}
/>
{resolvedView === "map" && mapFocusedCity ? (
<MapCityDetailPanel detail={mapFocusedDetail} />
) : (
<DetailPanel
row={selectedRow}
marketScan={selectedDetail}
loading={detailLoadingId === selectedRow?.id}
/>
)}
</div>
</div>
);