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