feat(i18n): Header 加匿名可用的 ZH/EN 语言切换按钮#300
Conversation
之前切语言的唯一入口在 /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 已经统一视图
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
This PR makes language switching available to anonymous visitors by adding a locale toggle to the site header, using the existing locale cookie mechanism already used by /settings and server-rendered pages.
Changes:
- Added a new client component
LocaleTogglethat reads/writes thelocale=zh|encookie and triggersrouter.refresh()to re-render server components in the selected language. - Mounted the new locale toggle button in the header UI (next to the theme toggle).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| app/components/LocaleToggle.tsx | New client-side header locale toggle that persists preference via cookie and refreshes SSR content. |
| app/components/Header.tsx | Adds LocaleToggle into the header action row. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| return ( | ||
| <Button | ||
| variant="ghost" | ||
| size="sm" | ||
| onClick={toggle} | ||
| aria-label="Toggle language" | ||
| title={locale === "zh" ? "切换为 English" : "Switch to 中文"} |
There was a problem hiding this comment.
title 文案在首屏会基于默认 state(zh)渲染;如果用户 cookie 实际是 en,水合前的 tooltip 会短暂显示为“切换为 English”。可以在 ready === false 时使用中性 title(如 "Toggle language"),或把 title 文案也用 ready/cookie 读到后再切换,避免误导。
| return ( | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={toggle} | |
| aria-label="Toggle language" | |
| title={locale === "zh" ? "切换为 English" : "Switch to 中文"} | |
| const title = !ready | |
| ? "Toggle language" | |
| : locale === "zh" | |
| ? "切换为 English" | |
| : "Switch to 中文"; | |
| return ( | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={toggle} | |
| aria-label="Toggle language" | |
| title={title} |
| function readLocaleCookie(): Locale { | ||
| if (typeof document === "undefined") return "zh"; | ||
| const m = document.cookie.match(/(?:^|;\s*)locale=([^;]+)/); | ||
| const v = m?.[1]; | ||
| return v === "en" ? "en" : "zh"; | ||
| } | ||
|
|
||
| function writeLocaleCookie(next: Locale) { | ||
| // 一年;samesite=lax 够用(这个 cookie 不涉及跨站 POST) | ||
| document.cookie = `locale=${next};path=/;max-age=${60 * 60 * 24 * 365};samesite=lax`; | ||
| } | ||
|
|
||
| export function LocaleToggle() { | ||
| const router = useRouter(); | ||
| // 初始 render 先给默认值,避免 SSR 和 CSR 结构不同触发 hydration 警告; | ||
| // 真实值由 useEffect 读 cookie 后再覆盖 | ||
| const [locale, setLocale] = useState<Locale>("zh"); |
There was a problem hiding this comment.
这里的 locale cookie 读取/写入逻辑(正则、默认值、max-age / samesite 等)与 app/settings/SettingsForm.tsx 重复;两处都宣称“格式完全一致”,后续任一处改动容易产生 drift。建议抽到一个共享 helper(如 lib/locale-cookie.ts),由 SettingsForm / LocaleToggle 共同复用。
| function readLocaleCookie(): Locale { | |
| if (typeof document === "undefined") return "zh"; | |
| const m = document.cookie.match(/(?:^|;\s*)locale=([^;]+)/); | |
| const v = m?.[1]; | |
| return v === "en" ? "en" : "zh"; | |
| } | |
| function writeLocaleCookie(next: Locale) { | |
| // 一年;samesite=lax 够用(这个 cookie 不涉及跨站 POST) | |
| document.cookie = `locale=${next};path=/;max-age=${60 * 60 * 24 * 365};samesite=lax`; | |
| } | |
| export function LocaleToggle() { | |
| const router = useRouter(); | |
| // 初始 render 先给默认值,避免 SSR 和 CSR 结构不同触发 hydration 警告; | |
| // 真实值由 useEffect 读 cookie 后再覆盖 | |
| const [locale, setLocale] = useState<Locale>("zh"); | |
| const DEFAULT_LOCALE: Locale = "zh"; | |
| const LOCALE_COOKIE_NAME = "locale"; | |
| const LOCALE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; | |
| const LOCALE_COOKIE_PATTERN = new RegExp( | |
| `(?:^|;\\s*)${LOCALE_COOKIE_NAME}=([^;]+)`, | |
| ); | |
| function normalizeLocale(value?: string): Locale { | |
| return value === "en" ? "en" : DEFAULT_LOCALE; | |
| } | |
| function buildLocaleCookie(next: Locale): string { | |
| // 一年;samesite=lax 够用(这个 cookie 不涉及跨站 POST) | |
| return `${LOCALE_COOKIE_NAME}=${next};path=/;max-age=${LOCALE_COOKIE_MAX_AGE};samesite=lax`; | |
| } | |
| function readLocaleCookie(): Locale { | |
| if (typeof document === "undefined") return DEFAULT_LOCALE; | |
| const m = document.cookie.match(LOCALE_COOKIE_PATTERN); | |
| return normalizeLocale(m?.[1]); | |
| } | |
| function writeLocaleCookie(next: Locale) { | |
| document.cookie = buildLocaleCookie(next); | |
| } | |
| export function LocaleToggle() { | |
| const router = useRouter(); | |
| // 初始 render 先给默认值,避免 SSR 和 CSR 结构不同触发 hydration 警告; | |
| // 真实值由 useEffect 读 cookie 后再覆盖 | |
| const [locale, setLocale] = useState<Locale>(DEFAULT_LOCALE); |
动机
之前切语言唯一入口在 `/settings`,UserMenu 里只有登录用户能看到。匿名访客看到的永远是默认 `zh`,对英语访客不友好。
实现
新增 `app/components/LocaleToggle.tsx` 客户端组件,挂在 Header 右上(ThemeToggle 左边)。
刻意不做