2026-03-09 10:36:03 +08:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
2026-03-18 20:38:43 +08:00
|
|
|
|
import type { ChartConfiguration } from "chart.js";
|
2026-03-09 10:36:03 +08:00
|
|
|
|
import clsx from "clsx";
|
2026-03-26 01:19:56 +08:00
|
|
|
|
import { useRouter } from "next/navigation";
|
2026-03-18 20:38:43 +08:00
|
|
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
2026-03-10 04:45:40 +08:00
|
|
|
|
import { ForecastTable } from "@/components/dashboard/PanelSections";
|
2026-04-15 15:51:08 +08:00
|
|
|
|
import { useChart } from "@/hooks/useChart";
|
2026-03-09 10:36:03 +08:00
|
|
|
|
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
2026-03-10 04:45:40 +08:00
|
|
|
|
import { useI18n } from "@/hooks/useI18n";
|
2026-03-21 13:02:07 +08:00
|
|
|
|
import { getOfficialSourceLinks } from "@/lib/dashboard-official-sources";
|
2026-03-10 04:45:40 +08:00
|
|
|
|
import { CityDetail } from "@/lib/dashboard-types";
|
2026-03-31 07:15:54 +08:00
|
|
|
|
import { trackAppEvent } from "@/lib/app-analytics";
|
2026-03-30 00:09:35 +08:00
|
|
|
|
import { getTodayPolymarketUrl } from "@/lib/polymarket-market-links";
|
2026-03-09 10:36:03 +08:00
|
|
|
|
import {
|
|
|
|
|
|
getCityProfileStats,
|
|
|
|
|
|
getRiskBadgeLabel,
|
2026-03-10 04:45:40 +08:00
|
|
|
|
getTemperatureChartData,
|
2026-04-15 23:51:54 +08:00
|
|
|
|
getWeatherSummary,
|
2026-03-09 10:36:03 +08:00
|
|
|
|
} from "@/lib/dashboard-utils";
|
2026-03-10 04:45:40 +08:00
|
|
|
|
|
|
|
|
|
|
function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
|
|
|
|
|
|
const { locale, t } = useI18n();
|
2026-03-18 20:38:43 +08:00
|
|
|
|
const chartData = useMemo(
|
|
|
|
|
|
() => getTemperatureChartData(detail, locale),
|
|
|
|
|
|
[detail, locale],
|
|
|
|
|
|
);
|
2026-03-10 04:45:40 +08:00
|
|
|
|
|
2026-03-13 08:15:27 +08:00
|
|
|
|
const canvasRef = useChart(() => {
|
|
|
|
|
|
if (!chartData) {
|
2026-03-10 04:45:40 +08:00
|
|
|
|
return {
|
2026-03-13 08:15:27 +08:00
|
|
|
|
data: { datasets: [], labels: [] },
|
2026-03-10 04:45:40 +08:00
|
|
|
|
type: "line",
|
|
|
|
|
|
} satisfies ChartConfiguration<"line">;
|
2026-03-13 08:15:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const forecastPoints = chartData.datasets.hasMgmHourly
|
|
|
|
|
|
? chartData.datasets.mgmHourlyPoints
|
|
|
|
|
|
: chartData.datasets.debPast.map(
|
|
|
|
|
|
(value, index) => value ?? chartData.datasets.debFuture[index],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
data: {
|
|
|
|
|
|
datasets: [
|
|
|
|
|
|
{
|
|
|
|
|
|
borderColor: chartData.datasets.hasMgmHourly
|
|
|
|
|
|
? "rgba(250, 204, 21, 0.92)"
|
|
|
|
|
|
: "rgba(52, 211, 153, 0.86)",
|
|
|
|
|
|
borderWidth: 1.8,
|
|
|
|
|
|
data: forecastPoints,
|
|
|
|
|
|
fill: false,
|
|
|
|
|
|
label: chartData.datasets.hasMgmHourly
|
|
|
|
|
|
? locale === "en-US"
|
|
|
|
|
|
? "MGM Forecast"
|
|
|
|
|
|
: "MGM 预测"
|
|
|
|
|
|
: locale === "en-US"
|
|
|
|
|
|
? "DEB Forecast"
|
|
|
|
|
|
: "DEB 预测",
|
|
|
|
|
|
pointRadius: 0,
|
|
|
|
|
|
spanGaps: true,
|
|
|
|
|
|
tension: 0.28,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
backgroundColor: "#22d3ee",
|
|
|
|
|
|
borderColor: "#22d3ee",
|
|
|
|
|
|
borderWidth: 0,
|
|
|
|
|
|
data: chartData.datasets.metarPoints,
|
|
|
|
|
|
fill: false,
|
2026-03-17 23:15:13 +08:00
|
|
|
|
label:
|
|
|
|
|
|
chartData.observationLabel ||
|
|
|
|
|
|
(locale === "en-US" ? "METAR Observation" : "METAR 实况"),
|
2026-03-13 08:15:27 +08:00
|
|
|
|
pointHoverRadius: 6,
|
|
|
|
|
|
pointRadius: 3.8,
|
|
|
|
|
|
showLine: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
labels: chartData.times,
|
|
|
|
|
|
},
|
|
|
|
|
|
options: {
|
|
|
|
|
|
interaction: { intersect: false, mode: "index" },
|
|
|
|
|
|
maintainAspectRatio: false,
|
|
|
|
|
|
plugins: {
|
|
|
|
|
|
legend: { display: false },
|
|
|
|
|
|
tooltip: {
|
|
|
|
|
|
backgroundColor: "rgba(15, 23, 42, 0.95)",
|
|
|
|
|
|
borderColor: "rgba(34, 211, 238, 0.25)",
|
|
|
|
|
|
borderWidth: 1,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
responsive: true,
|
|
|
|
|
|
scales: {
|
|
|
|
|
|
x: {
|
|
|
|
|
|
grid: { color: "rgba(255,255,255,0.03)" },
|
|
|
|
|
|
ticks: {
|
|
|
|
|
|
callback: (_value, index) =>
|
|
|
|
|
|
typeof index === "number" && index % 4 === 0
|
|
|
|
|
|
? chartData.times[index]
|
|
|
|
|
|
: "",
|
|
|
|
|
|
color: "#64748b",
|
|
|
|
|
|
font: { size: 10 },
|
|
|
|
|
|
maxRotation: 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
y: {
|
|
|
|
|
|
grid: { color: "rgba(255,255,255,0.03)" },
|
|
|
|
|
|
max: chartData.max,
|
|
|
|
|
|
min: chartData.min,
|
|
|
|
|
|
ticks: {
|
|
|
|
|
|
callback: (value) => `${value}${detail.temp_symbol || "°C"}`,
|
|
|
|
|
|
color: "#64748b",
|
|
|
|
|
|
font: { size: 10 },
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
type: "line",
|
|
|
|
|
|
} satisfies ChartConfiguration<"line">;
|
|
|
|
|
|
}, [chartData, detail.temp_symbol, locale]);
|
2026-03-10 04:45:40 +08:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="detail-mini-chart-wrap">
|
|
|
|
|
|
<div className="detail-mini-chart">
|
|
|
|
|
|
<canvas ref={canvasRef} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="detail-mini-meta">
|
|
|
|
|
|
{chartData?.legendText || t("detail.chartLegendEmpty")}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-09 10:36:03 +08:00
|
|
|
|
|
|
|
|
|
|
export function DetailPanel() {
|
|
|
|
|
|
const store = useDashboardStore();
|
2026-03-10 04:45:40 +08:00
|
|
|
|
const { locale, t } = useI18n();
|
2026-03-26 01:19:56 +08:00
|
|
|
|
const router = useRouter();
|
2026-03-09 10:36:03 +08:00
|
|
|
|
const detail = store.selectedDetail;
|
2026-03-26 00:29:14 +08:00
|
|
|
|
const selectedCityItem = useMemo(
|
|
|
|
|
|
() =>
|
|
|
|
|
|
store.selectedCity
|
|
|
|
|
|
? store.cities.find((city) => city.name === store.selectedCity) || null
|
|
|
|
|
|
: null,
|
|
|
|
|
|
[store.cities, store.selectedCity],
|
|
|
|
|
|
);
|
2026-04-08 18:25:25 +08:00
|
|
|
|
const selectedSummary = useMemo(
|
|
|
|
|
|
() =>
|
|
|
|
|
|
store.selectedCity
|
|
|
|
|
|
? store.citySummariesByName[store.selectedCity] || null
|
|
|
|
|
|
: null,
|
|
|
|
|
|
[store.citySummariesByName, store.selectedCity],
|
|
|
|
|
|
);
|
2026-03-13 06:41:33 +08:00
|
|
|
|
const isPro = store.proAccess.subscriptionActive;
|
2026-03-26 01:19:56 +08:00
|
|
|
|
const isAuthenticated = store.proAccess.authenticated;
|
2026-04-09 20:19:07 +08:00
|
|
|
|
const isProStateLoading = store.proAccess.loading;
|
2026-03-11 11:14:03 +08:00
|
|
|
|
const panelRef = useRef<HTMLElement | null>(null);
|
2026-03-18 20:38:43 +08:00
|
|
|
|
const [heavyContentReady, setHeavyContentReady] = useState(false);
|
2026-04-15 23:51:54 +08:00
|
|
|
|
const isOverlayOpen = Boolean(store.futureModalDate) || store.historyState.isOpen;
|
|
|
|
|
|
const isVisible = store.isPanelOpen && Boolean(store.selectedCity) && !isOverlayOpen;
|
|
|
|
|
|
const hasBasicPanelContent = Boolean(detail || selectedSummary || selectedCityItem);
|
2026-03-26 00:53:38 +08:00
|
|
|
|
const panelDisplayName =
|
|
|
|
|
|
detail?.display_name ||
|
2026-04-08 18:25:25 +08:00
|
|
|
|
selectedSummary?.display_name ||
|
2026-03-26 00:53:38 +08:00
|
|
|
|
selectedCityItem?.display_name ||
|
|
|
|
|
|
store.selectedCity ||
|
|
|
|
|
|
"...";
|
2026-04-08 18:25:25 +08:00
|
|
|
|
const panelRiskLevel =
|
|
|
|
|
|
detail?.risk?.level ||
|
|
|
|
|
|
selectedSummary?.risk?.level ||
|
|
|
|
|
|
selectedCityItem?.risk_level ||
|
|
|
|
|
|
"low";
|
2026-03-18 20:38:43 +08:00
|
|
|
|
const profileStats = useMemo(
|
|
|
|
|
|
() => (detail ? getCityProfileStats(detail, locale) : []),
|
|
|
|
|
|
[detail, locale],
|
|
|
|
|
|
);
|
2026-03-21 13:02:07 +08:00
|
|
|
|
const officialLinks = useMemo(
|
|
|
|
|
|
() => (detail ? getOfficialSourceLinks(detail) : []),
|
|
|
|
|
|
[detail],
|
|
|
|
|
|
);
|
2026-03-30 00:09:35 +08:00
|
|
|
|
const marketUrl = useMemo(
|
|
|
|
|
|
() => getTodayPolymarketUrl(detail, locale),
|
|
|
|
|
|
[detail, locale],
|
|
|
|
|
|
);
|
2026-04-15 23:51:54 +08:00
|
|
|
|
const weatherSummary = detail
|
|
|
|
|
|
? getWeatherSummary(detail, locale)
|
|
|
|
|
|
: { weatherIcon: "", weatherText: "" };
|
2026-04-08 18:25:25 +08:00
|
|
|
|
const basicCurrentTemp = selectedSummary?.current?.temp;
|
|
|
|
|
|
const basicObsTime = selectedSummary?.current?.obs_time;
|
|
|
|
|
|
const basicDeb = selectedSummary?.deb?.prediction;
|
|
|
|
|
|
const basicSettlementLabel =
|
|
|
|
|
|
selectedSummary?.current?.settlement_source_label ||
|
|
|
|
|
|
selectedCityItem?.settlement_source_label ||
|
|
|
|
|
|
selectedCityItem?.settlement_source ||
|
|
|
|
|
|
(locale === "en-US" ? "Settlement source pending" : "结算口径待确认");
|
|
|
|
|
|
const basicAirportLabel =
|
|
|
|
|
|
selectedCityItem?.airport ||
|
|
|
|
|
|
selectedSummary?.icao ||
|
|
|
|
|
|
(locale === "en-US" ? "Airport pending" : "机场待确认");
|
2026-04-15 23:51:54 +08:00
|
|
|
|
const activeTempSymbol = detail?.temp_symbol || selectedSummary?.temp_symbol || "°C";
|
|
|
|
|
|
const heroCurrentTemp = detail?.current?.temp ?? basicCurrentTemp;
|
|
|
|
|
|
const heroCurrentTempText =
|
|
|
|
|
|
heroCurrentTemp != null ? `${heroCurrentTemp}${activeTempSymbol}` : "--";
|
|
|
|
|
|
const heroObsTimeLabel =
|
|
|
|
|
|
detail?.current?.obs_time ||
|
|
|
|
|
|
basicObsTime ||
|
|
|
|
|
|
(locale === "en-US" ? "Observation pending" : "观测时间待更新");
|
|
|
|
|
|
const heroDebValue = detail?.deb?.prediction ?? basicDeb;
|
|
|
|
|
|
const heroDebText =
|
|
|
|
|
|
heroDebValue != null
|
|
|
|
|
|
? `${heroDebValue}${activeTempSymbol}`
|
|
|
|
|
|
: locale === "en-US"
|
|
|
|
|
|
? "Pending"
|
|
|
|
|
|
: "待更新";
|
|
|
|
|
|
const heroSettlementLabel =
|
|
|
|
|
|
detail?.current?.settlement_source_label || basicSettlementLabel;
|
|
|
|
|
|
const heroAirportLabel = detail?.risk?.airport || basicAirportLabel;
|
|
|
|
|
|
const heroWeatherText = detail
|
|
|
|
|
|
? weatherSummary.weatherText
|
|
|
|
|
|
: locale === "en-US"
|
|
|
|
|
|
? "City snapshot"
|
|
|
|
|
|
: "城市快照";
|
|
|
|
|
|
const heroWeatherIcon = detail ? weatherSummary.weatherIcon : "";
|
|
|
|
|
|
const isBasicSummaryLoading = !detail && !selectedSummary && store.loadingState.cityDetail;
|
2026-04-09 20:19:07 +08:00
|
|
|
|
const shouldShowSyncCard =
|
|
|
|
|
|
!detail &&
|
|
|
|
|
|
(store.loadingState.cityDetail || isProStateLoading || isAuthenticated);
|
2026-04-15 23:51:54 +08:00
|
|
|
|
|
2026-03-11 11:14:03 +08:00
|
|
|
|
const blurActiveElement = () => {
|
|
|
|
|
|
if (typeof document === "undefined") return;
|
|
|
|
|
|
const active = document.activeElement;
|
|
|
|
|
|
if (active instanceof HTMLElement) {
|
|
|
|
|
|
active.blur();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-04-15 23:51:54 +08:00
|
|
|
|
|
2026-03-26 01:19:56 +08:00
|
|
|
|
const handleFeatureAccess = (feature: "today" | "history") => {
|
|
|
|
|
|
blurActiveElement();
|
|
|
|
|
|
|
2026-03-31 07:15:54 +08:00
|
|
|
|
if (!isPro) {
|
|
|
|
|
|
trackAppEvent("paywall_feature_clicked", {
|
|
|
|
|
|
entry: "detail_panel",
|
|
|
|
|
|
feature,
|
|
|
|
|
|
city: store.selectedCity,
|
|
|
|
|
|
user_state: isAuthenticated ? "logged_in" : "guest",
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-26 01:19:56 +08:00
|
|
|
|
if (isPro) {
|
|
|
|
|
|
if (feature === "today") {
|
|
|
|
|
|
void store.openTodayModal();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
void store.openHistory();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (isAuthenticated) {
|
|
|
|
|
|
router.push("/account");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (feature === "today") {
|
|
|
|
|
|
void store.openTodayModal();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
void store.openHistory();
|
|
|
|
|
|
};
|
2026-03-11 11:14:03 +08:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const panel = panelRef.current;
|
|
|
|
|
|
if (!panel) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (!isVisible) {
|
|
|
|
|
|
panel.setAttribute("inert", "");
|
2026-04-15 23:51:54 +08:00
|
|
|
|
if (typeof document !== "undefined" && panel.contains(document.activeElement)) {
|
2026-03-11 11:14:03 +08:00
|
|
|
|
const active = document.activeElement;
|
|
|
|
|
|
if (active instanceof HTMLElement) {
|
|
|
|
|
|
active.blur();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
panel.removeAttribute("inert");
|
|
|
|
|
|
}, [isVisible]);
|
2026-03-09 10:36:03 +08:00
|
|
|
|
|
2026-03-18 20:38:43 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!isVisible || !detail) {
|
|
|
|
|
|
setHeavyContentReady(false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let canceled = false;
|
|
|
|
|
|
let timeoutId: number | null = null;
|
|
|
|
|
|
let idleId: number | null = null;
|
|
|
|
|
|
const win = typeof window !== "undefined" ? (window as any) : null;
|
|
|
|
|
|
|
|
|
|
|
|
const markReady = () => {
|
|
|
|
|
|
if (!canceled) {
|
|
|
|
|
|
setHeavyContentReady(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (win && typeof win.requestIdleCallback === "function") {
|
|
|
|
|
|
idleId = win.requestIdleCallback(markReady, { timeout: 180 });
|
|
|
|
|
|
} else if (typeof window !== "undefined") {
|
|
|
|
|
|
timeoutId = window.setTimeout(markReady, 80);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setHeavyContentReady(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
canceled = true;
|
|
|
|
|
|
if (win && idleId != null && typeof win.cancelIdleCallback === "function") {
|
|
|
|
|
|
win.cancelIdleCallback(idleId);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (timeoutId != null && typeof window !== "undefined") {
|
|
|
|
|
|
window.clearTimeout(timeoutId);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [detail, isVisible]);
|
|
|
|
|
|
|
2026-03-09 10:36:03 +08:00
|
|
|
|
return (
|
2026-04-15 23:51:54 +08:00
|
|
|
|
<aside ref={panelRef} className={clsx("detail-panel", isVisible && "visible")}>
|
2026-03-09 10:36:03 +08:00
|
|
|
|
<div className="panel-header">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="panel-close"
|
2026-03-10 04:45:40 +08:00
|
|
|
|
aria-label={t("detail.closeAria")}
|
2026-03-11 11:14:03 +08:00
|
|
|
|
onClick={() => {
|
|
|
|
|
|
blurActiveElement();
|
|
|
|
|
|
store.closePanel();
|
|
|
|
|
|
}}
|
2026-03-09 10:36:03 +08:00
|
|
|
|
>
|
|
|
|
|
|
×
|
|
|
|
|
|
</button>
|
2026-04-16 00:08:29 +08:00
|
|
|
|
<div className="panel-title-area">
|
2026-04-15 23:51:54 +08:00
|
|
|
|
<div className="panel-title-stack">
|
|
|
|
|
|
<div className="panel-overline">
|
|
|
|
|
|
<span>{locale === "en-US" ? "City briefing" : "城市简报"}</span>
|
|
|
|
|
|
<span className="panel-overline-sep">•</span>
|
|
|
|
|
|
<span>{panelDisplayName.toUpperCase()}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<h2>{panelDisplayName}</h2>
|
|
|
|
|
|
</div>
|
2026-04-09 08:29:02 +08:00
|
|
|
|
{store.loadingState.cityDetail && (
|
|
|
|
|
|
<div className="panel-loading-hint" role="status" aria-live="polite">
|
|
|
|
|
|
<span className="panel-loading-spinner" aria-hidden="true" />
|
|
|
|
|
|
<span>
|
|
|
|
|
|
{locale === "en-US"
|
|
|
|
|
|
? `Syncing ${panelDisplayName}...`
|
|
|
|
|
|
: `正在同步 ${panelDisplayName}...`}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-03-09 10:36:03 +08:00
|
|
|
|
<div className="panel-meta">
|
2026-03-26 00:53:38 +08:00
|
|
|
|
<span className={clsx("risk-badge", panelRiskLevel)}>
|
|
|
|
|
|
{getRiskBadgeLabel(panelRiskLevel, locale)}
|
2026-03-09 10:36:03 +08:00
|
|
|
|
</span>
|
2026-04-15 23:51:54 +08:00
|
|
|
|
<span className="panel-meta-chip panel-meta-chip-strong">{heroSettlementLabel}</span>
|
|
|
|
|
|
<span className="panel-meta-chip panel-meta-chip-muted">{heroAirportLabel}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="panel-actions">
|
|
|
|
|
|
{marketUrl ? (
|
|
|
|
|
|
<a
|
|
|
|
|
|
className="panel-action-button panel-action-button-ghost"
|
|
|
|
|
|
href={marketUrl}
|
|
|
|
|
|
target="_blank"
|
|
|
|
|
|
rel="noreferrer"
|
2026-03-13 08:15:27 +08:00
|
|
|
|
title={
|
2026-04-15 23:51:54 +08:00
|
|
|
|
locale === "en-US"
|
|
|
|
|
|
? "Open today's Polymarket market"
|
|
|
|
|
|
: "打开今日 Polymarket 题目页"
|
2026-03-13 08:15:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
>
|
2026-04-15 23:51:54 +08:00
|
|
|
|
{locale === "en-US" ? "Market" : "市场页"}
|
|
|
|
|
|
</a>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={clsx(
|
|
|
|
|
|
"panel-action-button",
|
|
|
|
|
|
"panel-action-button-primary",
|
|
|
|
|
|
!isPro && "pro-locked",
|
|
|
|
|
|
)}
|
|
|
|
|
|
title={
|
|
|
|
|
|
isPro ? t("detail.todayAnalysis") : `${t("detail.todayAnalysis")} (Pro)`
|
|
|
|
|
|
}
|
|
|
|
|
|
onClick={() => handleFeatureAccess("today")}
|
|
|
|
|
|
disabled={!store.selectedCity}
|
|
|
|
|
|
>
|
|
|
|
|
|
{isPro ? t("detail.todayAnalysis") : `${t("detail.todayAnalysis")} · Pro`}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={clsx(
|
|
|
|
|
|
"panel-action-button",
|
|
|
|
|
|
"panel-action-button-secondary",
|
|
|
|
|
|
!isPro && "pro-locked",
|
|
|
|
|
|
)}
|
|
|
|
|
|
title={isPro ? t("detail.history") : `${t("detail.history")} (Pro)`}
|
|
|
|
|
|
onClick={() => handleFeatureAccess("history")}
|
|
|
|
|
|
disabled={!store.selectedCity}
|
|
|
|
|
|
>
|
|
|
|
|
|
{isPro ? t("detail.history") : `${t("detail.history")} · Pro`}
|
|
|
|
|
|
</button>
|
2026-03-09 10:36:03 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="panel-body">
|
2026-04-08 18:25:25 +08:00
|
|
|
|
{!hasBasicPanelContent ? (
|
2026-04-15 23:51:54 +08:00
|
|
|
|
<section className="detail-summary-shell detail-empty-state">
|
|
|
|
|
|
<div className="detail-section-head">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="detail-section-kicker">
|
|
|
|
|
|
{locale === "en-US" ? "No city selected" : "尚未选择城市"}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<h3>{locale === "en-US" ? "Pick a city to start." : "先选择一个城市。"}</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-09 10:36:03 +08:00
|
|
|
|
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>
|
2026-04-15 23:51:54 +08:00
|
|
|
|
{store.loadingState.cityDetail ? t("detail.loading") : t("detail.emptyHint")}
|
2026-03-09 10:36:03 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
2026-04-08 18:25:25 +08:00
|
|
|
|
) : !detail ? (
|
|
|
|
|
|
<>
|
2026-04-15 23:51:54 +08:00
|
|
|
|
<section className="detail-summary-shell">
|
|
|
|
|
|
<div className="detail-section-head">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="detail-section-kicker">
|
|
|
|
|
|
{locale === "en-US" ? "Quick read" : "基础快照"}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<h3>{locale === "en-US" ? "Summary before deep sync." : "先看基础结论,再等深度详情。"}</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-08 20:22:32 +08:00
|
|
|
|
{isBasicSummaryLoading ? (
|
|
|
|
|
|
<div className="detail-mini-meta">
|
2026-04-15 23:51:54 +08:00
|
|
|
|
{locale === "en-US" ? "Syncing public city snapshot..." : "正在同步城市基础信息..."}
|
2026-04-08 20:22:32 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
2026-04-15 23:51:54 +08:00
|
|
|
|
<div className="detail-summary-main">
|
|
|
|
|
|
<article className="detail-card detail-card-hero">
|
|
|
|
|
|
<span className="detail-label">{locale === "en-US" ? "Current posture" : "当前态势"}</span>
|
|
|
|
|
|
<span className="detail-summary-temp">{heroCurrentTempText}</span>
|
|
|
|
|
|
<span className="detail-summary-supporting">@{heroObsTimeLabel}</span>
|
|
|
|
|
|
<span className="detail-summary-supporting">{heroWeatherText}</span>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
<div className="detail-summary-grid">
|
|
|
|
|
|
<article className="detail-card">
|
|
|
|
|
|
<span className="detail-label">DEB</span>
|
|
|
|
|
|
<span className="detail-value">{heroDebText}</span>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
<article className="detail-card">
|
|
|
|
|
|
<span className="detail-label">{locale === "en-US" ? "Settlement" : "结算口径"}</span>
|
|
|
|
|
|
<span className="detail-value">{heroSettlementLabel}</span>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
<article className="detail-card">
|
|
|
|
|
|
<span className="detail-label">{locale === "en-US" ? "Airport" : "结算机场"}</span>
|
|
|
|
|
|
<span className="detail-value">{heroAirportLabel}</span>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
<article className="detail-card">
|
|
|
|
|
|
<span className="detail-label">{locale === "en-US" ? "Sync status" : "同步状态"}</span>
|
|
|
|
|
|
<span className="detail-value-muted">
|
|
|
|
|
|
{locale === "en-US" ? "Deep detail pending" : "深度详情补齐中"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</article>
|
2026-04-08 18:25:25 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
2026-04-15 23:51:54 +08:00
|
|
|
|
<section className="detail-summary-shell detail-summary-shell-live">
|
|
|
|
|
|
<div className="detail-section-head">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="detail-section-kicker">
|
|
|
|
|
|
{shouldShowSyncCard
|
|
|
|
|
|
? locale === "en-US"
|
|
|
|
|
|
? "Background sync"
|
|
|
|
|
|
: "后台同步"
|
|
|
|
|
|
: locale === "en-US"
|
|
|
|
|
|
? "Pro workflow"
|
|
|
|
|
|
: "Pro 工作流"}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<h3>
|
|
|
|
|
|
{shouldShowSyncCard
|
|
|
|
|
|
? locale === "en-US"
|
|
|
|
|
|
? "Deep evidence will appear automatically."
|
|
|
|
|
|
: "更深层的结算证据会自动补齐。"
|
|
|
|
|
|
: locale === "en-US"
|
|
|
|
|
|
? "Intraday and history live behind the analysis flow."
|
|
|
|
|
|
: "今日日内分析和历史对账会在分析流中继续展开。"}
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="detail-callout-card">
|
|
|
|
|
|
<span className="detail-value-muted">
|
2026-04-09 20:19:07 +08:00
|
|
|
|
{shouldShowSyncCard
|
2026-04-09 14:24:03 +08:00
|
|
|
|
? locale === "en-US"
|
2026-04-15 23:51:54 +08:00
|
|
|
|
? "Full city detail is still syncing. Nearby stations, source links, and mini trend will land here once ready."
|
|
|
|
|
|
: "完整城市详情仍在同步中。周边站点、官方参考和温度微趋势会在完成后自动出现在这里。"
|
2026-04-09 14:24:03 +08:00
|
|
|
|
: locale === "en-US"
|
2026-04-15 23:51:54 +08:00
|
|
|
|
? "Intraday analysis, history reconciliation, and deeper structure signals stay available from the action bar."
|
|
|
|
|
|
: "今日日内分析、历史对账和更深层结构信号会从上方操作区继续展开。"}
|
2026-04-08 18:25:25 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</>
|
2026-03-09 10:36:03 +08:00
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
2026-04-15 23:51:54 +08:00
|
|
|
|
<section className="detail-structured-section">
|
|
|
|
|
|
<div className="detail-section-head">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="detail-section-kicker">{locale === "en-US" ? "Profile" : "城市画像"}</div>
|
|
|
|
|
|
<h3>{t("detail.profile")}</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-09 10:36:03 +08:00
|
|
|
|
<div className="detail-grid">
|
|
|
|
|
|
{profileStats.map((item) => (
|
2026-04-15 23:51:54 +08:00
|
|
|
|
<article key={item.label} className="detail-card">
|
2026-03-09 10:36:03 +08:00
|
|
|
|
<span className="detail-label">{item.label}</span>
|
|
|
|
|
|
<span className="detail-value">{item.value}</span>
|
2026-04-15 23:51:54 +08:00
|
|
|
|
</article>
|
2026-03-09 10:36:03 +08:00
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
2026-03-21 13:02:07 +08:00
|
|
|
|
{officialLinks.length > 0 ? (
|
2026-04-15 23:51:54 +08:00
|
|
|
|
<section className="detail-structured-section">
|
|
|
|
|
|
<div className="detail-section-head">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="detail-section-kicker">
|
|
|
|
|
|
{locale === "en-US" ? "Primary references" : "官方参考"}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<h3>{locale === "en-US" ? "Settlement and observation references" : "结算与观测参考来源"}</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-21 13:02:07 +08:00
|
|
|
|
<p className="detail-source-note">
|
|
|
|
|
|
{locale === "en-US"
|
|
|
|
|
|
? "AGENCY = national meteorological service, METAR = airport observation, AIRPORT = airport official page."
|
|
|
|
|
|
: "AGENCY = 国家气象机构,METAR = 机场实测报文,AIRPORT = 机场官网页面。"}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div className="detail-source-list">
|
|
|
|
|
|
{officialLinks.map((link) => (
|
|
|
|
|
|
<a
|
|
|
|
|
|
key={`${link.label}-${link.href}`}
|
|
|
|
|
|
className="detail-source-link"
|
|
|
|
|
|
href={link.href}
|
|
|
|
|
|
target="_blank"
|
|
|
|
|
|
rel="noreferrer"
|
|
|
|
|
|
>
|
2026-04-15 23:51:54 +08:00
|
|
|
|
<span className="detail-source-kind">{link.kind.toUpperCase()}</span>
|
2026-03-21 13:02:07 +08:00
|
|
|
|
<span className="detail-source-label">{link.label}</span>
|
|
|
|
|
|
</a>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
2026-04-15 23:51:54 +08:00
|
|
|
|
<section className="detail-structured-section rounded-2xl">
|
|
|
|
|
|
<div className="detail-section-head">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="detail-section-kicker">
|
|
|
|
|
|
{locale === "en-US" ? "Mini trend" : "温度微趋势"}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<h3>{t("detail.todayMiniTrend")}</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-18 20:38:43 +08:00
|
|
|
|
{heavyContentReady ? (
|
2026-04-15 23:51:54 +08:00
|
|
|
|
<DetailMiniTemperatureChart detail={detail} />
|
2026-03-18 20:38:43 +08:00
|
|
|
|
) : (
|
|
|
|
|
|
<div className="detail-mini-meta">{t("detail.loading")}</div>
|
|
|
|
|
|
)}
|
2026-03-09 10:36:03 +08:00
|
|
|
|
</section>
|
|
|
|
|
|
|
2026-03-18 20:38:43 +08:00
|
|
|
|
{heavyContentReady ? <ForecastTable /> : null}
|
2026-03-09 10:36:03 +08:00
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|