|
| 1 | +"use client"; |
| 2 | + |
| 3 | +import { useEffect, useState } from "react"; |
1 | 4 | import Link from "next/link"; |
2 | | -import { getTranslations } from "next-intl/server"; |
| 5 | +import { useTranslations } from "next-intl"; |
3 | 6 |
|
4 | 7 | interface TopDocDto { |
5 | 8 | path: string; |
6 | 9 | title: string; |
7 | 10 | views: number; |
8 | 11 | } |
9 | 12 |
|
10 | | -async function fetchTopDocs(): Promise<TopDocDto[]> { |
11 | | - const backendUrl = process.env.BACKEND_URL; |
12 | | - if (!backendUrl) { |
13 | | - if (process.env.NODE_ENV !== "production") { |
14 | | - console.warn( |
15 | | - "[HotDocsPreview] BACKEND_URL 未配置,跳过 Top Docs 请求。本地请在 .env.local 设置。", |
16 | | - ); |
17 | | - } |
18 | | - return []; |
19 | | - } |
20 | | - try { |
21 | | - const res = await fetch( |
22 | | - `${backendUrl}/analytics/top-docs?window=7d&limit=5`, |
23 | | - { |
24 | | - next: { revalidate: 300 }, |
25 | | - // UA 必须带 "InvolutionHell-SSR" token,否则 Cloudflare Bot Fight Mode |
26 | | - // 会按 Vercel runner IP 信誉判定为 bot 拦截(CF Custom Rule 用这个 |
27 | | - // token 识别"自己人"放行)。其他 SSR fetcher(fetchProfile / events / |
28 | | - // feed)都已经带,唯独本组件之前漏了导致首页 "本周最热" 一直空。 |
29 | | - headers: { |
30 | | - accept: "application/json", |
31 | | - "user-agent": "InvolutionHell-SSR/1.0 (+https://involutionhell.com)", |
32 | | - }, |
33 | | - }, |
34 | | - ); |
35 | | - if (!res.ok) return []; |
36 | | - const json = await res.json(); |
37 | | - return Array.isArray(json?.data) ? json.data : []; |
38 | | - } catch { |
39 | | - return []; |
40 | | - } |
41 | | -} |
42 | | - |
43 | 13 | /** |
44 | 14 | * HotDocsPreview 的骨架屏。 |
45 | | - * 在首页被 <Suspense> 包裹时作为 fallback,让页面 shell 先 stream 给浏览器, |
46 | | - * 等后端 /analytics/top-docs 返回后再替换成真实内容。 |
47 | | - * 结构刻意贴合真组件(同样的 5 行 + 标题栏),避免 CLS。 |
| 15 | + * 数据加载期间显示,避免 CLS(结构刻意贴合真组件,5 行 + 标题栏)。 |
48 | 16 | */ |
49 | 17 | export function HotDocsPreviewSkeleton() { |
50 | 18 | return ( |
@@ -76,9 +44,39 @@ export function HotDocsPreviewSkeleton() { |
76 | 44 | ); |
77 | 45 | } |
78 | 46 |
|
79 | | -export async function HotDocsPreview() { |
80 | | - const docs = await fetchTopDocs(); |
81 | | - const t = await getTranslations("hotDocs"); |
| 47 | +/** |
| 48 | + * HotDocsPreview - 首页 "本周最热" 文档榜。 |
| 49 | + * |
| 50 | + * 客户端化(i18n 改造收尾,2026-05): |
| 51 | + * 原来是 async server component(await fetchTopDocs + getTranslations), |
| 52 | + * server fetch 让首页 RSC tree 整体 ƒ Dynamic。改 client 后: |
| 53 | + * - 数据走 /api/public/top-docs(revalidate=300 ISR + 浏览器 5min 缓存) |
| 54 | + * - 翻译用 next-intl 的 useTranslations(client hook) |
| 55 | + * - 首屏先显示 Skeleton,hydrate 后 fetch + 替换为真实内容(不影响 LCP) |
| 56 | + */ |
| 57 | +export function HotDocsPreview() { |
| 58 | + const t = useTranslations("hotDocs"); |
| 59 | + const [docs, setDocs] = useState<TopDocDto[] | null>(null); |
| 60 | + |
| 61 | + useEffect(() => { |
| 62 | + let cancelled = false; |
| 63 | + fetch("/api/public/top-docs", { cache: "force-cache" }) |
| 64 | + .then((r) => (r.ok ? r.json() : [])) |
| 65 | + .then((data: TopDocDto[]) => { |
| 66 | + if (!cancelled) setDocs(data); |
| 67 | + }) |
| 68 | + .catch(() => { |
| 69 | + if (!cancelled) setDocs([]); |
| 70 | + }); |
| 71 | + return () => { |
| 72 | + cancelled = true; |
| 73 | + }; |
| 74 | + }, []); |
| 75 | + |
| 76 | + // fetch 未完成:渲染 Skeleton(与 Suspense fallback 同形态) |
| 77 | + if (docs === null) { |
| 78 | + return <HotDocsPreviewSkeleton />; |
| 79 | + } |
82 | 80 |
|
83 | 81 | return ( |
84 | 82 | <div className="border border-[var(--foreground)] p-6 bg-[var(--background)]"> |
|
0 commit comments