Skip to content

Commit 9f968be

Browse files
committed
feat(i18n): Header 加匿名可用的 ZH/EN 语言切换按钮
之前切语言的唯一入口在 /settings 页,UserMenu 里只有登录用户能看到; 匿名访客看到的永远是默认 zh,站点对英语用户非常不友好。 新增 LocaleToggle client component,挂在 Header 右上 ThemeToggle 左边: - 写 locale=zh|en 到 document.cookie(字段 / 格式 / 路径 / 一年有效期 与 SettingsForm 完全一致),登录用户在 /settings 改过的偏好不会被打乱 - 切完 router.refresh() 让 server component(Hero / docs 详情等)重新 按 cookie 渲染当前语言 - 初始 render 给默认值,useEffect 里读 cookie 覆盖,避免 hydration 警告 - 按 "ZH / EN" 样式展示,当前语言加粗 不做的事: - 不搞 URL prefix(/zh/xxx、/en/xxx):现站点没有 i18n 路由分段, 硬改牵动路由 / sitemap / canonical 大量逻辑,留给后续专门做 SEO 双语 URL 的 PR - 不读后端 user_preferences:登录用户在设置页改完也只是写同一条 cookie, 这里读 cookie 已经统一视图
1 parent 6a72d76 commit 9f968be

2 files changed

Lines changed: 87 additions & 0 deletions

File tree

app/components/Header.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Link from "next/link";
22
import { getTranslations } from "next-intl/server";
33
import { ThemeToggle } from "./ThemeToggle";
4+
import { LocaleToggle } from "./LocaleToggle";
45
import { Button } from "@/components/ui/button";
56
import { MessageCircle } from "lucide-react";
67
import { Github as GithubIcon } from "./icons/Github";
@@ -106,6 +107,7 @@ export async function Header() {
106107
<MessageCircle className="h-4 w-4" />
107108
</a>
108109
</Button>
110+
<LocaleToggle />
109111
<ThemeToggle />
110112
<AuthNav />
111113
</div>

app/components/LocaleToggle.tsx

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"use client";
2+
3+
/**
4+
* Header 里的语言切换按钮(匿名也能用)。
5+
*
6+
* 为什么要做:
7+
* 之前切语言的唯一入口在 /settings 页面,UserMenu 里只有登录用户能看到。
8+
* 访客看到的永远是默认 zh,站点对英语用户非常不友好。
9+
*
10+
* 实现:
11+
* - 写 locale=zh|en 到 document.cookie(path=/,一年有效期,samesite=lax)
12+
* 字段和格式与 SettingsForm 完全一致,登录用户在设置页改的偏好仍然生效
13+
* - 切完 router.refresh() 让 SSR 重新渲染,server component(Hero / docs
14+
* 详情页等)从 cookie 读新 locale 切文案
15+
* - 简单的 ZH / EN 双字母展示,当前语言高亮;button 尺寸与 ThemeToggle 对齐
16+
*
17+
* 不做的事:
18+
* - 不读后端 user_preferences:游客无账号,登录用户在 /settings 改完也只是
19+
* 写同一条 cookie,这里读 cookie 即可统一视图。
20+
* - 不做 URL prefix 切换(/zh/xxx、/en/xxx):站点当前没有 i18n 路由分段,
21+
* 硬改会牵动大量路由和 sitemap。Cookie 方案延续现状,后续若要 SEO 双语
22+
* URL 再另开 PR。
23+
*/
24+
25+
import { useEffect, useState } from "react";
26+
import { useRouter } from "next/navigation";
27+
import { Button } from "../../components/ui/button";
28+
29+
type Locale = "zh" | "en";
30+
31+
function readLocaleCookie(): Locale {
32+
if (typeof document === "undefined") return "zh";
33+
const m = document.cookie.match(/(?:^|;\s*)locale=([^;]+)/);
34+
const v = m?.[1];
35+
return v === "en" ? "en" : "zh";
36+
}
37+
38+
function writeLocaleCookie(next: Locale) {
39+
// 一年;samesite=lax 够用(这个 cookie 不涉及跨站 POST)
40+
document.cookie = `locale=${next};path=/;max-age=${60 * 60 * 24 * 365};samesite=lax`;
41+
}
42+
43+
export function LocaleToggle() {
44+
const router = useRouter();
45+
// 初始 render 先给默认值,避免 SSR 和 CSR 结构不同触发 hydration 警告;
46+
// 真实值由 useEffect 读 cookie 后再覆盖
47+
const [locale, setLocale] = useState<Locale>("zh");
48+
const [ready, setReady] = useState(false);
49+
50+
useEffect(() => {
51+
setLocale(readLocaleCookie());
52+
setReady(true);
53+
}, []);
54+
55+
const toggle = () => {
56+
const next: Locale = locale === "zh" ? "en" : "zh";
57+
writeLocaleCookie(next);
58+
setLocale(next);
59+
if (typeof window !== "undefined" && window.umami) {
60+
window.umami.track("locale_toggle", { locale: next });
61+
}
62+
// 刷新 server component 树,重新按 cookie 渲染各页面
63+
router.refresh();
64+
};
65+
66+
return (
67+
<Button
68+
variant="ghost"
69+
size="sm"
70+
onClick={toggle}
71+
aria-label="Toggle language"
72+
title={locale === "zh" ? "切换为 English" : "Switch to 中文"}
73+
className="h-10 px-2 rounded-none font-mono text-xs uppercase tracking-widest transition-colors"
74+
>
75+
{/* 未 hydrate 时默认展示 ZH/EN 两字,hydrate 后根据 cookie 高亮当前 */}
76+
<span className={ready && locale === "zh" ? "font-bold" : "opacity-50"}>
77+
ZH
78+
</span>
79+
<span className="opacity-30 mx-0.5">/</span>
80+
<span className={ready && locale === "en" ? "font-bold" : "opacity-50"}>
81+
EN
82+
</span>
83+
</Button>
84+
);
85+
}

0 commit comments

Comments
 (0)