feat: 完成社区首页功能迭代,新增首页聚合与工作台组件
本次提交实现了完整的社区首页功能: 1. 新增首页聚合接口与服务,包含社区统计、热门帖、活跃用户与板块数据 2. 实现帖子推荐排序、SSR缓存与点赞态同步逻辑 3. 重构导航与布局系统,新增工作台侧边栏与移动端适配 4. 优化主题色与UI样式,统一设计系统风格 5. 修复登录注册页自动跳转与组件状态同步问题
This commit is contained in:
121
frontend/components/FooterTiming.tsx
Normal file
121
frontend/components/FooterTiming.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
"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="text-[12px] meta whitespace-nowrap">
|
||||
版本:{version || PLACEHOLDER}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<TimingInner version={version} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user