Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 41 additions & 12 deletions app/[locale]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { setRequestLocale } from "next-intl/server";
import { hasLocale } from "next-intl";
import { notFound } from "next/navigation";
Expand All @@ -6,7 +7,6 @@ import { Hero } from "@/app/components/Hero";
import { DispatchNetwork } from "@/app/components/DispatchNetwork";
import { Footer } from "@/app/components/Footer";
import { FloatWindow } from "@/app/components/float-window/FloatWindow";
import { fetchHomepageEvents } from "@/lib/events-fetch";
import { routing } from "@/i18n/routing";

interface Props {
Expand All @@ -16,30 +16,59 @@ interface Props {
/**
* 站点首页 (/[locale])。
*
* i18n 改造前是 RSC + cookies(),整页 dynamic。改造后通过 setRequestLocale
* 启用 SSG —— 但这页 await fetchHomepageEvents 仍然是 server fetch,会把
* 整页钉成 dynamic(fetch 命中 cache 也算访问态)。
* SSG 化(i18n 改造收尾,2026-05):
* 原版 await fetchHomepageEvents() server fetch backend,把首页钉成
* ƒ Dynamic。改造让 FloatWindow / ActivityTicker 各自 client fetch
* /api/public/homepage-events,page 本身只剩纯静态渲染,build 时随
* [locale] generateStaticParams 一起预渲染(zh + en 两份),Vercel
* Function 调用归零。
*
* TODO: 把 FloatWindow 的 event prop 移到 client 自己 fetch(参考 Hero
* 的 ActivityTicker 模式),让首页变 ●(SSG)。当前先保持 ƒ,等下一轮
* 优化。
* force-static + setRequestLocale 双保险:让 next-intl 不退回 dynamic。
*/
export const dynamic = "force-static";

export default async function HomePage({ params }: Props) {
const { locale } = await params;
if (!hasLocale(routing.locales, locale)) notFound();
setRequestLocale(locale);

const homepageEvents = await fetchHomepageEvents();
// FloatWindow 只展示"第一条未过期活动";fetchHomepageEvents 已把未过期排前面
const latestActive = homepageEvents.find((e) => !e.deprecated) ?? null;

return (
<>
<Header />
<Hero />
<DispatchNetwork />
<Footer />
<FloatWindow event={latestActive} />
<FloatWindow />
</>
);
}

/**
* 首页 metadata:覆盖 root layout 的 alternates。
*
* canonical 指向当前 locale 的首页(/zh 或 /en),让两个 locale 各自有独立
* 的 canonical URL,避免 Google 把它们当成重复内容互相争 PageRank。
*
* languages(hreflang)三向声明,告诉 Google 同一首页的另一语言版本在哪。
*/
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale } = await params;
if (!hasLocale(routing.locales, locale)) notFound();
setRequestLocale(locale);

return {
alternates: {
canonical: `/${locale}`,
languages: {
"zh-CN": "/zh",
"en-US": "/en",
"x-default": "/zh",
},
},
openGraph: {
url: `/${locale}`,
locale: locale === "en" ? "en_US" : "zh_CN",
alternateLocale: locale === "en" ? ["zh_CN"] : ["en_US"],
},
};
}
32 changes: 32 additions & 0 deletions app/api/public/homepage-events/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { fetchHomepageEvents } from "@/lib/events-fetch";

/**
* 首页活动数据的客户端代理 API。
*
* 为什么需要这个端点(i18n 改造收尾,2026-05):
* 首页 page.tsx 之前用 server component await fetchHomepageEvents(),把
* 首页钉成 ƒ Dynamic(任何 server fetch 都阻挡 SSG)。改造让 FloatWindow /
* ActivityTicker 改 client component 自己 fetch,但他们直接打后端会涉及
* CORS + NEXT_PUBLIC_BACKEND_URL 暴露。走自家 API 中转更干净:
* - 浏览器调 /api/public/homepage-events(同源,无 CORS)
* - 路由 server-side 调后端 /api/events(BACKEND_URL 不暴露)
* - revalidate: 300 让 Next.js Data Cache 命中 5 分钟内的请求,最多
* 每 5min 一次 server fetch,Vercel CPU 几乎为零
*
* 即使首页 SSG,每个浏览器加载会发一次这条请求,但因为 fetch cache + ISR
* 命中(HTTP 304),Vercel Function 大部分时候不会冷启动。
*/

// ISR:5 分钟缓存(与 lib/events-fetch.ts 内部 revalidate 对齐)
export const revalidate = 300;

export async function GET() {
const events = await fetchHomepageEvents();
return NextResponse.json(events, {
headers: {
// 浏览器侧也缓存 5 分钟,刷新页面期间不再打 origin
"cache-control": "public, max-age=300, s-maxage=300",
},
});
}
61 changes: 61 additions & 0 deletions app/api/public/top-docs/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { NextResponse } from "next/server";

interface TopDocDto {
path: string;
title: string;
views: number;
}

/**
* 首页 "本周最热" 数据的客户端代理 API。
*
* 与 /api/public/homepage-events 同一思路(i18n 改造收尾,2026-05):
* 原来的 HotDocsPreview server component 直接 await fetchTopDocs,server
* fetch 让 Hero 段所在的整个首页 RSC tree 变 ƒ Dynamic。改为 client fetch
* 走自家代理:
* - 浏览器调 /api/public/top-docs(同源,无 CORS)
* - 路由 server-side 调后端 /analytics/top-docs(BACKEND_URL 不暴露)
* - revalidate=300 让 Next.js Data Cache 命中,最多每 5min 一次后端调用
* - 浏览器侧 cache-control 5min 复用响应
*
* 后端拒服务时静默返回空数组,HotDocsPreview 显示"empty"。
*/
export const revalidate = 300;

export async function GET() {
const backendUrl = process.env.BACKEND_URL;
if (!backendUrl) {
return NextResponse.json([], {
headers: { "cache-control": "public, max-age=300, s-maxage=300" },
});
}

try {
const res = await fetch(
`${backendUrl}/analytics/top-docs?window=7d&limit=5`,
{
next: { revalidate: 300 },
// UA 带 InvolutionHell-SSR token,让 Cloudflare Bot Fight Mode 放行
// (CF Custom Rule 用这个 token 识别"自己人")。
headers: {
accept: "application/json",
"user-agent": "InvolutionHell-SSR/1.0 (+https://involutionhell.com)",
},
},
);
if (!res.ok) {
return NextResponse.json([], {
headers: { "cache-control": "public, max-age=60" },
});
}
const json = await res.json();
const data: TopDocDto[] = Array.isArray(json?.data) ? json.data : [];
return NextResponse.json(data, {
headers: { "cache-control": "public, max-age=300, s-maxage=300" },
});
} catch {
return NextResponse.json([], {
headers: { "cache-control": "public, max-age=60" },
});
}
}
38 changes: 29 additions & 9 deletions app/components/ActivityTicker.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
"use client";

import { useEffect, useState } from "react";
import Link from "next/link";
import { cn } from "@/lib/utils";
import { fetchHomepageEvents, type HomepageEvent } from "@/lib/events-fetch";
import type { HomepageEvent } from "@/lib/events-fetch";

/**
* 首页顶部活动轮播。
*
* 数据源:后端 /api/events(管理员在 /admin/events 维护)。
* 后端失败时返回空数组,组件 return null 不渲染轮播(整条不显示)。
*
* 为什么是 Server Component:
* - 没有交互状态(纯 CSS 跑马灯动画)
* - SSR 时已经能拿到数据,避免 client fetch 造成首屏闪烁
* - revalidate: 300 让 Neon 压力稳定在每 5min 一次 SSR
* 为什么是 Client Component(i18n 改造收尾,2026-05):
* 原来是 async server component,server fetch 让首页 page.tsx 整页变 ƒ
* Dynamic(任何 server fetch 都阻挡 SSG)。改 client + 自家 ISR 代理后:
* - 首页 page.tsx 可以纯静态预渲染,Vercel CPU 归零
* - 数据走 /api/public/homepage-events(revalidate=300,5min 缓存)
* - 浏览器拿到 304 命中本地缓存,不打 Vercel Function
* - 首屏没有 ticker(events 还在 fetch),hydrate 后出现,不影响 LCP
* (ticker 不是 LCP 元素)
*/

const MAX_ITEMS = 3;
Expand All @@ -21,9 +27,23 @@ type ActivityTickerProps = {
className?: string;
};

export async function ActivityTicker({ className }: ActivityTickerProps) {
const all = await fetchHomepageEvents();
const events = all.slice(0, MAX_ITEMS);
export function ActivityTicker({ className }: ActivityTickerProps) {
const [events, setEvents] = useState<HomepageEvent[]>([]);

useEffect(() => {
let cancelled = false;
fetch("/api/public/homepage-events", { cache: "force-cache" })
.then((r) => (r.ok ? r.json() : []))
.then((data: HomepageEvent[]) => {
if (!cancelled) setEvents(data.slice(0, MAX_ITEMS));
})
.catch(() => {
// 静默失败:ticker 不显示比报错更友好
});
return () => {
cancelled = true;
};
}, []);

if (events.length === 0) return null;

Expand Down
78 changes: 38 additions & 40 deletions app/components/HotDocsPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,50 +1,18 @@
"use client";

import { useEffect, useState } from "react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { useTranslations } from "next-intl";

interface TopDocDto {
path: string;
title: string;
views: number;
}

async function fetchTopDocs(): Promise<TopDocDto[]> {
const backendUrl = process.env.BACKEND_URL;
if (!backendUrl) {
if (process.env.NODE_ENV !== "production") {
console.warn(
"[HotDocsPreview] BACKEND_URL 未配置,跳过 Top Docs 请求。本地请在 .env.local 设置。",
);
}
return [];
}
try {
const res = await fetch(
`${backendUrl}/analytics/top-docs?window=7d&limit=5`,
{
next: { revalidate: 300 },
// UA 必须带 "InvolutionHell-SSR" token,否则 Cloudflare Bot Fight Mode
// 会按 Vercel runner IP 信誉判定为 bot 拦截(CF Custom Rule 用这个
// token 识别"自己人"放行)。其他 SSR fetcher(fetchProfile / events /
// feed)都已经带,唯独本组件之前漏了导致首页 "本周最热" 一直空。
headers: {
accept: "application/json",
"user-agent": "InvolutionHell-SSR/1.0 (+https://involutionhell.com)",
},
},
);
if (!res.ok) return [];
const json = await res.json();
return Array.isArray(json?.data) ? json.data : [];
} catch {
return [];
}
}

/**
* HotDocsPreview 的骨架屏。
* 在首页被 <Suspense> 包裹时作为 fallback,让页面 shell 先 stream 给浏览器,
* 等后端 /analytics/top-docs 返回后再替换成真实内容。
* 结构刻意贴合真组件(同样的 5 行 + 标题栏),避免 CLS。
* 数据加载期间显示,避免 CLS(结构刻意贴合真组件,5 行 + 标题栏)。
*/
export function HotDocsPreviewSkeleton() {
return (
Expand Down Expand Up @@ -76,9 +44,39 @@ export function HotDocsPreviewSkeleton() {
);
}

export async function HotDocsPreview() {
const docs = await fetchTopDocs();
const t = await getTranslations("hotDocs");
/**
* HotDocsPreview - 首页 "本周最热" 文档榜。
*
* 客户端化(i18n 改造收尾,2026-05):
* 原来是 async server component(await fetchTopDocs + getTranslations),
* server fetch 让首页 RSC tree 整体 ƒ Dynamic。改 client 后:
* - 数据走 /api/public/top-docs(revalidate=300 ISR + 浏览器 5min 缓存)
* - 翻译用 next-intl 的 useTranslations(client hook)
* - 首屏先显示 Skeleton,hydrate 后 fetch + 替换为真实内容(不影响 LCP)
*/
export function HotDocsPreview() {
const t = useTranslations("hotDocs");
const [docs, setDocs] = useState<TopDocDto[] | null>(null);

useEffect(() => {
let cancelled = false;
fetch("/api/public/top-docs", { cache: "force-cache" })
.then((r) => (r.ok ? r.json() : []))
.then((data: TopDocDto[]) => {
if (!cancelled) setDocs(data);
})
.catch(() => {
if (!cancelled) setDocs([]);
});
return () => {
cancelled = true;
};
}, []);

// fetch 未完成:渲染 Skeleton(与 Suspense fallback 同形态)
if (docs === null) {
return <HotDocsPreviewSkeleton />;
}

return (
<div className="border border-[var(--foreground)] p-6 bg-[var(--background)]">
Expand Down
45 changes: 31 additions & 14 deletions app/components/float-window/FloatWindow.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState, useCallback } from "react";
import { useState, useCallback, useEffect } from "react";
import { usePathname } from "next/navigation";
import { motion, AnimatePresence } from "motion/react";
import type { HomepageEvent } from "@/lib/events-fetch";
Expand All @@ -10,26 +10,43 @@ import styles from "./FloatWindow.module.css";

/**
* FloatWindow - 复古报纸风格的活动预告悬浮窗。
* 仅显示最新的一条活动,仅在首页 (/) 可见。
* 仅显示最新的一条活动,仅在首页(locale 段下的 /) 可见。
*
* 数据来源:
* - 之前从 data/event.json 直接 import
* - 现在由上游 Server Component(app/page.tsx)调 lib/events-fetch.ts 拉 /api/events
* 后通过 event prop 传进来;后端失败时 fetch 内部会 fallback 到 JSON
* 数据来源(i18n 改造收尾,2026-05):
* - 之前由上游 Server Component(app/page.tsx)调 fetchHomepageEvents
* 后通过 event prop 传入;这让首页变 ƒ Dynamic
* - 改为本组件自己 client fetch /api/public/homepage-events,首页可以
* SSG 静态化。组件仍然按"第一条未过期活动"的逻辑挑一条展示。
*/

interface FloatWindowProps {
/** 要展示的单条活动;null 或已过期时组件不渲染 */
event: HomepageEvent | null;
}

export function FloatWindow({ event }: FloatWindowProps) {
export function FloatWindow() {
const pathname = usePathname();
const [isCollapsed, setIsCollapsed] = useState(false);
const [isDismissed, setIsDismissed] = useState(false);
const [event, setEvent] = useState<HomepageEvent | null>(null);

// 仅在首页(/zh、/en 或裸 /)可见。i18n 段化后 pathname 是 /<locale>。
const isHomePage =
pathname === "/" || pathname === "/zh" || pathname === "/en";

// 仅在首页 (/) 可见
const isHomePage = pathname === "/";
useEffect(() => {
if (!isHomePage) return;
let cancelled = false;
fetch("/api/public/homepage-events", { cache: "force-cache" })
.then((r) => (r.ok ? r.json() : []))
.then((data: HomepageEvent[]) => {
if (cancelled) return;
// 未过期的活动排前面(API 已排序),挑第一条非 deprecated
const latest = data.find((e) => !e.deprecated) ?? null;
setEvent(latest);
})
.catch(() => {
// 静默失败:组件不显示比报错更友好
});
return () => {
cancelled = true;
};
}, [isHomePage]);

const handleDismiss = useCallback(() => setIsDismissed(true), []);
const handleToggle = useCallback(() => setIsCollapsed((prev) => !prev), []);
Expand Down
Loading
Loading