feat: Implement the PolyWeather dashboard including frontend components, data collection, analysis, and API endpoints.

This commit is contained in:
2569718930@qq.com
2026-03-10 04:45:40 +08:00
parent 020c62676e
commit aab4477ab3
24 changed files with 2835 additions and 524 deletions
+135 -46
View File
@@ -1,18 +1,131 @@
"use client";
import { ChartConfiguration } from "chart.js/auto";
import clsx from "clsx";
import { ForecastTable } from "@/components/dashboard/PanelSections";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { getCityScenery } from "@/lib/dashboard-scenery";
import { CityDetail } from "@/lib/dashboard-types";
import {
getCityProfileStats,
getClimateDrivers,
getRiskBadgeLabel,
getSettlementRiskNarrative,
getTemperatureChartData,
} from "@/lib/dashboard-utils";
import { ForecastTable } from "@/components/dashboard/PanelSections";
function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
const { locale, t } = useI18n();
const chartData = getTemperatureChartData(detail, locale);
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: 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,
label: locale === "en-US" ? "METAR Observation" : "METAR 实测",
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],
);
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>
);
}
export function DetailPanel() {
const store = useDashboardStore();
const { locale, t } = useI18n();
const detail = store.selectedDetail;
const isOverlayOpen =
Boolean(store.futureModalDate) ||
@@ -24,9 +137,7 @@ export function DetailPanel() {
Boolean(detail) &&
!store.loadingState.cityDetail &&
!isOverlayOpen;
const profileStats = detail ? getCityProfileStats(detail) : [];
const riskLines = detail ? getSettlementRiskNarrative(detail) : [];
const climateDrivers = detail ? getClimateDrivers(detail) : [];
const profileStats = detail ? getCityProfileStats(detail, locale) : [];
const scenery = getCityScenery(detail?.name);
return (
@@ -38,39 +149,39 @@ export function DetailPanel() {
<button
type="button"
className="panel-close"
aria-label="关闭城市详情面板"
aria-label={t("detail.closeAria")}
onClick={store.closePanel}
>
×
</button>
<div className="panel-title-area">
<h2>{detail?.display_name?.toUpperCase() || ""}</h2>
<h2>{detail?.display_name?.toUpperCase() || "..."}</h2>
<div className="panel-meta">
<span className={clsx("risk-badge", detail?.risk?.level || "low")}>
{getRiskBadgeLabel(detail?.risk?.level)}
{getRiskBadgeLabel(detail?.risk?.level, locale)}
</span>
<span className="local-time">
{detail
? `${detail.local_date} ${detail.local_time}`
: "等待选择城市"}
: t("detail.waitSelect")}
</span>
<button
type="button"
className="history-btn"
title="查看今日日内分析"
onClick={store.openTodayModal}
title={t("detail.todayAnalysis")}
onClick={() => void store.openTodayModal()}
disabled={!detail}
>
{t("detail.todayAnalysis")}
</button>
<button
type="button"
className="history-btn"
title="查看历史对账"
title={t("detail.history")}
onClick={() => void store.openHistory()}
disabled={!detail}
>
{t("detail.history")}
</button>
</div>
</div>
@@ -81,8 +192,8 @@ export function DetailPanel() {
<section>
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>
{store.loadingState.cityDetail
? "正在加载城市详情..."
: "从左侧城市列表选择一个城市查看详情。"}
? t("detail.loading")
: t("detail.emptyHint")}
</div>
</section>
) : (
@@ -93,7 +204,7 @@ export function DetailPanel() {
<img
className="detail-scenery-image"
src={scenery.imageUrl}
alt={`${detail.display_name} 风景照`}
alt={t("detail.sceneryAlt", { city: detail.display_name })}
/>
<div className="detail-scenery-overlay">
<div className="detail-scenery-copy">
@@ -113,21 +224,19 @@ export function DetailPanel() {
</>
) : (
<div className="detail-scenery-fallback">
<span className="detail-scenery-kicker">
{detail.display_name}
</span>
<span className="detail-scenery-kicker">{detail.display_name}</span>
<strong className="detail-scenery-title">
{t("detail.sceneryTitle")}
</strong>
<span className="detail-scenery-subtitle">
{t("detail.sceneryFallback")}
</span>
</div>
)}
</section>
<section className="detail-section">
<h3></h3>
<h3>{t("detail.profile")}</h3>
<div className="detail-grid">
{profileStats.map((item) => (
<div key={item.label} className="detail-card">
@@ -139,30 +248,10 @@ export function DetailPanel() {
</section>
<section className="detail-section">
<h3></h3>
<div className="risk-info">
{riskLines.map((line) => (
<div key={line} className="risk-row">
<span style={{ color: "var(--accent-cyan)", opacity: 0.6 }}>
</span>
<span>{line}</span>
</div>
))}
</div>
<h3>{t("detail.todayMiniTrend")}</h3>
<DetailMiniTemperatureChart detail={detail} />
</section>
<section className="detail-section">
<h3></h3>
<div className="insight-list">
{climateDrivers.map((driver) => (
<div key={driver.label} className="insight-item">
<div className="insight-title">{driver.label}</div>
<div className="insight-text">{driver.text}</div>
</div>
))}
</div>
</section>
<ForecastTable />
</>
)}