From 8f265f8c0cdef91f907ef1175ab99d85c4d7a9be Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 7 Mar 2026 21:09:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=A4=9A=E8=AF=AD=E8=A8=80?= =?UTF-8?q?=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/layout.tsx | 25 +++++-- src/components/i18n-provider.tsx | 79 +++++++++++++++------- src/components/layout/app-boot-loading.tsx | 14 ++++ src/i18n/request.ts | 16 +++-- src/i18n/resolve-request-locale.ts | 51 ++++++++++++++ src/lib/i18n.ts | 44 +++++++++++- 6 files changed, 193 insertions(+), 36 deletions(-) create mode 100644 src/components/layout/app-boot-loading.tsx create mode 100644 src/i18n/resolve-request-locale.ts diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9e69c47..70a9909 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,7 +3,10 @@ import "./globals.css" import { JetBrains_Mono } from "next/font/google" import { NextIntlClientProvider } from "next-intl" import { AppI18nProvider } from "@/components/i18n-provider" +import { getMessagesForLocale } from "@/i18n/messages" +import { resolveRequestLocale } from "@/i18n/resolve-request-locale" import { ThemeProvider } from "@/components/theme-provider" +import { toIntlLocale } from "@/lib/i18n" const jetbrainsMono = JetBrains_Mono({ subsets: ["latin"], @@ -15,16 +18,30 @@ export const metadata: Metadata = { description: "AI Coding Agent Conversation Manager", } -export default function RootLayout({ +export default async function RootLayout({ children, }: { children: React.ReactNode }) { + const appLocale = await resolveRequestLocale() + const initialLocale = toIntlLocale(appLocale) + const initialMessages = await getMessagesForLocale(appLocale) + return ( - + - - + + ( - () => loadPersistedLanguageSettings() ?? DEFAULT_LANGUAGE_SETTINGS - ) + useState({ + mode: "manual", + language: initialAppLocale, + }) const [languageSettingsLoaded, setLanguageSettingsLoaded] = useState(false) const [messages, setMessages] = useState( - getFallbackMessages() + initialMessages ?? getFallbackMessages() + ) + const [messagesLocale, setMessagesLocale] = useState( + initialMessages ? initialAppLocale : "en" ) const systemLocaleSnapshot = useSyncExternalStore( @@ -137,15 +155,22 @@ export function AppI18nProvider({ children }: { children: React.ReactNode }) { [languageSettings, systemLocaleCandidates] ) - const intlLocale = APP_LOCALE_TO_INTL_LOCALE[appLocale] + useEffect(() => { + if (!languageSettingsLoaded) return + persistLanguageCookies(languageSettings, appLocale) + }, [appLocale, languageSettings, languageSettingsLoaded]) useEffect(() => { + if (appLocale === messagesLocale) { + return + } let cancelled = false getMessagesForLocale(appLocale) .then((nextMessages) => { if (!cancelled) { setMessages(nextMessages) + setMessagesLocale(appLocale) } }) .catch((err) => { @@ -155,11 +180,15 @@ export function AppI18nProvider({ children }: { children: React.ReactNode }) { return () => { cancelled = true } - }, [appLocale]) + }, [appLocale, messagesLocale]) + + const localeReady = appLocale === messagesLocale + const appReady = languageSettingsLoaded && localeReady + const activeIntlLocale = toIntlLocale(messagesLocale) useEffect(() => { - document.documentElement.lang = intlLocale - }, [intlLocale]) + document.documentElement.lang = activeIntlLocale + }, [activeIntlLocale]) const contextValue = useMemo( () => ({ @@ -173,8 +202,8 @@ export function AppI18nProvider({ children }: { children: React.ReactNode }) { return ( - - {children} + + {appReady ? children : } ) diff --git a/src/components/layout/app-boot-loading.tsx b/src/components/layout/app-boot-loading.tsx new file mode 100644 index 0000000..a409b1c --- /dev/null +++ b/src/components/layout/app-boot-loading.tsx @@ -0,0 +1,14 @@ +"use client" + +import { Loader2 } from "lucide-react" + +export function AppBootLoading() { + return ( +
+
+ + codeg +
+
+ ) +} diff --git a/src/i18n/request.ts b/src/i18n/request.ts index 431baed..4239fe6 100644 --- a/src/i18n/request.ts +++ b/src/i18n/request.ts @@ -1,7 +1,13 @@ import { getRequestConfig } from "next-intl/server" -import enMessages from "@/i18n/messages/en.json" +import { getMessagesForLocale } from "@/i18n/messages" +import { resolveRequestLocale } from "@/i18n/resolve-request-locale" +import { APP_LOCALE_TO_INTL_LOCALE } from "@/lib/i18n" -export default getRequestConfig(async () => ({ - locale: "en", - messages: enMessages, -})) +export default getRequestConfig(async () => { + const appLocale = await resolveRequestLocale() + + return { + locale: APP_LOCALE_TO_INTL_LOCALE[appLocale], + messages: await getMessagesForLocale(appLocale), + } +}) diff --git a/src/i18n/resolve-request-locale.ts b/src/i18n/resolve-request-locale.ts new file mode 100644 index 0000000..c438696 --- /dev/null +++ b/src/i18n/resolve-request-locale.ts @@ -0,0 +1,51 @@ +import { cookies, headers } from "next/headers" +import { + LANGUAGE_COOKIE_KEY, + LANGUAGE_MODE_COOKIE_KEY, + parseAcceptLanguageHeader, + parseLocaleFromCookieValue, + resolveSystemLocale, +} from "@/lib/i18n" +import type { AppLocale, LanguageMode } from "@/lib/types" + +const FALLBACK_LOCALE: AppLocale = "en" + +function parseLanguageModeCookie(value: string | undefined): LanguageMode { + return value === "manual" ? "manual" : "system" +} + +export async function resolveRequestLocale(): Promise { + let configuredLocale: AppLocale | null = null + let languageMode: LanguageMode = "system" + + try { + const cookieStore = await cookies() + configuredLocale = parseLocaleFromCookieValue( + cookieStore.get(LANGUAGE_COOKIE_KEY)?.value + ) + languageMode = parseLanguageModeCookie( + cookieStore.get(LANGUAGE_MODE_COOKIE_KEY)?.value + ) + } catch { + // Ignore when request cookies are unavailable (e.g. static export build). + } + + if (configuredLocale && languageMode === "manual") { + return configuredLocale + } + + try { + const headerStore = await headers() + const candidates = parseAcceptLanguageHeader( + headerStore.get("accept-language") + ) + const fromHeader = resolveSystemLocale(candidates) + if (fromHeader) { + return fromHeader + } + } catch { + // Ignore when request headers are unavailable (e.g. static export build). + } + + return configuredLocale ?? FALLBACK_LOCALE +} diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 805dbf9..839726e 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -3,6 +3,8 @@ import type { AppLocale, SystemLanguageSettings } from "@/lib/types" export const APP_LOCALES: readonly AppLocale[] = ["en", "zh_cn", "zh_tw"] const FALLBACK_APP_LOCALE: AppLocale = "en" export const LANGUAGE_SETTINGS_STORAGE_KEY = "codeg.system_language_settings" +export const LANGUAGE_MODE_COOKIE_KEY = "codeg.language_mode" +export const LANGUAGE_COOKIE_KEY = "codeg.locale" export type IntlLocale = "en" | "zh-CN" | "zh-TW" export const DEFAULT_LANGUAGE_SETTINGS: SystemLanguageSettings = { @@ -16,10 +18,28 @@ export const APP_LOCALE_TO_INTL_LOCALE: Record = { zh_tw: "zh-TW", } +export const INTL_LOCALE_TO_APP_LOCALE: Record = { + en: "en", + "zh-CN": "zh_cn", + "zh-TW": "zh_tw", +} + export function isAppLocale(value: unknown): value is AppLocale { return APP_LOCALES.includes(value as AppLocale) } +export function isIntlLocale(value: unknown): value is IntlLocale { + return value === "en" || value === "zh-CN" || value === "zh-TW" +} + +export function toIntlLocale(locale: AppLocale): IntlLocale { + return APP_LOCALE_TO_INTL_LOCALE[locale] +} + +export function fromIntlLocale(locale: IntlLocale): AppLocale { + return INTL_LOCALE_TO_APP_LOCALE[locale] +} + export function normalizeLanguageSettings( settings: Partial | null | undefined ): SystemLanguageSettings { @@ -34,7 +54,7 @@ export function normalizeLanguageSettings( } } -function mapSystemLocaleToAppLocale(localeTag: string): AppLocale | null { +export function mapLocaleTagToAppLocale(localeTag: string): AppLocale | null { const normalized = localeTag.trim().toLowerCase().replace(/_/g, "-") if (!normalized) return null @@ -54,6 +74,26 @@ function mapSystemLocaleToAppLocale(localeTag: string): AppLocale | null { return null } +export function parseAcceptLanguageHeader(value: string | null): string[] { + if (!value) return [] + + return value + .split(",") + .map((entry) => entry.split(";")[0]?.trim()) + .filter((entry): entry is string => Boolean(entry)) +} + +export function parseLocaleFromCookieValue( + value: string | undefined +): AppLocale | null { + if (!value) return null + + if (isAppLocale(value)) return value + if (isIntlLocale(value)) return fromIntlLocale(value) + + return mapLocaleTagToAppLocale(value) +} + export function getSystemLocaleCandidates(): string[] { if (typeof navigator === "undefined") return [] @@ -67,7 +107,7 @@ export function getSystemLocaleCandidates(): string[] { export function resolveSystemLocale(candidates: string[]): AppLocale | null { for (const candidate of candidates) { - const resolved = mapSystemLocaleToAppLocale(candidate) + const resolved = mapLocaleTagToAppLocale(candidate) if (resolved) return resolved }