From 5aaaf8f197792cffc304618151f09ae4766d9550 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 16 Aug 2026 17:29:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20=E4=BF=AE=E5=A4=8D=E9=9D=99?= =?UTF-8?q?=E6=80=81=E5=AF=BC=E5=87=BA=E4=B8=8B=E5=88=87=E6=8D=A2=E8=AF=AD?= =?UTF-8?q?=E8=A8=80=E6=97=A0=E6=95=88=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/index.md | 1 + .../providers/intl-provider.test.tsx | 73 +++++++++++++++++++ .../components/providers/intl-provider.tsx | 37 +++++++++- 3 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 frontend/components/providers/intl-provider.test.tsx diff --git a/docs/changelog/index.md b/docs/changelog/index.md index b5ceb0f1..2a6d4af6 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -16,6 +16,7 @@ sidebar: false ### 🛠 修复 - 修复自定义 Webhook 推送在企业微信/钉钉返回 HTTP 200 但 `errcode` 非零时仍记为成功的问题;任务日志会记录上游响应体。 - 修复 OpenTelemetry Resource 绑定 semconv schema 版本导致 SDK 升级后可能无法启动的问题。 +- 修复静态导出(build:embed)部署下切换语言无效的问题:此前页面在构建时固定为默认中文,运行时不再读取 `NEXT_LOCALE`;现在客户端会按 cookie/浏览器语言重新解析并切换界面语言与 `html lang`。 ### 💄 其他/体验 - 前端使用 `next/font` 自托管 Inter,并忽略浏览器扩展改写 `body` 属性引起的 hydration 警告。 diff --git a/frontend/components/providers/intl-provider.test.tsx b/frontend/components/providers/intl-provider.test.tsx new file mode 100644 index 00000000..0369cfd7 --- /dev/null +++ b/frontend/components/providers/intl-provider.test.tsx @@ -0,0 +1,73 @@ +import { render, screen } from '@testing-library/react'; +import { useTranslations } from 'next-intl'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import enMessages from '@/messages/en.json'; +import zhCNMessages from '@/messages/zh-CN.json'; + +import { AppIntlProvider } from './intl-provider'; + +function Probe() { + const t = useTranslations('common'); + return ; +} + +const zhSave = (zhCNMessages.common as Record).save; +const enSave = (enMessages.common as Record).save; + +describe('AppIntlProvider (static export locale resolution)', () => { + beforeEach(() => { + // Deterministic browser language (jsdom defaults to en-US). + Object.defineProperty(navigator, 'languages', { + value: ['zh-CN'], + configurable: true, + }); + Object.defineProperty(navigator, 'language', { + value: 'zh-CN', + configurable: true, + }); + document.documentElement.lang = 'zh-CN'; // as pre-rendered by SSR + }); + + afterEach(() => { + document.cookie = 'NEXT_LOCALE=; Max-Age=0; Path=/'; + }); + + it('flips to the NEXT_LOCALE cookie locale after mount when SSR locale is stale', async () => { + // Static export pre-renders with the build-time default (zh-CN) even when + // the user previously chose en — the exact reported bug. + document.cookie = 'NEXT_LOCALE=en; Path=/'; + + render( + + + , + ); + + expect(await screen.findByText(enSave)).toBeInTheDocument(); + expect(document.documentElement.lang).toBe('en'); + }); + + it('keeps the SSR locale when the cookie matches it', () => { + document.cookie = 'NEXT_LOCALE=zh-CN; Path=/'; + + render( + + + , + ); + + expect(screen.getByText(zhSave)).toBeInTheDocument(); + expect(document.documentElement.lang).toBe('zh-CN'); + }); + + it('does not flip without a cookie preference', () => { + render( + + + , + ); + + expect(screen.getByText(zhSave)).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/components/providers/intl-provider.tsx b/frontend/components/providers/intl-provider.tsx index bb2500f0..2a08b0b9 100644 --- a/frontend/components/providers/intl-provider.tsx +++ b/frontend/components/providers/intl-provider.tsx @@ -1,9 +1,18 @@ 'use client'; +import { useEffect, useState } from 'react'; import { NextIntlClientProvider } from 'next-intl'; import type { AbstractIntlMessages } from 'next-intl'; import type { ReactNode } from 'react'; +import { resolveClientLocale } from '@/i18n/client'; import type { AppLocale } from '@/i18n/config'; +import enMessages from '@/messages/en.json'; +import zhCNMessages from '@/messages/zh-CN.json'; + +const messagesByLocale: Record = { + 'zh-CN': zhCNMessages, + en: enMessages, +}; type Props = { locale: AppLocale; @@ -11,14 +20,36 @@ type Props = { children: ReactNode; }; -export function AppIntlProvider({ locale, messages, children }: Props) { +export function AppIntlProvider({ + locale: ssrLocale, + messages: ssrMessages, + children, +}: Props) { + // Static export (NEXT_STANDALONE_EXPORT) pre-renders every page at build time + // with the default locale, so the SSR locale is stale at runtime. Re-resolve + // from the NEXT_LOCALE cookie / browser language on the client. The first + // render stays identical to SSR to avoid a hydration mismatch, then flips — + // the brief flash matches the theme-hydration pattern (suppressHydrationWarning + // is already set on /). + const [clientLocale, setClientLocale] = useState(null); + + useEffect(() => { + const resolved = resolveClientLocale(); + if (resolved !== ssrLocale) { + setClientLocale(resolved); + document.documentElement.lang = resolved; + } + }, [ssrLocale]); + + const locale = clientLocale ?? ssrLocale; + return ( {children} ); -} +} \ No newline at end of file