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 type { Metadata } from "next";
|
||||||
import { I18nProvider } from "@/hooks/useI18n";
|
import { I18nProvider } from "@/hooks/useI18n";
|
||||||
import { AccountCenter } from "@/components/account/AccountCenter";
|
import { AccountEntry } from "@/components/account/AccountEntry";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "PolyWeather | Account Center",
|
title: "PolyWeather | Account Center",
|
||||||
@@ -10,8 +10,7 @@ export const metadata: Metadata = {
|
|||||||
export default function AccountPage() {
|
export default function AccountPage() {
|
||||||
return (
|
return (
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<AccountCenter />
|
<AccountEntry />
|
||||||
</I18nProvider>
|
</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 type { Metadata } from "next";
|
||||||
import { Analytics } from "@vercel/analytics/react";
|
import { Analytics } from "@vercel/analytics/react";
|
||||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
import { SpeedInsights } from "@vercel/speed-insights/next";
|
||||||
|
import { WebVitalsReporter } from "@/components/observability/WebVitalsReporter";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -25,10 +26,6 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="zh-CN" className="dark">
|
<html lang="zh-CN" className="dark">
|
||||||
<head>
|
<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.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
||||||
<link
|
<link
|
||||||
@@ -38,6 +35,7 @@ export default function RootLayout({
|
|||||||
</head>
|
</head>
|
||||||
<body className="min-h-screen font-sans antialiased">
|
<body className="min-h-screen font-sans antialiased">
|
||||||
{children}
|
{children}
|
||||||
|
<WebVitalsReporter />
|
||||||
<Analytics />
|
<Analytics />
|
||||||
<SpeedInsights />
|
<SpeedInsights />
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -612,10 +612,42 @@ export function AccountCenter() {
|
|||||||
const [paymentError, setPaymentError] = useState("");
|
const [paymentError, setPaymentError] = useState("");
|
||||||
const [lastIntentId, setLastIntentId] = useState("");
|
const [lastIntentId, setLastIntentId] = useState("");
|
||||||
const [lastTxHash, setLastTxHash] = useState("");
|
const [lastTxHash, setLastTxHash] = useState("");
|
||||||
|
const [showSecondarySections, setShowSecondarySections] = useState(false);
|
||||||
|
|
||||||
const supabaseReady = hasSupabasePublicEnv();
|
const supabaseReady = hasSupabasePublicEnv();
|
||||||
const walletConnectEnabled = Boolean(WALLETCONNECT_PROJECT_ID);
|
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
|
* 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.
|
* is missing or close to expiry. Throws if the user is not authenticated.
|
||||||
@@ -1826,55 +1858,66 @@ export function AccountCenter() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Weekly Ranking Motivation */}
|
{/* 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">
|
{showSecondarySections ? (
|
||||||
<div>
|
<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">
|
||||||
<h3 className="text-lg font-bold flex items-center gap-2 text-white mb-6">
|
<div>
|
||||||
<Sparkles size={20} className="text-yellow-400" />{" "}
|
<h3 className="text-lg font-bold flex items-center gap-2 text-white mb-6">
|
||||||
{copy.weeklyRewards}
|
<Sparkles size={20} className="text-yellow-400" />{" "}
|
||||||
</h3>
|
{copy.weeklyRewards}
|
||||||
<div className="space-y-3">
|
</h3>
|
||||||
<div className="flex items-center justify-between p-3 bg-white/5 rounded-xl border border-white/5">
|
<div className="space-y-3">
|
||||||
<span className="text-sm flex items-center gap-2">
|
<div className="flex items-center justify-between p-3 bg-white/5 rounded-xl border border-white/5">
|
||||||
<div className="w-5 h-5 bg-yellow-500 rounded text-black font-bold text-[10px] flex items-center justify-center">
|
<span className="text-sm flex items-center gap-2">
|
||||||
1
|
<div className="w-5 h-5 bg-yellow-500 rounded text-black font-bold text-[10px] flex items-center justify-center">
|
||||||
</div>{" "}
|
1
|
||||||
Top 1
|
</div>{" "}
|
||||||
</span>
|
Top 1
|
||||||
<span className="text-xs font-bold text-yellow-500">
|
</span>
|
||||||
+500 积分 & 7天Pro
|
<span className="text-xs font-bold text-yellow-500">
|
||||||
</span>
|
+500 积分 & 7天Pro
|
||||||
</div>
|
</span>
|
||||||
<div className="flex items-center justify-between p-3 bg-white/5 rounded-xl border border-white/5">
|
</div>
|
||||||
<span className="text-sm flex items-center gap-2">
|
<div className="flex items-center justify-between p-3 bg-white/5 rounded-xl border border-white/5">
|
||||||
<div className="w-5 h-5 bg-slate-300 rounded text-black font-bold text-[10px] flex items-center justify-center">
|
<span className="text-sm flex items-center gap-2">
|
||||||
2
|
<div className="w-5 h-5 bg-slate-300 rounded text-black font-bold text-[10px] flex items-center justify-center">
|
||||||
</div>{" "}
|
2
|
||||||
Top 2-3
|
</div>{" "}
|
||||||
</span>
|
Top 2-3
|
||||||
<span className="text-xs font-bold text-slate-300">
|
</span>
|
||||||
+300 积分 & 3天Pro
|
<span className="text-xs font-bold text-slate-300">
|
||||||
</span>
|
+300 积分 & 3天Pro
|
||||||
</div>
|
</span>
|
||||||
<div className="flex items-center justify-between p-3 bg-white/5 rounded-xl border border-white/5">
|
</div>
|
||||||
<span className="text-sm flex items-center gap-2">
|
<div className="flex items-center justify-between p-3 bg-white/5 rounded-xl border border-white/5">
|
||||||
<div className="w-5 h-5 bg-orange-800 rounded text-white font-bold text-[10px] flex items-center justify-center">
|
<span className="text-sm flex items-center gap-2">
|
||||||
4
|
<div className="w-5 h-5 bg-orange-800 rounded text-white font-bold text-[10px] flex items-center justify-center">
|
||||||
</div>{" "}
|
4
|
||||||
Top 4-10
|
</div>{" "}
|
||||||
</span>
|
Top 4-10
|
||||||
<span className="text-xs font-bold text-orange-400">
|
</span>
|
||||||
+150 积分
|
<span className="text-xs font-bold text-orange-400">
|
||||||
</span>
|
+150 积分
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</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>
|
||||||
<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" />
|
<div className="lg:col-span-4 rounded-[2.5rem] border border-white/10 bg-white/5 p-6">
|
||||||
<p className="text-[10px] text-slate-500 leading-normal italic">
|
<div className="h-6 w-40 animate-pulse rounded bg-slate-800/80" />
|
||||||
积分规则:群内有效发言(自动防刷检测)。每周一零点结算并重置周积分榜。
|
<div className="mt-4 space-y-2">
|
||||||
</p>
|
<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>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Subscription Info & Paywall */}
|
{/* Subscription Info & Paywall */}
|
||||||
<div className="lg:col-span-12 relative">
|
<div className="lg:col-span-12 relative">
|
||||||
@@ -1966,6 +2009,7 @@ export function AccountCenter() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Telegram Bot Section */}
|
{/* Telegram Bot Section */}
|
||||||
|
{showSecondarySections ? (
|
||||||
<div className="lg:col-span-12 grid grid-cols-1 md:flex gap-6">
|
<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">
|
<section className="flex-1 bg-white/5 border border-white/10 rounded-[2rem] p-8 relative overflow-hidden group">
|
||||||
<Bot
|
<Bot
|
||||||
@@ -2142,6 +2186,12 @@ export function AccountCenter() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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>
|
</main>
|
||||||
|
|
||||||
<footer className="mt-16 text-center text-slate-600 text-[10px] uppercase tracking-[0.3em] font-mono z-10 pb-8">
|
<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";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { startTransition, useEffect, useMemo, useState } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||||
import { useI18n } from "@/hooks/useI18n";
|
import { useI18n } from "@/hooks/useI18n";
|
||||||
@@ -57,18 +57,19 @@ export function CitySidebar() {
|
|||||||
Record<RiskGroupKey, boolean>
|
Record<RiskGroupKey, boolean>
|
||||||
>(DEFAULT_EXPANDED_GROUPS);
|
>(DEFAULT_EXPANDED_GROUPS);
|
||||||
|
|
||||||
const sortedCities = useMemo(() => [...store.cities].sort((a, b) => {
|
const sortedCities = useMemo(
|
||||||
const aSelected = a.name === selectedCity;
|
() =>
|
||||||
const bSelected = b.name === selectedCity;
|
[...store.cities].sort((a, b) => {
|
||||||
if (aSelected !== bSelected) return aSelected ? -1 : 1;
|
const aGroup = toRiskGroup(a.risk_level);
|
||||||
const aGroup = toRiskGroup(a.risk_level);
|
const bGroup = toRiskGroup(b.risk_level);
|
||||||
const bGroup = toRiskGroup(b.risk_level);
|
return (
|
||||||
return (
|
(riskOrder[aGroup] ?? 3) -
|
||||||
(riskOrder[aGroup] ?? 3) -
|
(riskOrder[bGroup] ?? 3) ||
|
||||||
(riskOrder[bGroup] ?? 3) ||
|
a.display_name.localeCompare(b.display_name)
|
||||||
a.display_name.localeCompare(b.display_name)
|
);
|
||||||
);
|
}),
|
||||||
}), [store.cities, selectedCity]);
|
[store.cities],
|
||||||
|
);
|
||||||
|
|
||||||
const groupedCities = useMemo(() => {
|
const groupedCities = useMemo(() => {
|
||||||
const groups: Record<RiskGroupKey, CityListItem[]> = {
|
const groups: Record<RiskGroupKey, CityListItem[]> = {
|
||||||
@@ -170,7 +171,11 @@ export function CitySidebar() {
|
|||||||
key={city.name}
|
key={city.name}
|
||||||
type="button"
|
type="button"
|
||||||
className={clsx("city-item", isActive && "active")}
|
className={clsx("city-item", isActive && "active")}
|
||||||
onClick={() => void store.selectCity(city.name)}
|
onClick={() =>
|
||||||
|
startTransition(() => {
|
||||||
|
void store.selectCity(city.name);
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="city-item-main">
|
<div className="city-item-main">
|
||||||
<span className={clsx("risk-dot", city.risk_level)} />
|
<span className={clsx("risk-dot", city.risk_level)} />
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChartConfiguration } from "chart.js/auto";
|
import type { ChartConfiguration } from "chart.js";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ForecastTable } from "@/components/dashboard/PanelSections";
|
import { ForecastTable } from "@/components/dashboard/PanelSections";
|
||||||
import { useChart } from "@/hooks/useChart";
|
import { useChart } from "@/hooks/useChart";
|
||||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||||
@@ -17,7 +17,10 @@ import {
|
|||||||
|
|
||||||
function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
|
function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
|
||||||
const { locale, t } = useI18n();
|
const { locale, t } = useI18n();
|
||||||
const chartData = getTemperatureChartData(detail, locale);
|
const chartData = useMemo(
|
||||||
|
() => getTemperatureChartData(detail, locale),
|
||||||
|
[detail, locale],
|
||||||
|
);
|
||||||
|
|
||||||
const canvasRef = useChart(() => {
|
const canvasRef = useChart(() => {
|
||||||
if (!chartData) {
|
if (!chartData) {
|
||||||
@@ -129,6 +132,7 @@ export function DetailPanel() {
|
|||||||
const detail = store.selectedDetail;
|
const detail = store.selectedDetail;
|
||||||
const isPro = store.proAccess.subscriptionActive;
|
const isPro = store.proAccess.subscriptionActive;
|
||||||
const panelRef = useRef<HTMLElement | null>(null);
|
const panelRef = useRef<HTMLElement | null>(null);
|
||||||
|
const [heavyContentReady, setHeavyContentReady] = useState(false);
|
||||||
const isOverlayOpen =
|
const isOverlayOpen =
|
||||||
Boolean(store.futureModalDate) ||
|
Boolean(store.futureModalDate) ||
|
||||||
store.historyState.isOpen ||
|
store.historyState.isOpen ||
|
||||||
@@ -139,7 +143,10 @@ export function DetailPanel() {
|
|||||||
Boolean(detail) &&
|
Boolean(detail) &&
|
||||||
!store.loadingState.cityDetail &&
|
!store.loadingState.cityDetail &&
|
||||||
!isOverlayOpen;
|
!isOverlayOpen;
|
||||||
const profileStats = detail ? getCityProfileStats(detail, locale) : [];
|
const profileStats = useMemo(
|
||||||
|
() => (detail ? getCityProfileStats(detail, locale) : []),
|
||||||
|
[detail, locale],
|
||||||
|
);
|
||||||
const scenery = getCityScenery(detail?.name);
|
const scenery = getCityScenery(detail?.name);
|
||||||
const blurActiveElement = () => {
|
const blurActiveElement = () => {
|
||||||
if (typeof document === "undefined") return;
|
if (typeof document === "undefined") return;
|
||||||
@@ -170,6 +177,42 @@ export function DetailPanel() {
|
|||||||
panel.removeAttribute("inert");
|
panel.removeAttribute("inert");
|
||||||
}, [isVisible]);
|
}, [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 (
|
return (
|
||||||
<aside
|
<aside
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
@@ -295,10 +338,14 @@ export function DetailPanel() {
|
|||||||
|
|
||||||
<section className="detail-section rounded-2xl">
|
<section className="detail-section rounded-2xl">
|
||||||
<h3>{t("detail.todayMiniTrend")}</h3>
|
<h3>{t("detail.todayMiniTrend")}</h3>
|
||||||
<DetailMiniTemperatureChart detail={detail} />
|
{heavyContentReady ? (
|
||||||
|
<DetailMiniTemperatureChart detail={detail} />
|
||||||
|
) : (
|
||||||
|
<div className="detail-mini-meta">{t("detail.loading")}</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<ForecastTable />
|
{heavyContentReady ? <ForecastTable /> : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import {
|
|||||||
Wind,
|
Wind,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { ChartConfiguration } from "chart.js/auto";
|
import type { ChartConfiguration } from "chart.js";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { CSSProperties } from "react";
|
import { CSSProperties, useMemo } from "react";
|
||||||
import { useChart } from "@/hooks/useChart";
|
import { useChart } from "@/hooks/useChart";
|
||||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||||
import { useI18n } from "@/hooks/useI18n";
|
import { useI18n } from "@/hooks/useI18n";
|
||||||
@@ -186,8 +186,10 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
|
|||||||
const detail = store.selectedDetail;
|
const detail = store.selectedDetail;
|
||||||
const view = detail ? getFutureModalView(detail, dateStr, locale) : null;
|
const view = detail ? getFutureModalView(detail, dateStr, locale) : null;
|
||||||
const isToday = detail ? dateStr === detail.local_date : false;
|
const isToday = detail ? dateStr === detail.local_date : false;
|
||||||
const todayChartData =
|
const todayChartData = useMemo(
|
||||||
detail && isToday ? getTemperatureChartData(detail, locale) : null;
|
() => (detail && isToday ? getTemperatureChartData(detail, locale) : null),
|
||||||
|
[detail, isToday, locale],
|
||||||
|
);
|
||||||
|
|
||||||
const canvasRef = useChart(() => {
|
const canvasRef = useChart(() => {
|
||||||
if (!detail || !view) {
|
if (!detail || !view) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChartConfiguration } from "chart.js/auto";
|
import type { ChartConfiguration } from "chart.js";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useChart } from "@/hooks/useChart";
|
import { useChart } from "@/hooks/useChart";
|
||||||
import { useDashboardStore, useHistoryData } from "@/hooks/useDashboardStore";
|
import { useDashboardStore, useHistoryData } from "@/hooks/useDashboardStore";
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
|
||||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||||
import { useLeafletMap } from "@/hooks/useLeafletMap";
|
import { useLeafletMap } from "@/hooks/useLeafletMap";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChartConfiguration } from "chart.js/auto";
|
import type { ChartConfiguration } from "chart.js";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { startTransition, useMemo } from "react";
|
||||||
import { useChart } from "@/hooks/useChart";
|
import { useChart } from "@/hooks/useChart";
|
||||||
import { useCityData, useDashboardStore } from "@/hooks/useDashboardStore";
|
import { useCityData, useDashboardStore } from "@/hooks/useDashboardStore";
|
||||||
import { useI18n } from "@/hooks/useI18n";
|
import { useI18n } from "@/hooks/useI18n";
|
||||||
@@ -230,7 +231,10 @@ export function HeroSummary() {
|
|||||||
export function TemperatureChart() {
|
export function TemperatureChart() {
|
||||||
const { data } = useCityData();
|
const { data } = useCityData();
|
||||||
const { locale, t } = useI18n();
|
const { locale, t } = useI18n();
|
||||||
const chartData = data ? getTemperatureChartData(data, locale) : null;
|
const chartData = useMemo(
|
||||||
|
() => (data ? getTemperatureChartData(data, locale) : null),
|
||||||
|
[data, locale],
|
||||||
|
);
|
||||||
|
|
||||||
const canvasRef = useChart(() => {
|
const canvasRef = useChart(() => {
|
||||||
if (!data || !chartData) {
|
if (!data || !chartData) {
|
||||||
@@ -403,7 +407,7 @@ export function ProbabilityDistribution({
|
|||||||
const marketNoText = toPercent(marketNoPrice);
|
const marketNoText = toPercent(marketNoPrice);
|
||||||
const isToday = !targetDate || targetDate === detail.local_date;
|
const isToday = !targetDate || targetDate === detail.local_date;
|
||||||
const marketTopBuckets = isToday ? getMarketTopBuckets(marketScan) : [];
|
const marketTopBuckets = isToday ? getMarketTopBuckets(marketScan) : [];
|
||||||
const sortedMarketTopBuckets = (() => {
|
const sortedMarketTopBuckets = useMemo(() => {
|
||||||
const sorted = [...marketTopBuckets].sort(
|
const sorted = [...marketTopBuckets].sort(
|
||||||
(a, b) => Number(b.probability || 0) - Number(a.probability || 0),
|
(a, b) => Number(b.probability || 0) - Number(a.probability || 0),
|
||||||
);
|
);
|
||||||
@@ -417,7 +421,7 @@ export function ProbabilityDistribution({
|
|||||||
if (deduped.length >= 4) break;
|
if (deduped.length >= 4) break;
|
||||||
}
|
}
|
||||||
return deduped;
|
return deduped;
|
||||||
})();
|
}, [marketTopBuckets]);
|
||||||
const useMarketTopBuckets =
|
const useMarketTopBuckets =
|
||||||
marketScan?.available && sortedMarketTopBuckets.length >= 2;
|
marketScan?.available && sortedMarketTopBuckets.length >= 2;
|
||||||
const topMarketBucketText = toPercent(sortedMarketTopBuckets[0]?.probability);
|
const topMarketBucketText = toPercent(sortedMarketTopBuckets[0]?.probability);
|
||||||
@@ -729,7 +733,9 @@ export function ForecastTable() {
|
|||||||
isSelected && "selected",
|
isSelected && "selected",
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
store.openFutureModal(day.date);
|
startTransition(() => {
|
||||||
|
store.openFutureModal(day.date);
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="f-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";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
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>(
|
export function useChart<TType extends ChartType>(
|
||||||
createConfig: () => ChartConfiguration<TType>,
|
createConfig: () => ChartConfiguration<TType>,
|
||||||
dependencies: React.DependencyList,
|
dependencies: React.DependencyList,
|
||||||
) {
|
) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const chartRef = useRef<Chart<TType> | null>(null);
|
const chartRef = useRef<ChartInstance<TType> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
const config = createConfig();
|
const setupChart = async () => {
|
||||||
if (chartRef.current) {
|
const { Chart } = await import("chart.js/auto");
|
||||||
chartRef.current.destroy();
|
if (disposed) return;
|
||||||
chartRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 () => {
|
return () => {
|
||||||
|
disposed = true;
|
||||||
chartRef.current?.destroy();
|
chartRef.current?.destroy();
|
||||||
chartRef.current = null;
|
chartRef.current = null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -216,59 +216,73 @@ export function useLeafletMap({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map || !cities.length) return;
|
if (!map || !cities.length) return;
|
||||||
|
let canceled = false;
|
||||||
|
const frameId =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? window.requestAnimationFrame(() => {
|
||||||
|
if (canceled) return;
|
||||||
|
|
||||||
const currentMarkers = markersRef.current;
|
const currentMarkers = markersRef.current;
|
||||||
const nextMarkers: typeof currentMarkers = {};
|
const nextMarkers: typeof currentMarkers = {};
|
||||||
const nextLastData: typeof lastCityDataRef.current = {};
|
const nextLastData: typeof lastCityDataRef.current = {};
|
||||||
|
|
||||||
cities.forEach((city) => {
|
cities.forEach((city) => {
|
||||||
const detail = cityDetailsByName[city.name];
|
const detail = cityDetailsByName[city.name];
|
||||||
const summary = citySummariesByName[city.name];
|
const summary = citySummariesByName[city.name];
|
||||||
const snapshot = detail || summary;
|
const snapshot = detail || summary;
|
||||||
const existing = currentMarkers[city.name];
|
const existing = currentMarkers[city.name];
|
||||||
|
|
||||||
const currentTemp = snapshot?.current?.temp;
|
const currentTemp = snapshot?.current?.temp;
|
||||||
const currentRisk = city.risk_level;
|
const currentRisk = city.risk_level;
|
||||||
const lastData = lastCityDataRef.current[city.name];
|
const lastData = lastCityDataRef.current[city.name];
|
||||||
const dataChanged =
|
const dataChanged =
|
||||||
!lastData ||
|
!lastData ||
|
||||||
lastData.temp !== currentTemp ||
|
lastData.temp !== currentTemp ||
|
||||||
lastData.risk !== currentRisk;
|
lastData.risk !== currentRisk;
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (dataChanged) {
|
if (dataChanged) {
|
||||||
existing.marker.setIcon(createMarkerIcon(city, snapshot));
|
existing.marker.setIcon(createMarkerIcon(city, snapshot));
|
||||||
}
|
}
|
||||||
nextMarkers[city.name] = { city, marker: existing.marker };
|
nextMarkers[city.name] = { city, marker: existing.marker };
|
||||||
nextLastData[city.name] = { temp: currentTemp, risk: currentRisk };
|
nextLastData[city.name] = { temp: currentTemp, risk: currentRisk };
|
||||||
return;
|
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]);
|
}, [cities, cityDetailsByName, citySummariesByName]);
|
||||||
|
|
||||||
useEffect(() => {
|
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