优化多语言处理

This commit is contained in:
xintaofei
2026-03-07 21:09:55 +08:00
parent 3cc28167e0
commit 8f265f8c0c
6 changed files with 193 additions and 36 deletions

View File

@@ -3,7 +3,10 @@ import "./globals.css"
import { JetBrains_Mono } from "next/font/google" import { JetBrains_Mono } from "next/font/google"
import { NextIntlClientProvider } from "next-intl" import { NextIntlClientProvider } from "next-intl"
import { AppI18nProvider } from "@/components/i18n-provider" 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 { ThemeProvider } from "@/components/theme-provider"
import { toIntlLocale } from "@/lib/i18n"
const jetbrainsMono = JetBrains_Mono({ const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"], subsets: ["latin"],
@@ -15,16 +18,30 @@ export const metadata: Metadata = {
description: "AI Coding Agent Conversation Manager", description: "AI Coding Agent Conversation Manager",
} }
export default function RootLayout({ export default async function RootLayout({
children, children,
}: { }: {
children: React.ReactNode children: React.ReactNode
}) { }) {
const appLocale = await resolveRequestLocale()
const initialLocale = toIntlLocale(appLocale)
const initialMessages = await getMessagesForLocale(appLocale)
return ( return (
<html lang="en" className={jetbrainsMono.variable} suppressHydrationWarning> <html
lang={initialLocale}
className={jetbrainsMono.variable}
suppressHydrationWarning
>
<body> <body>
<NextIntlClientProvider locale="en" messages={null}> <NextIntlClientProvider
<AppI18nProvider> locale={initialLocale}
messages={initialMessages}
>
<AppI18nProvider
initialLocale={initialLocale}
initialMessages={initialMessages}
>
<ThemeProvider <ThemeProvider
attribute="class" attribute="class"
defaultTheme="system" defaultTheme="system"

View File

@@ -12,14 +12,18 @@ import {
import { NextIntlClientProvider, type AbstractIntlMessages } from "next-intl" import { NextIntlClientProvider, type AbstractIntlMessages } from "next-intl"
import { getFallbackMessages, getMessagesForLocale } from "@/i18n/messages" import { getFallbackMessages, getMessagesForLocale } from "@/i18n/messages"
import { import {
APP_LOCALE_TO_INTL_LOCALE, fromIntlLocale,
DEFAULT_LANGUAGE_SETTINGS,
getSystemLocaleCandidates, getSystemLocaleCandidates,
LANGUAGE_COOKIE_KEY,
LANGUAGE_MODE_COOKIE_KEY,
LANGUAGE_SETTINGS_STORAGE_KEY, LANGUAGE_SETTINGS_STORAGE_KEY,
normalizeLanguageSettings, normalizeLanguageSettings,
resolveAppLocale, resolveAppLocale,
toIntlLocale,
type IntlLocale,
} from "@/lib/i18n" } from "@/lib/i18n"
import { getSystemLanguageSettings } from "@/lib/tauri" import { getSystemLanguageSettings } from "@/lib/tauri"
import { AppBootLoading } from "@/components/layout/app-boot-loading"
import type { AppLocale, SystemLanguageSettings } from "@/lib/types" import type { AppLocale, SystemLanguageSettings } from "@/lib/types"
interface AppI18nContextValue { interface AppI18nContextValue {
@@ -48,18 +52,6 @@ function getSystemLocaleServerSnapshot(): string {
return "" return ""
} }
function loadPersistedLanguageSettings(): SystemLanguageSettings | null {
if (typeof window === "undefined") return null
try {
const raw = window.localStorage.getItem(LANGUAGE_SETTINGS_STORAGE_KEY)
if (!raw) return null
return normalizeLanguageSettings(JSON.parse(raw) as SystemLanguageSettings)
} catch {
return null
}
}
function persistLanguageSettings(settings: SystemLanguageSettings) { function persistLanguageSettings(settings: SystemLanguageSettings) {
if (typeof window === "undefined") return if (typeof window === "undefined") return
@@ -73,6 +65,17 @@ function persistLanguageSettings(settings: SystemLanguageSettings) {
} }
} }
function persistLanguageCookies(
settings: SystemLanguageSettings,
appLocale: AppLocale
) {
if (typeof document === "undefined") return
const maxAge = 60 * 60 * 24 * 365
document.cookie = `${LANGUAGE_MODE_COOKIE_KEY}=${settings.mode}; Path=/; Max-Age=${maxAge}; SameSite=Lax`
document.cookie = `${LANGUAGE_COOKIE_KEY}=${toIntlLocale(appLocale)}; Path=/; Max-Age=${maxAge}; SameSite=Lax`
}
export function useAppI18n() { export function useAppI18n() {
const context = useContext(AppI18nContext) const context = useContext(AppI18nContext)
if (!context) { if (!context) {
@@ -81,14 +84,29 @@ export function useAppI18n() {
return context return context
} }
export function AppI18nProvider({ children }: { children: React.ReactNode }) { interface AppI18nProviderProps {
children: React.ReactNode
initialLocale?: IntlLocale
initialMessages?: AbstractIntlMessages
}
export function AppI18nProvider({
children,
initialLocale = "en",
initialMessages,
}: AppI18nProviderProps) {
const initialAppLocale = fromIntlLocale(initialLocale)
const [languageSettings, setLanguageSettingsState] = const [languageSettings, setLanguageSettingsState] =
useState<SystemLanguageSettings>( useState<SystemLanguageSettings>({
() => loadPersistedLanguageSettings() ?? DEFAULT_LANGUAGE_SETTINGS mode: "manual",
) language: initialAppLocale,
})
const [languageSettingsLoaded, setLanguageSettingsLoaded] = useState(false) const [languageSettingsLoaded, setLanguageSettingsLoaded] = useState(false)
const [messages, setMessages] = useState<AbstractIntlMessages>( const [messages, setMessages] = useState<AbstractIntlMessages>(
getFallbackMessages() initialMessages ?? getFallbackMessages()
)
const [messagesLocale, setMessagesLocale] = useState<AppLocale>(
initialMessages ? initialAppLocale : "en"
) )
const systemLocaleSnapshot = useSyncExternalStore( const systemLocaleSnapshot = useSyncExternalStore(
@@ -137,15 +155,22 @@ export function AppI18nProvider({ children }: { children: React.ReactNode }) {
[languageSettings, systemLocaleCandidates] [languageSettings, systemLocaleCandidates]
) )
const intlLocale = APP_LOCALE_TO_INTL_LOCALE[appLocale] useEffect(() => {
if (!languageSettingsLoaded) return
persistLanguageCookies(languageSettings, appLocale)
}, [appLocale, languageSettings, languageSettingsLoaded])
useEffect(() => { useEffect(() => {
if (appLocale === messagesLocale) {
return
}
let cancelled = false let cancelled = false
getMessagesForLocale(appLocale) getMessagesForLocale(appLocale)
.then((nextMessages) => { .then((nextMessages) => {
if (!cancelled) { if (!cancelled) {
setMessages(nextMessages) setMessages(nextMessages)
setMessagesLocale(appLocale)
} }
}) })
.catch((err) => { .catch((err) => {
@@ -155,11 +180,15 @@ export function AppI18nProvider({ children }: { children: React.ReactNode }) {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [appLocale]) }, [appLocale, messagesLocale])
const localeReady = appLocale === messagesLocale
const appReady = languageSettingsLoaded && localeReady
const activeIntlLocale = toIntlLocale(messagesLocale)
useEffect(() => { useEffect(() => {
document.documentElement.lang = intlLocale document.documentElement.lang = activeIntlLocale
}, [intlLocale]) }, [activeIntlLocale])
const contextValue = useMemo<AppI18nContextValue>( const contextValue = useMemo<AppI18nContextValue>(
() => ({ () => ({
@@ -173,8 +202,8 @@ export function AppI18nProvider({ children }: { children: React.ReactNode }) {
return ( return (
<AppI18nContext.Provider value={contextValue}> <AppI18nContext.Provider value={contextValue}>
<NextIntlClientProvider locale={intlLocale} messages={messages}> <NextIntlClientProvider locale={activeIntlLocale} messages={messages}>
{children} {appReady ? children : <AppBootLoading />}
</NextIntlClientProvider> </NextIntlClientProvider>
</AppI18nContext.Provider> </AppI18nContext.Provider>
) )

View File

@@ -0,0 +1,14 @@
"use client"
import { Loader2 } from "lucide-react"
export function AppBootLoading() {
return (
<div className="flex min-h-screen items-center justify-center bg-background text-foreground">
<div className="flex items-center gap-3 px-2 py-1">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="text-sm font-medium tracking-tight">codeg</span>
</div>
</div>
)
}

View File

@@ -1,7 +1,13 @@
import { getRequestConfig } from "next-intl/server" 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 () => ({ export default getRequestConfig(async () => {
locale: "en", const appLocale = await resolveRequestLocale()
messages: enMessages,
})) return {
locale: APP_LOCALE_TO_INTL_LOCALE[appLocale],
messages: await getMessagesForLocale(appLocale),
}
})

View File

@@ -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<AppLocale> {
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
}

View File

@@ -3,6 +3,8 @@ import type { AppLocale, SystemLanguageSettings } from "@/lib/types"
export const APP_LOCALES: readonly AppLocale[] = ["en", "zh_cn", "zh_tw"] export const APP_LOCALES: readonly AppLocale[] = ["en", "zh_cn", "zh_tw"]
const FALLBACK_APP_LOCALE: AppLocale = "en" const FALLBACK_APP_LOCALE: AppLocale = "en"
export const LANGUAGE_SETTINGS_STORAGE_KEY = "codeg.system_language_settings" 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 type IntlLocale = "en" | "zh-CN" | "zh-TW"
export const DEFAULT_LANGUAGE_SETTINGS: SystemLanguageSettings = { export const DEFAULT_LANGUAGE_SETTINGS: SystemLanguageSettings = {
@@ -16,10 +18,28 @@ export const APP_LOCALE_TO_INTL_LOCALE: Record<AppLocale, IntlLocale> = {
zh_tw: "zh-TW", zh_tw: "zh-TW",
} }
export const INTL_LOCALE_TO_APP_LOCALE: Record<IntlLocale, AppLocale> = {
en: "en",
"zh-CN": "zh_cn",
"zh-TW": "zh_tw",
}
export function isAppLocale(value: unknown): value is AppLocale { export function isAppLocale(value: unknown): value is AppLocale {
return APP_LOCALES.includes(value as 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( export function normalizeLanguageSettings(
settings: Partial<SystemLanguageSettings> | null | undefined settings: Partial<SystemLanguageSettings> | null | undefined
): SystemLanguageSettings { ): 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, "-") const normalized = localeTag.trim().toLowerCase().replace(/_/g, "-")
if (!normalized) return null if (!normalized) return null
@@ -54,6 +74,26 @@ function mapSystemLocaleToAppLocale(localeTag: string): AppLocale | null {
return 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[] { export function getSystemLocaleCandidates(): string[] {
if (typeof navigator === "undefined") return [] if (typeof navigator === "undefined") return []
@@ -67,7 +107,7 @@ export function getSystemLocaleCandidates(): string[] {
export function resolveSystemLocale(candidates: string[]): AppLocale | null { export function resolveSystemLocale(candidates: string[]): AppLocale | null {
for (const candidate of candidates) { for (const candidate of candidates) {
const resolved = mapSystemLocaleToAppLocale(candidate) const resolved = mapLocaleTagToAppLocale(candidate)
if (resolved) return resolved if (resolved) return resolved
} }