feat: 完成社区首页功能迭代,新增首页聚合与工作台组件

本次提交实现了完整的社区首页功能:
1. 新增首页聚合接口与服务,包含社区统计、热门帖、活跃用户与板块数据
2. 实现帖子推荐排序、SSR缓存与点赞态同步逻辑
3. 重构导航与布局系统,新增工作台侧边栏与移动端适配
4. 优化主题色与UI样式,统一设计系统风格
5. 修复登录注册页自动跳转与组件状态同步问题
This commit is contained in:
2026-09-12 23:36:18 +08:00
parent 055688a6cf
commit bee06fa962
24 changed files with 1446 additions and 359 deletions

View 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>
);
}