feat(frontend): add next-intl bilingual i18n for core paths

Wire next-intl without locale routes, add zh-CN/en catalogs, language
switcher, and migrate layout/auth/settings UI copy. Document i18n rules
in AGENTS.md and keep static export builds working.
This commit is contained in:
ryan
2026-07-24 16:25:41 +08:00
parent 9c1369186f
commit 1625cfb2fb
23 changed files with 1638 additions and 355 deletions
+72
View File
@@ -0,0 +1,72 @@
'use client';
import {
defaultLocale,
isAppLocale,
localeCookieName,
normalizeLocale,
type AppLocale,
} from './config';
const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365; // 1 year
function readCookie(name: string): string | null {
if (typeof document === 'undefined') return null;
const match = document.cookie
.split(';')
.map((part) => part.trim())
.find((part) => part.startsWith(`${name}=`));
if (!match) return null;
return decodeURIComponent(match.slice(name.length + 1));
}
export function getClientLocalePreference(): AppLocale | null {
const raw = readCookie(localeCookieName);
if (isAppLocale(raw)) return raw;
if (raw) return normalizeLocale(raw);
return null;
}
export function detectBrowserLocale(): AppLocale {
if (typeof navigator === 'undefined') return defaultLocale;
const languages =
navigator.languages?.length > 0
? navigator.languages
: [navigator.language];
for (const lang of languages) {
const lower = (lang ?? '').toLowerCase();
if (lower.startsWith('zh') || lower.startsWith('en')) {
return normalizeLocale(lang);
}
}
return defaultLocale;
}
export function resolveClientLocale(): AppLocale {
return getClientLocalePreference() ?? detectBrowserLocale();
}
export function setLocaleCookie(locale: AppLocale): void {
if (typeof document === 'undefined') return;
const secure =
typeof window !== 'undefined' && window.location.protocol === 'https:'
? '; Secure'
: '';
document.cookie = `${localeCookieName}=${encodeURIComponent(locale)}; Path=/; Max-Age=${COOKIE_MAX_AGE_SECONDS}; SameSite=Lax${secure}`;
try {
localStorage.setItem(localeCookieName, locale);
} catch {
// ignore quota / private mode
}
}
/** Persist locale and reload so server + client messages stay in sync. */
export function switchLocale(locale: AppLocale): void {
setLocaleCookie(locale);
if (typeof document !== 'undefined') {
document.documentElement.lang = locale;
}
if (typeof window !== 'undefined') {
window.location.reload();
}
}
+67
View File
@@ -0,0 +1,67 @@
export const locales = ['zh-CN', 'en'] as const;
export type AppLocale = (typeof locales)[number];
export const defaultLocale: AppLocale = 'zh-CN';
export const localeCookieName = 'NEXT_LOCALE';
export const localeLabels: Record<AppLocale, string> = {
'zh-CN': '中文',
en: 'English',
};
export function isAppLocale(
value: string | undefined | null,
): value is AppLocale {
return value === 'zh-CN' || value === 'en';
}
/** Normalize BCP 47 / browser tags into a supported app locale. */
export function normalizeLocale(input?: string | null): AppLocale {
if (!input) return defaultLocale;
const tag = input.trim().replaceAll('_', '-');
if (!tag) return defaultLocale;
const lower = tag.toLowerCase();
if (lower === 'zh-cn' || lower.startsWith('zh-hans') || lower === 'zh') {
return 'zh-CN';
}
if (lower.startsWith('zh')) {
// zh-TW / zh-Hant etc. map to zh-CN until a dedicated locale exists
return 'zh-CN';
}
if (lower === 'en' || lower.startsWith('en-')) {
return 'en';
}
return defaultLocale;
}
/** Pick the best locale from an Accept-Language style list. */
export function pickLocaleFromAcceptLanguage(
header?: string | null,
): AppLocale {
if (!header) return defaultLocale;
const candidates = header
.split(',')
.map((part) => {
const [tag, ...params] = part.trim().split(';');
const qParam = params.find((p) => p.trim().startsWith('q='));
const q = qParam ? Number.parseFloat(qParam.split('=')[1] ?? '1') : 1;
return { tag: tag?.trim() ?? '', q: Number.isFinite(q) ? q : 0 };
})
.filter((c) => c.tag)
.sort((a, b) => b.q - a.q);
for (const candidate of candidates) {
const normalized = normalizeLocale(candidate.tag);
// Only accept if the raw tag roughly matches a supported family
const lower = candidate.tag.toLowerCase();
if (lower.startsWith('zh') || lower.startsWith('en')) {
return normalized;
}
}
return defaultLocale;
}
+39
View File
@@ -0,0 +1,39 @@
import type { AppLocale } from './config';
import { defaultLocale } from './config';
const localeToIntl: Record<AppLocale, string> = {
'zh-CN': 'zh-CN',
en: 'en-US',
};
export function formatDateTime(
dateStr: string | Date,
locale: AppLocale = defaultLocale,
timeZone = 'Asia/Shanghai',
): string {
try {
const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr;
return new Intl.DateTimeFormat(localeToIntl[locale] ?? locale, {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date);
} catch {
return String(dateStr);
}
}
export function formatNumber(
value: number,
locale: AppLocale = defaultLocale,
): string {
try {
return new Intl.NumberFormat(localeToIntl[locale] ?? locale).format(value);
} catch {
return String(value);
}
}
+44
View File
@@ -0,0 +1,44 @@
import { cookies, headers } from 'next/headers';
import { getRequestConfig } from 'next-intl/server';
import {
defaultLocale,
isAppLocale,
localeCookieName,
normalizeLocale,
pickLocaleFromAcceptLanguage,
type AppLocale,
} from './config';
async function resolveRequestLocale(): Promise<AppLocale> {
try {
const cookieStore = await cookies();
const cookieLocale = cookieStore.get(localeCookieName)?.value;
if (isAppLocale(cookieLocale)) {
return cookieLocale;
}
if (cookieLocale) {
return normalizeLocale(cookieLocale);
}
} catch {
// Static export / non-request contexts cannot read cookies.
}
try {
const headerStore = await headers();
const acceptLanguage = headerStore.get('accept-language');
return pickLocaleFromAcceptLanguage(acceptLanguage);
} catch {
// Static export build time.
}
return defaultLocale;
}
export default getRequestConfig(async () => {
const locale = await resolveRequestLocale();
const messages = (await import(`../messages/${locale}.json`)).default;
return {
locale,
messages,
};
});