Optimize frontend performance and add route-level web vitals tracking

This commit is contained in:
2569718930@qq.com
2026-03-18 20:38:43 +08:00
parent f0fcb1e379
commit 69f24ddc08
21 changed files with 503 additions and 1386 deletions
View File
View File
+2 -3
View File
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import { I18nProvider } from "@/hooks/useI18n";
import { AccountCenter } from "@/components/account/AccountCenter";
import { AccountEntry } from "@/components/account/AccountEntry";
export const metadata: Metadata = {
title: "PolyWeather | Account Center",
@@ -10,8 +10,7 @@ export const metadata: Metadata = {
export default function AccountPage() {
return (
<I18nProvider>
<AccountCenter />
<AccountEntry />
</I18nProvider>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { NextResponse } from "next/server";
import {
getVitalsSummary,
normalizeMetricName,
recordVitalsSample,
} from "@/lib/vitals-store";
type VitalsPayload = {
id?: string;
metric?: string;
navigationType?: string;
pathname?: string;
rating?: string;
value?: number;
};
export async function POST(request: Request) {
try {
const payload = (await request.json()) as VitalsPayload;
const metric = normalizeMetricName(payload.metric);
if (!metric) {
return NextResponse.json({ ok: false, error: "metric is required" }, { status: 400 });
}
const pathname = String(payload.pathname || "/");
const rating = String(payload.rating || "unknown");
const value = Number(payload.value);
const navigationType = String(payload.navigationType || "unknown");
const id = String(payload.id || "");
const ts = Date.now();
if (!Number.isFinite(value)) {
return NextResponse.json({ ok: false, error: "value must be finite" }, { status: 400 });
}
recordVitalsSample({
id,
metric,
navigationType,
pathname,
rating,
timestamp: ts,
value,
});
// Keep this lightweight: log for now, can be wired to a persistent sink later.
console.info(
`[vitals] metric=${metric} path=${pathname} value=${Number.isFinite(value) ? value : "NaN"} rating=${rating} nav=${navigationType} id=${id}`,
);
return NextResponse.json({ ok: true });
} catch (error) {
console.warn("[vitals] failed to parse payload", error);
return NextResponse.json({ ok: false }, { status: 400 });
}
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const targetRoute = String(searchParams.get("route") || "").trim();
const summary = getVitalsSummary();
if (!targetRoute) {
return NextResponse.json({ ok: true, ...summary });
}
return NextResponse.json({
ok: true,
generatedAt: summary.generatedAt,
route: targetRoute,
metrics: summary.routes[targetRoute] || {},
});
}
+2 -4
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Analytics } from "@vercel/analytics/react";
import { SpeedInsights } from "@vercel/speed-insights/next";
import { WebVitalsReporter } from "@/components/observability/WebVitalsReporter";
import "./globals.css";
export const metadata: Metadata = {
@@ -25,10 +26,6 @@ export default function RootLayout({
return (
<html lang="zh-CN" className="dark">
<head>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link
@@ -38,6 +35,7 @@ export default function RootLayout({
</head>
<body className="min-h-screen font-sans antialiased">
{children}
<WebVitalsReporter />
<Analytics />
<SpeedInsights />
</body>
+95 -45
View File
@@ -612,10 +612,42 @@ export function AccountCenter() {
const [paymentError, setPaymentError] = useState("");
const [lastIntentId, setLastIntentId] = useState("");
const [lastTxHash, setLastTxHash] = useState("");
const [showSecondarySections, setShowSecondarySections] = useState(false);
const supabaseReady = hasSupabasePublicEnv();
const walletConnectEnabled = Boolean(WALLETCONNECT_PROJECT_ID);
useEffect(() => {
let canceled = false;
let timeoutId: number | null = null;
let idleId: number | null = null;
const win = typeof window !== "undefined" ? (window as any) : null;
const reveal = () => {
if (!canceled) {
setShowSecondarySections(true);
}
};
if (win && typeof win.requestIdleCallback === "function") {
idleId = win.requestIdleCallback(reveal, { timeout: 320 });
} else if (typeof window !== "undefined") {
timeoutId = window.setTimeout(reveal, 140);
} else {
setShowSecondarySections(true);
}
return () => {
canceled = true;
if (win && idleId != null && typeof win.cancelIdleCallback === "function") {
win.cancelIdleCallback(idleId);
}
if (timeoutId != null && typeof window !== "undefined") {
window.clearTimeout(timeoutId);
}
};
}, []);
/**
* Returns a valid access token, refreshing the session if the stored one
* is missing or close to expiry. Throws if the user is not authenticated.
@@ -1826,55 +1858,66 @@ export function AccountCenter() {
</div>
{/* Weekly Ranking Motivation */}
<div className="lg:col-span-4 bg-gradient-to-br from-indigo-600/20 to-purple-600/20 border border-indigo-500/30 rounded-[2.5rem] p-6 flex flex-col justify-between shadow-xl">
<div>
<h3 className="text-lg font-bold flex items-center gap-2 text-white mb-6">
<Sparkles size={20} className="text-yellow-400" />{" "}
{copy.weeklyRewards}
</h3>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 bg-white/5 rounded-xl border border-white/5">
<span className="text-sm flex items-center gap-2">
<div className="w-5 h-5 bg-yellow-500 rounded text-black font-bold text-[10px] flex items-center justify-center">
1
</div>{" "}
Top 1
</span>
<span className="text-xs font-bold text-yellow-500">
+500 & 7Pro
</span>
</div>
<div className="flex items-center justify-between p-3 bg-white/5 rounded-xl border border-white/5">
<span className="text-sm flex items-center gap-2">
<div className="w-5 h-5 bg-slate-300 rounded text-black font-bold text-[10px] flex items-center justify-center">
2
</div>{" "}
Top 2-3
</span>
<span className="text-xs font-bold text-slate-300">
+300 & 3Pro
</span>
</div>
<div className="flex items-center justify-between p-3 bg-white/5 rounded-xl border border-white/5">
<span className="text-sm flex items-center gap-2">
<div className="w-5 h-5 bg-orange-800 rounded text-white font-bold text-[10px] flex items-center justify-center">
4
</div>{" "}
Top 4-10
</span>
<span className="text-xs font-bold text-orange-400">
+150
</span>
{showSecondarySections ? (
<div className="lg:col-span-4 bg-gradient-to-br from-indigo-600/20 to-purple-600/20 border border-indigo-500/30 rounded-[2.5rem] p-6 flex flex-col justify-between shadow-xl">
<div>
<h3 className="text-lg font-bold flex items-center gap-2 text-white mb-6">
<Sparkles size={20} className="text-yellow-400" />{" "}
{copy.weeklyRewards}
</h3>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 bg-white/5 rounded-xl border border-white/5">
<span className="text-sm flex items-center gap-2">
<div className="w-5 h-5 bg-yellow-500 rounded text-black font-bold text-[10px] flex items-center justify-center">
1
</div>{" "}
Top 1
</span>
<span className="text-xs font-bold text-yellow-500">
+500 & 7Pro
</span>
</div>
<div className="flex items-center justify-between p-3 bg-white/5 rounded-xl border border-white/5">
<span className="text-sm flex items-center gap-2">
<div className="w-5 h-5 bg-slate-300 rounded text-black font-bold text-[10px] flex items-center justify-center">
2
</div>{" "}
Top 2-3
</span>
<span className="text-xs font-bold text-slate-300">
+300 & 3Pro
</span>
</div>
<div className="flex items-center justify-between p-3 bg-white/5 rounded-xl border border-white/5">
<span className="text-sm flex items-center gap-2">
<div className="w-5 h-5 bg-orange-800 rounded text-white font-bold text-[10px] flex items-center justify-center">
4
</div>{" "}
Top 4-10
</span>
<span className="text-xs font-bold text-orange-400">
+150
</span>
</div>
</div>
</div>
<div className="mt-6 flex items-start gap-2 p-3 bg-black/20 rounded-xl">
<Info size={14} className="text-slate-500 mt-0.5 shrink-0" />
<p className="text-[10px] text-slate-500 leading-normal italic">
</p>
</div>
</div>
<div className="mt-6 flex items-start gap-2 p-3 bg-black/20 rounded-xl">
<Info size={14} className="text-slate-500 mt-0.5 shrink-0" />
<p className="text-[10px] text-slate-500 leading-normal italic">
</p>
) : (
<div className="lg:col-span-4 rounded-[2.5rem] border border-white/10 bg-white/5 p-6">
<div className="h-6 w-40 animate-pulse rounded bg-slate-800/80" />
<div className="mt-4 space-y-2">
<div className="h-12 animate-pulse rounded-xl bg-slate-800/60" />
<div className="h-12 animate-pulse rounded-xl bg-slate-800/60" />
<div className="h-12 animate-pulse rounded-xl bg-slate-800/60" />
</div>
</div>
</div>
)}
{/* Subscription Info & Paywall */}
<div className="lg:col-span-12 relative">
@@ -1966,6 +2009,7 @@ export function AccountCenter() {
</div>
{/* Telegram Bot Section */}
{showSecondarySections ? (
<div className="lg:col-span-12 grid grid-cols-1 md:flex gap-6">
<section className="flex-1 bg-white/5 border border-white/10 rounded-[2rem] p-8 relative overflow-hidden group">
<Bot
@@ -2142,6 +2186,12 @@ export function AccountCenter() {
</div>
</section>
</div>
) : (
<div className="lg:col-span-12 grid grid-cols-1 gap-6 md:grid-cols-3">
<div className="md:col-span-2 h-48 animate-pulse rounded-[2rem] border border-white/10 bg-white/5" />
<div className="h-48 animate-pulse rounded-[2rem] border border-white/10 bg-white/5" />
</div>
)}
</main>
<footer className="mt-16 text-center text-slate-600 text-[10px] uppercase tracking-[0.3em] font-mono z-10 pb-8">
@@ -0,0 +1,29 @@
"use client";
import dynamic from "next/dynamic";
const AccountCenter = dynamic(
() =>
import("@/components/account/AccountCenter").then(
(module) => module.AccountCenter,
),
{
ssr: false,
loading: () => (
<div className="min-h-screen bg-slate-950 text-slate-300">
<div className="mx-auto w-full max-w-6xl px-6 py-10">
<div className="h-7 w-48 animate-pulse rounded bg-slate-800/80" />
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="h-32 animate-pulse rounded-3xl bg-slate-800/70 md:col-span-2" />
<div className="h-32 animate-pulse rounded-3xl bg-slate-800/70" />
</div>
<div className="mt-6 h-72 animate-pulse rounded-3xl bg-slate-800/60" />
</div>
</div>
),
},
);
export function AccountEntry() {
return <AccountCenter />;
}
+19 -14
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { startTransition, useEffect, useMemo, useState } from "react";
import clsx from "clsx";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
@@ -57,18 +57,19 @@ export function CitySidebar() {
Record<RiskGroupKey, boolean>
>(DEFAULT_EXPANDED_GROUPS);
const sortedCities = useMemo(() => [...store.cities].sort((a, b) => {
const aSelected = a.name === selectedCity;
const bSelected = b.name === selectedCity;
if (aSelected !== bSelected) return aSelected ? -1 : 1;
const aGroup = toRiskGroup(a.risk_level);
const bGroup = toRiskGroup(b.risk_level);
return (
(riskOrder[aGroup] ?? 3) -
(riskOrder[bGroup] ?? 3) ||
a.display_name.localeCompare(b.display_name)
);
}), [store.cities, selectedCity]);
const sortedCities = useMemo(
() =>
[...store.cities].sort((a, b) => {
const aGroup = toRiskGroup(a.risk_level);
const bGroup = toRiskGroup(b.risk_level);
return (
(riskOrder[aGroup] ?? 3) -
(riskOrder[bGroup] ?? 3) ||
a.display_name.localeCompare(b.display_name)
);
}),
[store.cities],
);
const groupedCities = useMemo(() => {
const groups: Record<RiskGroupKey, CityListItem[]> = {
@@ -170,7 +171,11 @@ export function CitySidebar() {
key={city.name}
type="button"
className={clsx("city-item", isActive && "active")}
onClick={() => void store.selectCity(city.name)}
onClick={() =>
startTransition(() => {
void store.selectCity(city.name);
})
}
>
<div className="city-item-main">
<span className={clsx("risk-dot", city.risk_level)} />
+53 -6
View File
@@ -1,8 +1,8 @@
"use client";
import { ChartConfiguration } from "chart.js/auto";
import type { ChartConfiguration } from "chart.js";
import clsx from "clsx";
import { useEffect, useRef } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { ForecastTable } from "@/components/dashboard/PanelSections";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore } from "@/hooks/useDashboardStore";
@@ -17,7 +17,10 @@ import {
function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
const { locale, t } = useI18n();
const chartData = getTemperatureChartData(detail, locale);
const chartData = useMemo(
() => getTemperatureChartData(detail, locale),
[detail, locale],
);
const canvasRef = useChart(() => {
if (!chartData) {
@@ -129,6 +132,7 @@ export function DetailPanel() {
const detail = store.selectedDetail;
const isPro = store.proAccess.subscriptionActive;
const panelRef = useRef<HTMLElement | null>(null);
const [heavyContentReady, setHeavyContentReady] = useState(false);
const isOverlayOpen =
Boolean(store.futureModalDate) ||
store.historyState.isOpen ||
@@ -139,7 +143,10 @@ export function DetailPanel() {
Boolean(detail) &&
!store.loadingState.cityDetail &&
!isOverlayOpen;
const profileStats = detail ? getCityProfileStats(detail, locale) : [];
const profileStats = useMemo(
() => (detail ? getCityProfileStats(detail, locale) : []),
[detail, locale],
);
const scenery = getCityScenery(detail?.name);
const blurActiveElement = () => {
if (typeof document === "undefined") return;
@@ -170,6 +177,42 @@ export function DetailPanel() {
panel.removeAttribute("inert");
}, [isVisible]);
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]);
return (
<aside
ref={panelRef}
@@ -295,10 +338,14 @@ export function DetailPanel() {
<section className="detail-section rounded-2xl">
<h3>{t("detail.todayMiniTrend")}</h3>
<DetailMiniTemperatureChart detail={detail} />
{heavyContentReady ? (
<DetailMiniTemperatureChart detail={detail} />
) : (
<div className="detail-mini-meta">{t("detail.loading")}</div>
)}
</section>
<ForecastTable />
{heavyContentReady ? <ForecastTable /> : null}
</>
)}
</div>
@@ -12,9 +12,9 @@ import {
Wind,
} from "lucide-react";
import { ChartConfiguration } from "chart.js/auto";
import type { ChartConfiguration } from "chart.js";
import clsx from "clsx";
import { CSSProperties } from "react";
import { CSSProperties, useMemo } from "react";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
@@ -186,8 +186,10 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
const detail = store.selectedDetail;
const view = detail ? getFutureModalView(detail, dateStr, locale) : null;
const isToday = detail ? dateStr === detail.local_date : false;
const todayChartData =
detail && isToday ? getTemperatureChartData(detail, locale) : null;
const todayChartData = useMemo(
() => (detail && isToday ? getTemperatureChartData(detail, locale) : null),
[detail, isToday, locale],
);
const canvasRef = useChart(() => {
if (!detail || !view) {
@@ -1,6 +1,6 @@
"use client";
import { ChartConfiguration } from "chart.js/auto";
import type { ChartConfiguration } from "chart.js";
import { useMemo } from "react";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore, useHistoryData } from "@/hooks/useDashboardStore";
@@ -1,5 +1,7 @@
"use client";
import "leaflet/dist/leaflet.css";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useLeafletMap } from "@/hooks/useLeafletMap";
@@ -1,7 +1,8 @@
"use client";
import { ChartConfiguration } from "chart.js/auto";
import type { ChartConfiguration } from "chart.js";
import clsx from "clsx";
import { startTransition, useMemo } from "react";
import { useChart } from "@/hooks/useChart";
import { useCityData, useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
@@ -230,7 +231,10 @@ export function HeroSummary() {
export function TemperatureChart() {
const { data } = useCityData();
const { locale, t } = useI18n();
const chartData = data ? getTemperatureChartData(data, locale) : null;
const chartData = useMemo(
() => (data ? getTemperatureChartData(data, locale) : null),
[data, locale],
);
const canvasRef = useChart(() => {
if (!data || !chartData) {
@@ -403,7 +407,7 @@ export function ProbabilityDistribution({
const marketNoText = toPercent(marketNoPrice);
const isToday = !targetDate || targetDate === detail.local_date;
const marketTopBuckets = isToday ? getMarketTopBuckets(marketScan) : [];
const sortedMarketTopBuckets = (() => {
const sortedMarketTopBuckets = useMemo(() => {
const sorted = [...marketTopBuckets].sort(
(a, b) => Number(b.probability || 0) - Number(a.probability || 0),
);
@@ -417,7 +421,7 @@ export function ProbabilityDistribution({
if (deduped.length >= 4) break;
}
return deduped;
})();
}, [marketTopBuckets]);
const useMarketTopBuckets =
marketScan?.available && sortedMarketTopBuckets.length >= 2;
const topMarketBucketText = toPercent(sortedMarketTopBuckets[0]?.probability);
@@ -729,7 +733,9 @@ export function ForecastTable() {
isSelected && "selected",
)}
onClick={() => {
store.openFutureModal(day.date);
startTransition(() => {
store.openFutureModal(day.date);
});
}}
>
<div className="f-date">
@@ -0,0 +1,43 @@
"use client";
import { usePathname } from "next/navigation";
import { useReportWebVitals } from "next/web-vitals";
const TRACKED_METRICS = new Set(["INP", "LCP", "FCP"]);
export function WebVitalsReporter() {
const pathname = usePathname();
useReportWebVitals((metric) => {
if (!TRACKED_METRICS.has(metric.name)) {
return;
}
const payload = {
id: metric.id,
metric: metric.name,
navigationType: metric.navigationType,
pathname: pathname || "/",
rating: metric.rating,
value: metric.value,
};
const body = JSON.stringify(payload);
const url = "/api/vitals";
if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
const blob = new Blob([body], { type: "application/json" });
navigator.sendBeacon(url, blob);
return;
}
void fetch(url, {
body,
headers: { "Content-Type": "application/json" },
keepalive: true,
method: "POST",
});
});
return null;
}
+17 -8
View File
@@ -1,27 +1,36 @@
"use client";
import { useEffect, useRef } from "react";
import { Chart, ChartConfiguration, ChartType } from "chart.js/auto";
import type { Chart as ChartInstance, ChartConfiguration, ChartType } from "chart.js";
export function useChart<TType extends ChartType>(
createConfig: () => ChartConfiguration<TType>,
dependencies: React.DependencyList,
) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const chartRef = useRef<Chart<TType> | null>(null);
const chartRef = useRef<ChartInstance<TType> | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
let disposed = false;
const config = createConfig();
if (chartRef.current) {
chartRef.current.destroy();
chartRef.current = null;
}
const setupChart = async () => {
const { Chart } = await import("chart.js/auto");
if (disposed) return;
chartRef.current = new Chart(canvas, config);
const config = createConfig();
if (chartRef.current) {
chartRef.current.destroy();
chartRef.current = null;
}
chartRef.current = new Chart(canvas, config);
};
void setupChart();
return () => {
disposed = true;
chartRef.current?.destroy();
chartRef.current = null;
};
+62 -48
View File
@@ -216,59 +216,73 @@ export function useLeafletMap({
useEffect(() => {
const map = mapRef.current;
if (!map || !cities.length) return;
let canceled = false;
const frameId =
typeof window !== "undefined"
? window.requestAnimationFrame(() => {
if (canceled) return;
const currentMarkers = markersRef.current;
const nextMarkers: typeof currentMarkers = {};
const nextLastData: typeof lastCityDataRef.current = {};
const currentMarkers = markersRef.current;
const nextMarkers: typeof currentMarkers = {};
const nextLastData: typeof lastCityDataRef.current = {};
cities.forEach((city) => {
const detail = cityDetailsByName[city.name];
const summary = citySummariesByName[city.name];
const snapshot = detail || summary;
const existing = currentMarkers[city.name];
cities.forEach((city) => {
const detail = cityDetailsByName[city.name];
const summary = citySummariesByName[city.name];
const snapshot = detail || summary;
const existing = currentMarkers[city.name];
const currentTemp = snapshot?.current?.temp;
const currentRisk = city.risk_level;
const lastData = lastCityDataRef.current[city.name];
const dataChanged =
!lastData ||
lastData.temp !== currentTemp ||
lastData.risk !== currentRisk;
const currentTemp = snapshot?.current?.temp;
const currentRisk = city.risk_level;
const lastData = lastCityDataRef.current[city.name];
const dataChanged =
!lastData ||
lastData.temp !== currentTemp ||
lastData.risk !== currentRisk;
if (existing) {
if (dataChanged) {
existing.marker.setIcon(createMarkerIcon(city, snapshot));
}
nextMarkers[city.name] = { city, marker: existing.marker };
nextLastData[city.name] = { temp: currentTemp, risk: currentRisk };
return;
if (existing) {
if (dataChanged) {
existing.marker.setIcon(createMarkerIcon(city, snapshot));
}
nextMarkers[city.name] = { city, marker: existing.marker };
nextLastData[city.name] = { temp: currentTemp, risk: currentRisk };
return;
}
// Create new marker
const marker = L.marker([city.lat, city.lon], {
icon: createMarkerIcon(city, snapshot),
}).addTo(map);
marker.on("click", () => {
map.stop();
// Reset lastMovedCity so we can re-fly if needed
lastMovedCityRef.current = null;
onSelectCityRef.current(city.name);
});
nextMarkers[city.name] = { city, marker };
nextLastData[city.name] = { temp: currentTemp, risk: currentRisk };
});
// Cleanup removed markers
Object.entries(currentMarkers).forEach(([name, entry]) => {
if (!nextMarkers[name]) {
map.removeLayer(entry.marker);
}
});
markersRef.current = nextMarkers;
lastCityDataRef.current = nextLastData;
})
: null;
return () => {
canceled = true;
if (frameId != null && typeof window !== "undefined") {
window.cancelAnimationFrame(frameId);
}
// Create new marker
const marker = L.marker([city.lat, city.lon], {
icon: createMarkerIcon(city, snapshot),
}).addTo(map);
marker.on("click", () => {
map.stop();
// Reset lastMovedCity so we can re-fly if needed
lastMovedCityRef.current = null;
onSelectCityRef.current(city.name);
});
nextMarkers[city.name] = { city, marker };
nextLastData[city.name] = { temp: currentTemp, risk: currentRisk };
});
// Cleanup removed markers
Object.entries(currentMarkers).forEach(([name, entry]) => {
if (!nextMarkers[name]) {
map.removeLayer(entry.marker);
}
});
markersRef.current = nextMarkers;
lastCityDataRef.current = nextLastData;
};
}, [cities, cityDetailsByName, citySummariesByName]);
useEffect(() => {
+88
View File
@@ -0,0 +1,88 @@
type MetricName = "INP" | "LCP" | "FCP";
export type VitalsSample = {
id: string;
metric: MetricName;
navigationType: string;
pathname: string;
rating: string;
timestamp: number;
value: number;
};
type AggregatedMetric = {
count: number;
p75: number;
};
export type VitalsSummary = {
generatedAt: string;
routes: Record<string, Partial<Record<MetricName, AggregatedMetric>>>;
};
const MAX_SAMPLES = 3000;
const trackedMetrics = new Set<MetricName>(["INP", "LCP", "FCP"]);
declare global {
// eslint-disable-next-line no-var
var __polyweatherVitalsStore: VitalsSample[] | undefined;
}
function getStore() {
if (!globalThis.__polyweatherVitalsStore) {
globalThis.__polyweatherVitalsStore = [];
}
return globalThis.__polyweatherVitalsStore;
}
function toPercentile(values: number[], percentile: number) {
if (!values.length) return 0;
const sorted = [...values].sort((a, b) => a - b);
const position = Math.ceil(percentile * sorted.length) - 1;
const index = Math.max(0, Math.min(sorted.length - 1, position));
return sorted[index];
}
export function normalizeMetricName(input: unknown): MetricName | null {
const value = String(input || "").trim().toUpperCase() as MetricName;
return trackedMetrics.has(value) ? value : null;
}
export function recordVitalsSample(sample: VitalsSample) {
const store = getStore();
store.push(sample);
if (store.length > MAX_SAMPLES) {
store.splice(0, store.length - MAX_SAMPLES);
}
}
export function getVitalsSummary(): VitalsSummary {
const store = getStore();
const grouped: Record<string, Record<MetricName, number[]>> = {};
for (const item of store) {
const route = item.pathname || "/";
const metric = item.metric;
grouped[route] ||= { FCP: [], INP: [], LCP: [] };
grouped[route][metric].push(item.value);
}
const routes: VitalsSummary["routes"] = {};
Object.entries(grouped).forEach(([route, byMetric]) => {
const routeStats: Partial<Record<MetricName, AggregatedMetric>> = {};
(["INP", "LCP", "FCP"] as const).forEach((metric) => {
const values = byMetric[metric];
if (!values.length) return;
routeStats[metric] = {
count: values.length,
p75: Number(toPercentile(values, 0.75).toFixed(2)),
};
});
routes[route] = routeStats;
});
return {
generatedAt: new Date().toISOString(),
routes,
};
}
File diff suppressed because it is too large Load Diff
View File
View File
-130
View File
@@ -1,130 +0,0 @@
const fs = require('fs');
function replaceRegex(content, regex, replacement, label) {
const next = content.replace(regex, replacement);
if (next === content) {
console.error('No match for', label);
}
return next;
}
const appJsPath = 'E:/web/PolyWeather/frontend/public/static/app.js';
let app = fs.readFileSync(appJsPath, 'utf8');
app = replaceRegex(app, /function updateCityListInfo\(cityData\) \{[\s\S]*?\n\}/, `function updateCityListInfo(cityData) {
const cityName = cityData.name;
const cityId = cityName.replace(/\\s/g, "-");
const temp = cityData.current?.temp;
const tempEl = document.getElementById(\`temp-\${cityId}\`);
if (tempEl && temp != null) {
tempEl.textContent = \`\${temp}\${cityData.temp_symbol}\`;
tempEl.classList.add("loaded");
}
const timeEl = document.getElementById(\`time-\${cityId}\`);
if (timeEl && cityData.local_time) {
timeEl.textContent = \`🕐 \${cityData.local_time}\`;
}
const maxEl = document.getElementById(\`max-\${cityId}\`);
if (maxEl && cityData.current?.max_temp_time) {
maxEl.textContent = \`峰值 @\${cityData.current.max_temp_time}\`;
}
}`,'updateCityListInfo');
app = app.replace(/alert\(`[^`]*cityName[^`]*`\);/, 'alert(`加载 ${cityName} 数据失败:${e.message}`);');
app = replaceRegex(app, /function renderPanel\(data\) \{[\s\S]*?\n\}/, `function renderPanel(data) {
const panel = document.getElementById("panel");
if (!panel) return;
panel.classList.remove("hidden");
requestAnimationFrame(() => panel.classList.add("visible"));
const panelCityName = document.getElementById("panelCityName");
const panelLocalTime = document.getElementById("panelLocalTime");
const badge = document.getElementById("panelRiskBadge");
if (panelCityName) {
panelCityName.textContent = \`\${data.risk?.emoji || "🏙️"} \${data.display_name}\`;
}
if (panelLocalTime) {
panelLocalTime.textContent = \`🕐 \${data.local_time || "--:--"} 当地时间\`;
}
if (badge) {
badge.textContent = {
high: "🔴 高危",
medium: "🟡 中危",
low: "🟢 低危",
}[data.risk?.level] || "未知";
badge.className = \`risk-badge \${data.risk?.level || "low"}\`;
}
renderHero(data);
renderChart(data);
renderProbabilities(data);
if (!selectedForecastDate) {
selectedForecastDate = data.local_date;
}
renderModels(data);
renderForecast(data);
renderAI(data);
renderRisk(data);
}`,'renderPanel');
app = replaceRegex(app, /const METAR_WX_MAP = \{[\s\S]*?\n\};/, `const METAR_WX_MAP = {
RA: { label: "降雨", icon: "🌧️" },
"-RA": { label: "小雨", icon: "🌦️" },
"+RA": { label: "强降雨", icon: "⛈️" },
SN: { label: "降雪", icon: "❄️" },
"-SN": { label: "小雪", icon: "🌨️" },
"+SN": { label: "大雪", icon: "🌨️" },
DZ: { label: "毛毛雨", icon: "🌦️" },
FG: { label: "雾", icon: "🌫️" },
BR: { label: "薄雾", icon: "🌫️" },
HZ: { label: "霾", icon: "🌫️" },
TS: { label: "雷暴", icon: "⛈️" },
VCTS: { label: "附近雷暴", icon: "⛈️" },
SQ: { label: "飑", icon: "💨" },
GS: { label: "冰雹", icon: "🌨️" },
};`, 'METAR_WX_MAP');
app = app.replace(/return \{ label: code, icon: .*? \};/, 'return { label: code, icon: "🌤️" };');
app = replaceRegex(app, /function renderHero\(data\) \{[\s\S]*?document.getElementById\("heroWeather"\)\.innerHTML = `/, `function renderHero(data) {
const cur = data.current || {};
const sym = data.temp_symbol || "°C";
const displayTemp = cur.temp;
let weatherText = cur.cloud_desc || "未知";
let weatherIcon =
{
多云: "☁️",
阴天: "☁️",
少云: "🌤️",
散云: "☁️",
晴: "☀️",
晴朗: "☀️",
}[cur.cloud_desc] || "🌤️";
if (cur.wx_desc) {
const metarTranslation = translateMETAR(cur.wx_desc);
if (metarTranslation) {
weatherText = metarTranslation.label;
weatherIcon = metarTranslation.icon;
}
}
document.getElementById("heroWeather").innerHTML = ``, 'renderHeroPrefix');
fs.writeFileSync(appJsPath, app, 'utf8');
const pagePath = 'E:/web/PolyWeather/frontend/app/page.tsx';
let page = fs.readFileSync(pagePath, 'utf8');
page = page.replace(/legacy-v13/g, 'legacy-v14');
fs.writeFileSync(pagePath, page, 'utf8');
const htmlPath = 'E:/web/PolyWeather/frontend/public/legacy/index.html';
let html = fs.readFileSync(htmlPath, 'utf8');
html = html.replace(/legacy-v13/g, 'legacy-v14');
fs.writeFileSync(htmlPath, html, 'utf8');