diff --git a/app/components/float-window/FloatWindow.tsx b/app/components/float-window/FloatWindow.tsx
index 66c0584..bfa216d 100644
--- a/app/components/float-window/FloatWindow.tsx
+++ b/app/components/float-window/FloatWindow.tsx
@@ -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";
@@ -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
(null);
+
+ // 仅在首页(/zh、/en 或裸 /)可见。i18n 段化后 pathname 是 /。
+ 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), []);
From e61c5a005739c62fd878058ff2fb424f3fc3caca Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Wed, 6 May 2026 17:03:31 +0000
Subject: [PATCH 3/3] =?UTF-8?q?docs+seo:=20=E9=A6=96=E9=A1=B5=20generateMe?=
=?UTF-8?q?tadata=20+=20=E5=8A=A0=E6=96=B0=E8=B7=AF=E7=94=B1=20boilerplate?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
补两条 i18n 收尾的零碎:
1. app/[locale]/page.tsx 加 generateMetadata
覆盖 root layout 默认 alternates,给首页输出当前 locale 的 canonical
(/zh 或 /en),并且 hreflang 三向声明(zh-CN / en-US / x-default)。
每个 locale 各自 canonical,不再共享 root 的 fallback。
2. dev_docs/i18n_url_routing.md 加「如何加新 user-facing 路由」章节
贴出 page.tsx + generateMetadata 的 boilerplate,列出 5 条容易踩的坑:
- setRequestLocale 排序
- @/i18n/navigation vs next/navigation 选错的后果
- components 不在 [locale] 下
- server fetch 让 page 退回 dynamic 的应对
- layout 嵌套也要逐层 setRequestLocale
后续加 page 直接抄就能保证 SSG + i18n 一起对。
---
app/[locale]/page.tsx | 31 ++++++++++++++++
dev_docs/i18n_url_routing.md | 70 ++++++++++++++++++++++++++++++++++++
2 files changed, 101 insertions(+)
diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx
index 209150a..93542b3 100644
--- a/app/[locale]/page.tsx
+++ b/app/[locale]/page.tsx
@@ -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";
@@ -41,3 +42,33 @@ export default async function HomePage({ params }: Props) {
>
);
}
+
+/**
+ * 首页 metadata:覆盖 root layout 的 alternates。
+ *
+ * canonical 指向当前 locale 的首页(/zh 或 /en),让两个 locale 各自有独立
+ * 的 canonical URL,避免 Google 把它们当成重复内容互相争 PageRank。
+ *
+ * languages(hreflang)三向声明,告诉 Google 同一首页的另一语言版本在哪。
+ */
+export async function generateMetadata({ params }: Props): Promise {
+ 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"],
+ },
+ };
+}
diff --git a/dev_docs/i18n_url_routing.md b/dev_docs/i18n_url_routing.md
index 6e93a8f..da94afa 100644
--- a/dev_docs/i18n_url_routing.md
+++ b/dev_docs/i18n_url_routing.md
@@ -143,6 +143,76 @@ frontmatter 不需要写 `lang` 字段。fumadocs 按文件名后缀识别。
`lib/source.ts` 配 `fallbackLanguage: "zh"`:访问 `/en/docs/` 但 `.en.mdx`
不存在时,自动渲染原文(zh)。文档站合理体验(缺译显示中文 > 显示空白)。
+## 加新 user-facing 路由
+
+新建 page 时直接抄这个 boilerplate,就能保证 SSG + i18n 同时正确:
+
+```tsx
+// app/[locale]//page.tsx
+import type { Metadata } from "next";
+import { setRequestLocale } from "next-intl/server";
+import { hasLocale } from "next-intl";
+import { notFound } from "next/navigation";
+import { routing } from "@/i18n/routing";
+
+interface Props {
+ params: Promise<{ locale: string }>;
+}
+
+// 没 server fetch 才能加 force-static;如果 page 里有 await fetch(...)
+// 就别加(会和 fetch 冲突报错),靠 setRequestLocale 也能让 RSC 静态化。
+export const dynamic = "force-static";
+
+export default async function Page({ params }: Props) {
+ const { locale } = await params;
+ if (!hasLocale(routing.locales, locale)) notFound();
+ setRequestLocale(locale); // ← 必须,且必须排在任何 next-intl hook 之前
+
+ // ... 业务逻辑
+ return ...
;
+}
+
+export async function generateMetadata({ params }: Props): Promise {
+ 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/",
+ },
+ },
+ };
+}
+```
+
+**几条容易踩的坑**:
+
+1. **`setRequestLocale` 必须在第一位**。排在 `useTranslations` / `getMessages`
+ / `getTranslations` 之前。否则 next-intl 会回退到从 cookies/headers 推断
+ locale,整页变 dynamic。
+2. **导航 API 用 `@/i18n/navigation` 而不是 `next/navigation`**。在 [locale]
+ 段下的客户端组件如果 `import { useRouter } from 'next/navigation'`,
+ `router.push("/foo")` 会跳到 `/foo` 而不是 `//foo`,丢 locale 段。
+ ```tsx
+ // ✅ 正确
+ import { useRouter, Link } from "@/i18n/navigation";
+ // ❌ 错误(在 [locale] 段下不要用)
+ import { useRouter } from "next/navigation";
+ import Link from "next/link";
+ ```
+3. **components 在 `app/components/`(不在 [locale] 下)**。组件本身不需要
+ locale 段,导入用 `@/app/components/X`。
+4. **server fetch 让 page 退回 dynamic**。如果非要 fetch backend,参考首页
+ 的做法:建一个 `/api/public/` ISR 代理(revalidate=300),组件改 client
+ useEffect fetch,page 本身保持纯静态。
+5. **layout.tsx 嵌套时也要调 `setRequestLocale`**。Next.js 独立渲染 layout
+ 和 page;page 调了 layout 没调照样退化 dynamic。每层都要补。
+
## 切换语言
`` 用 next-intl 的 `useRouter().replace(pathname, { locale })`。