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