后台设置和内容编辑改成可导航布局;部署用访客 origin 生成 Canonical,避免落到容器地址。 Co-authored-by: Cursor <cursoragent@cursor.com>
126 lines
4.6 KiB
TypeScript
126 lines
4.6 KiB
TypeScript
"use client";
|
||
|
||
import { Suspense, useEffect, useRef, useState } from "react";
|
||
import { usePathname, useSearchParams } from "next/navigation";
|
||
|
||
// 版本由 Footer(RSC)从 package.json 注入;未正式设计版本号时传入空串,显示占位
|
||
const PLACEHOLDER = "—";
|
||
|
||
interface Metrics {
|
||
page: number; // 页面:整页加载/客户端导航总耗时
|
||
tpl: number; // 模板:服务端响应(SSR / RSC)耗时
|
||
}
|
||
|
||
// 硬加载:页面=loadEventEnd,模板≈TTFB(localhost 下基本等于服务端生成时间)
|
||
function readHardNav(): Metrics {
|
||
const n = performance.getEntriesByType("navigation")[0] as
|
||
| PerformanceNavigationTiming
|
||
| undefined;
|
||
if (!n) return { page: 0, tpl: 0 };
|
||
return {
|
||
page: Math.round((n.loadEventEnd || n.domComplete || n.responseEnd) - n.startTime),
|
||
tpl: Math.max(0, Math.round(n.responseStart - n.requestStart)),
|
||
};
|
||
}
|
||
|
||
function TimingInner({ version }: { version: string }) {
|
||
const pathname = usePathname();
|
||
const search = useSearchParams().toString();
|
||
// 初始必须为 null:performance 仅客户端可读,直接参与首帧渲染会导致水合不一致
|
||
const [metrics, setMetrics] = useState<Metrics | null>(null);
|
||
// 记录一次站内链接点击,作为客户端导航计时起点
|
||
const pending = useRef<{ start: number; path: string; search: string } | null>(null);
|
||
|
||
// 挂载后读取硬加载指标;load 事件后 loadEventEnd 才有完整值,再补一次
|
||
useEffect(() => {
|
||
setMetrics(readHardNav());
|
||
const apply = () => setMetrics(readHardNav());
|
||
if (document.readyState !== "complete") {
|
||
window.addEventListener("load", apply, { once: true });
|
||
return () => window.removeEventListener("load", apply);
|
||
}
|
||
}, []);
|
||
|
||
// 捕获阶段监听站内点击(不干预任何默认行为)
|
||
useEffect(() => {
|
||
const onClick = (e: MouseEvent) => {
|
||
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.defaultPrevented) return;
|
||
const a = (e.target as HTMLElement).closest("a");
|
||
if (!a || !a.href || a.target === "_blank" || a.hasAttribute("download")) return;
|
||
let u: URL;
|
||
try {
|
||
u = new URL(a.href);
|
||
} catch {
|
||
return;
|
||
}
|
||
if (u.origin !== location.origin) return;
|
||
pending.current = { start: performance.now(), path: u.pathname, search: u.search };
|
||
};
|
||
document.addEventListener("click", onClick, true);
|
||
return () => document.removeEventListener("click", onClick, true);
|
||
}, []);
|
||
|
||
// 路由切换后两帧(绘制完成)结算;模板耗时取本次 RSC fetch 的资源条目
|
||
useEffect(() => {
|
||
const p = pending.current;
|
||
if (!p) return;
|
||
if (p.path !== pathname || p.search !== (search ? `?${search}` : "")) return;
|
||
let raf2 = 0;
|
||
const raf1 = requestAnimationFrame(() => {
|
||
raf2 = requestAnimationFrame(() => {
|
||
const page = Math.round(performance.now() - p.start);
|
||
let tpl = 0;
|
||
const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[];
|
||
for (let i = resources.length - 1; i >= 0; i--) {
|
||
const r = resources[i];
|
||
if (
|
||
r.initiatorType === "fetch" &&
|
||
r.startTime >= p.start - 10 &&
|
||
r.name.startsWith(location.origin + p.path)
|
||
) {
|
||
tpl = Math.round(r.responseEnd - r.startTime);
|
||
break;
|
||
}
|
||
}
|
||
setMetrics((prev) => ({ page: Math.max(1, page), tpl: tpl || prev?.tpl || 0 }));
|
||
pending.current = null;
|
||
});
|
||
});
|
||
return () => {
|
||
cancelAnimationFrame(raf1);
|
||
cancelAnimationFrame(raf2);
|
||
};
|
||
}, [pathname, search]);
|
||
|
||
return (
|
||
<span
|
||
className="inline-flex items-center gap-2 text-[12px] tabular-nums whitespace-nowrap"
|
||
style={{ color: "var(--ink-3)" }}
|
||
>
|
||
<span>版本:{version || PLACEHOLDER}</span>
|
||
<span aria-hidden="true">·</span>
|
||
<span suppressHydrationWarning>页面:{metrics ? `${metrics.page}ms` : PLACEHOLDER}</span>
|
||
<span aria-hidden="true">·</span>
|
||
<span suppressHydrationWarning>模板:{metrics ? `${metrics.tpl}ms` : PLACEHOLDER}</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
export default function FooterTiming({ version = "" }: { version?: string }) {
|
||
return (
|
||
<Suspense
|
||
fallback={
|
||
<span className="inline-flex items-center gap-2 text-[12px] tabular-nums whitespace-nowrap" style={{ color: "var(--ink-3)" }}>
|
||
版本:{version || PLACEHOLDER}
|
||
<span aria-hidden="true">·</span>
|
||
页面:{PLACEHOLDER}
|
||
<span aria-hidden="true">·</span>
|
||
模板:{PLACEHOLDER}
|
||
</span>
|
||
}
|
||
>
|
||
<TimingInner version={version} />
|
||
</Suspense>
|
||
);
|
||
}
|