feat: Introduce premium map dashboard with dark theme, pro features, and payment processing.

This commit is contained in:
2569718930@qq.com
2026-03-13 08:15:27 +08:00
parent a5948a35d4
commit 0f51780566
9 changed files with 2388 additions and 665 deletions
+151 -124
View File
@@ -14,103 +14,102 @@ import {
getRiskBadgeLabel,
getTemperatureChartData,
} from "@/lib/dashboard-utils";
import { ChevronRight, Crown } from "lucide-react";
import { useRouter } from "next/navigation";
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],
);
const canvasRef = useChart(() => {
if (!chartData) {
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 },
},
},
},
},
data: { datasets: [], labels: [] },
type: "line",
} satisfies ChartConfiguration<"line">;
},
[chartData, detail.temp_symbol, locale],
);
}
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">
@@ -126,6 +125,7 @@ function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
export function DetailPanel() {
const store = useDashboardStore();
const router = useRouter();
const { locale, t } = useI18n();
const detail = store.selectedDetail;
const isPro = store.proAccess.subscriptionActive;
@@ -194,39 +194,40 @@ export function DetailPanel() {
<span className={clsx("risk-badge", detail?.risk?.level || "low")}>
{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={
isPro
<div className="relative group">
<button
type="button"
className={clsx("history-btn", !isPro && "pro-locked")}
title={
isPro
? t("detail.todayAnalysis")
: `${t("detail.todayAnalysis")} (Pro)`
}
onClick={() => {
blurActiveElement();
void store.openTodayModal();
}}
disabled={!detail}
>
{isPro
? t("detail.todayAnalysis")
: `${t("detail.todayAnalysis")} (Pro)`
}
onClick={() => {
blurActiveElement();
void store.openTodayModal();
}}
disabled={!detail}
>
{isPro ? t("detail.todayAnalysis") : `${t("detail.todayAnalysis")} · Pro`}
</button>
<button
type="button"
className="history-btn"
title={isPro ? t("detail.history") : `${t("detail.history")} (Pro)`}
onClick={() => {
blurActiveElement();
void store.openHistory();
}}
disabled={!detail}
>
{isPro ? t("detail.history") : `${t("detail.history")} · Pro`}
</button>
: `${t("detail.todayAnalysis")} · Pro`}
</button>
<button
type="button"
className={clsx("history-btn", !isPro && "pro-locked")}
title={
isPro ? t("detail.history") : `${t("detail.history")} (Pro)`
}
onClick={() => {
blurActiveElement();
void store.openHistory();
}}
disabled={!detail}
>
{isPro ? t("detail.history") : `${t("detail.history")} · Pro`}
</button>
</div>
</div>
</div>
</div>
@@ -268,7 +269,9 @@ 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>
@@ -291,9 +294,33 @@ export function DetailPanel() {
</div>
</section>
<section className="detail-section">
<section className="detail-section relative overflow-hidden rounded-2xl">
<h3>{t("detail.todayMiniTrend")}</h3>
<DetailMiniTemperatureChart detail={detail} />
<div
className={clsx(
"transition-all duration-500",
!isPro &&
"blur-md opacity-30 select-none pointer-events-none",
)}
>
<DetailMiniTemperatureChart detail={detail} />
</div>
{!isPro && (
<div className="absolute inset-x-0 bottom-0 top-[3rem] z-10 flex flex-col items-center justify-center p-4 bg-gradient-to-t from-[#0b0f1a] via-transparent to-transparent">
<div className="bg-blue-600/90 text-white text-[10px] font-bold px-3 py-1.5 rounded-full shadow-lg shadow-blue-600/40 flex items-center gap-1.5 mb-2">
<Crown size={10} fill="currentColor" /> Pro Feature
</div>
<p className="text-[10px] text-slate-400 text-center mb-3">
</p>
<button
onClick={() => router.push("/account")}
className="text-[10px] font-bold text-blue-400 hover:text-blue-300 transition-colors flex items-center gap-1"
>
<ChevronRight size={10} />
</button>
</div>
)}
</section>
<ForecastTable />