Skip to content

Commit 4e511ae

Browse files
committed
feat(home): 首页 Leaderboard 右侧新增'本周最热'面板 (HotDocsPreview)
- 新建 HotDocsPreview.tsx(async Server Component + ISR 300s) fetch 自家 /api/analytics/top-docs?window=7d&limit=5, 后端或数据挂掉时静默降级,不影响首页其他模块 - 新建 app/api/analytics/top-docs/route.ts:从 analyticsEvent 表 按 eventData.path 聚合 7d 阅读量,供前端 SSR 使用 - Hero.tsx Leaderboard 区域改 12 列 grid: 贡献者 Top3 (lg:col-span-8) / 热门文档 Top5 (lg:col-span-4) SEO 价值:首页 HTML 中包含 5 条热门文章标题+链接,Google 爬虫 可直接建立首页 → 文章的链接关系,帮助长尾关键词索引。
1 parent 5335ca6 commit 4e511ae

3 files changed

Lines changed: 170 additions & 41 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { prisma } from "@/lib/db";
2+
import { NextRequest } from "next/server";
3+
4+
export const revalidate = 300;
5+
6+
export async function GET(req: NextRequest) {
7+
const { searchParams } = new URL(req.url);
8+
const window = searchParams.get("window") ?? "7d";
9+
const limit = Math.min(Number(searchParams.get("limit") ?? "5"), 20);
10+
11+
const since = new Date();
12+
if (window === "7d") {
13+
since.setDate(since.getDate() - 7);
14+
} else if (window === "30d") {
15+
since.setDate(since.getDate() - 30);
16+
} else {
17+
since.setFullYear(since.getFullYear() - 10);
18+
}
19+
20+
const rows = await prisma.analyticsEvent.findMany({
21+
where: {
22+
eventType: "page_view",
23+
createdAt: { gte: since },
24+
eventData: { path: { startsWith: "/docs/" } },
25+
},
26+
select: { eventData: true },
27+
});
28+
29+
// 统计各路径 PV
30+
const counts: Record<string, number> = {};
31+
for (const row of rows) {
32+
const data = row.eventData as { path?: string; title?: string } | null;
33+
const path = data?.path;
34+
if (path) counts[path] = (counts[path] ?? 0) + 1;
35+
}
36+
37+
const top = Object.entries(counts)
38+
.sort((a, b) => b[1] - a[1])
39+
.slice(0, limit)
40+
.map(([path, views]) => ({ path, views }));
41+
42+
return Response.json(top);
43+
}

app/components/Hero.tsx

Lines changed: 45 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Image from "next/image";
55
import { ActivityTicker } from "@/app/components/ActivityTicker";
66
import { cn } from "@/lib/utils";
77
import { AnimatedBar } from "@/app/components/rank/AnimatedBar";
8+
import { HotDocsPreview } from "@/app/components/HotDocsPreview";
89
import leaderboardData from "@/generated/site-leaderboard.json";
910
import { MAINTAINERS } from "@/lib/admins";
1011

@@ -169,49 +170,52 @@ export function Hero() {
169170
</Link>
170171
</div>
171172

172-
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
173-
{(() => {
174-
const rawData = leaderboardData as {
175-
id: string;
176-
name: string;
177-
points: number;
178-
avatarUrl: string;
179-
}[];
180-
const filteredData = rawData.filter(
181-
(user) => !MAINTAINERS.includes(user.name),
182-
);
183-
const top3 = filteredData.slice(0, 3);
184-
const maxPoints = top3.length > 0 ? top3[0].points : 100;
173+
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
174+
<div className="lg:col-span-8 grid grid-cols-1 md:grid-cols-3 gap-6">
175+
{(() => {
176+
const rawData = leaderboardData as {
177+
id: string;
178+
name: string;
179+
points: number;
180+
avatarUrl: string;
181+
}[];
182+
const filteredData = rawData.filter(
183+
(user) => !MAINTAINERS.includes(user.name),
184+
);
185+
const top3 = filteredData.slice(0, 3);
186+
const maxPoints = top3.length > 0 ? top3[0].points : 100;
185187

186-
return top3.map((user, idx) => (
187-
<div
188-
key={user.id}
189-
className="border border-[var(--foreground)] p-6 bg-[var(--background)] relative hard-shadow-hover transition-all group"
190-
>
191-
<div className="absolute top-0 right-0 w-12 h-12 bg-[var(--foreground)] text-[var(--background)] flex items-center justify-center font-mono font-bold text-xl border-b border-l border-[var(--foreground)] z-10">
192-
#{idx + 1}
193-
</div>
194-
<div className="w-16 h-16 bg-neutral-100 dark:bg-neutral-800 border border-[var(--foreground)] mb-4 transition-transform group-hover:scale-110 overflow-hidden">
195-
<Image
196-
src={user.avatarUrl}
197-
alt={user.name}
198-
width={64}
199-
height={64}
200-
className="w-full h-full object-cover transition-all duration-300"
201-
/>
202-
</div>
203-
<div className="font-serif text-2xl font-bold uppercase text-[var(--foreground)] mb-1 truncate">
204-
{user.name}
205-
</div>
206-
<div className="font-mono text-xs text-neutral-500 uppercase tracking-widest mb-4">
207-
{user.points.toLocaleString()} PTS
188+
return top3.map((user, idx) => (
189+
<div
190+
key={user.id}
191+
className="border border-[var(--foreground)] p-6 bg-[var(--background)] relative hard-shadow-hover transition-all group"
192+
>
193+
<div className="absolute top-0 right-0 w-12 h-12 bg-[var(--foreground)] text-[var(--background)] flex items-center justify-center font-mono font-bold text-xl border-b border-l border-[var(--foreground)] z-10">
194+
#{idx + 1}
195+
</div>
196+
<div className="w-16 h-16 bg-neutral-100 dark:bg-neutral-800 border border-[var(--foreground)] mb-4 transition-transform group-hover:scale-110 overflow-hidden">
197+
<Image
198+
src={user.avatarUrl}
199+
alt={user.name}
200+
width={64}
201+
height={64}
202+
className="w-full h-full object-cover transition-all duration-300"
203+
/>
204+
</div>
205+
<div className="font-serif text-2xl font-bold uppercase text-[var(--foreground)] mb-1 truncate">
206+
{user.name}
207+
</div>
208+
<div className="font-mono text-xs text-neutral-500 uppercase tracking-widest mb-4">
209+
{user.points.toLocaleString()} PTS
210+
</div>
211+
<AnimatedBar value={user.points} max={maxPoints} />
208212
</div>
209-
210-
{/* Visual bar chart representing points using motion */}
211-
<AnimatedBar value={user.points} max={maxPoints} />
212-
</div>
213-
));
214-
})()}
213+
));
214+
})()}
215+
</div>
216+
<div className="lg:col-span-4">
217+
<HotDocsPreview />
218+
</div>
215219
</div>
216220
</div>
217221
</div>

app/components/HotDocsPreview.tsx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import Link from "next/link";
2+
3+
interface TopDocDto {
4+
path: string;
5+
title: string;
6+
views: number;
7+
}
8+
9+
async function fetchTopDocs(): Promise<TopDocDto[]> {
10+
const backendUrl = process.env.BACKEND_URL ?? "http://localhost:8081";
11+
try {
12+
const res = await fetch(
13+
`${backendUrl}/analytics/top-docs?window=7d&limit=5`,
14+
{ next: { revalidate: 300 } },
15+
);
16+
if (!res.ok) return [];
17+
const json = await res.json();
18+
// 后端用 ApiResponse<List<TopDocDto>> 包裹,data 字段存实际数据
19+
return json.data ?? json;
20+
} catch {
21+
return [];
22+
}
23+
}
24+
25+
export async function HotDocsPreview() {
26+
const docs = await fetchTopDocs();
27+
28+
return (
29+
<div className="border border-[var(--foreground)] p-6 bg-[var(--background)]">
30+
<div className="flex items-center justify-between mb-4 border-b border-[var(--foreground)] pb-3">
31+
<div>
32+
<div className="font-serif text-lg font-black uppercase text-[var(--foreground)]">
33+
Hot This Week
34+
</div>
35+
<div className="font-mono text-[10px] uppercase tracking-widest text-neutral-500">
36+
本周最热
37+
</div>
38+
</div>
39+
<Link
40+
href="/rank?tab=hot&window=7d"
41+
className="font-mono text-[10px] uppercase tracking-widest font-bold text-[var(--foreground)] hover:text-[#CC0000] transition-colors flex items-center gap-1 group"
42+
data-umami-event="navigation_click"
43+
data-umami-event-region="hot_docs_preview"
44+
data-umami-event-label="MORE"
45+
>
46+
MORE
47+
<span className="transform group-hover:translate-x-0.5 transition-transform">
48+
&rarr;
49+
</span>
50+
</Link>
51+
</div>
52+
53+
{docs.length === 0 ? (
54+
<p className="font-mono text-xs text-neutral-400">暂无数据</p>
55+
) : (
56+
<ol className="flex flex-col gap-4">
57+
{docs.map((doc, idx) => (
58+
<li key={doc.path} className="flex items-start gap-3 group">
59+
<span className="font-mono text-[10px] text-neutral-400 w-4 shrink-0 pt-1">
60+
{String(idx + 1).padStart(2, "0")}
61+
</span>
62+
<div className="flex-1 min-w-0">
63+
<Link
64+
href={doc.path}
65+
className="font-serif text-sm font-bold uppercase text-[var(--foreground)] hover:text-[#CC0000] transition-colors leading-tight line-clamp-2 block"
66+
data-umami-event="navigation_click"
67+
data-umami-event-region="hot_docs_preview"
68+
data-umami-event-label={doc.path}
69+
>
70+
{doc.title}
71+
</Link>
72+
<div className="font-mono text-[10px] text-neutral-400 mt-0.5">
73+
{doc.views.toLocaleString()} views
74+
</div>
75+
</div>
76+
</li>
77+
))}
78+
</ol>
79+
)}
80+
</div>
81+
);
82+
}

0 commit comments

Comments
 (0)