Optimize frontend performance and add route-level web vitals tracking
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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] || {},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 积分 & 7天Pro
|
||||
</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 积分 & 3天Pro
|
||||
</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 积分 & 7天Pro
|
||||
</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 积分 & 3天Pro
|
||||
</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 />;
|
||||
}
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
@@ -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');
|
||||
Reference in New Issue
Block a user