i18n 代码进入URL路由,优化重定向规则

This commit is contained in:
2026-09-16 20:03:41 +08:00
parent d4bd36f5f8
commit f63f70d08b
42 changed files with 361 additions and 141 deletions
+11
View File
@@ -0,0 +1,11 @@
"use client";
import { createContext, useContext } from "react";
import type { Locale } from "@/lib/i18n";
export const LocaleContext = createContext<Locale>("zh-Hans");
export function useRouteLocale() { return useContext(LocaleContext); }
export function LocaleProvider({ locale, children }: { locale: Locale; children: React.ReactNode }) {
return <LocaleContext.Provider value={locale}>{children}</LocaleContext.Provider>;
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import { useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { applyLocale, getInitialLocale } from "@/lib/i18n";
export function LocaleUrlSync() {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
applyLocale(getInitialLocale());
}, [pathname, searchParams]);
return null;
}
+16 -1
View File
@@ -1,4 +1,10 @@
"use client";
import { useRouteLocale } from "@/components/layout/locale-context";
import Link from "next/link";
import { useEffect, useState } from "react";
import { getInitialLocale, withLocale, type Locale } from "@/lib/i18n";
export function PageBreadcrumb({
items,
@@ -7,12 +13,21 @@ export function PageBreadcrumb({
items: Array<{ label: string; href?: string }>;
ariaLabel?: string;
}) {
const [locale, setLocale] = useState<Locale>(useRouteLocale());
useEffect(() => {
const syncLocale = () => setLocale(getInitialLocale());
syncLocale();
window.addEventListener("kr-locale-change", syncLocale);
return () => window.removeEventListener("kr-locale-change", syncLocale);
}, []);
return (
<nav className="page-breadcrumb" aria-label={ariaLabel}>
{items.map((item, index) => (
<span className="page-breadcrumb-item" key={`${item.label}-${index}`}>
{index > 0 && <span className="page-breadcrumb-separator" aria-hidden="true">›</span>}
{item.href ? <Link href={item.href}>{item.label}</Link> : <span>{item.label}</span>}
{item.href ? <Link href={withLocale(item.href, locale)}>{item.label}</Link> : <span>{item.label}</span>}
</span>
))}
</nav>
+93
View File
@@ -0,0 +1,93 @@
import type { Metadata, Viewport } from "next";
import { Suspense } from "react";
import "@/app/globals.css";
import type { Locale } from "@/lib/i18n";
import { LocaleProvider } from "@/components/layout/locale-context";
import { SiteFooter } from "@/components/layout/site-footer";
import { SiteHeader } from "@/components/layout/site-header";
import { LocaleUrlSync } from "@/components/layout/locale-url-sync";
export const metadata: Metadata = {
title: {
default: "基沃托斯铁道公团 KivotosRailway",
template: "%s | 基沃托斯铁道公团 KivotosRailway",
},
description: "Kivotos Railway website",
icons: {
icon: "/icon/icon.svg",
},
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
};
export default function RootLayout({
children,
locale = "zh-Hans",
}: Readonly<{ children: React.ReactNode; locale?: Locale }>) {
return (
<html lang={locale} suppressHydrationWarning>
<head>
{/* Run during HTML parsing, before the body can paint. */}
<script
id="theme-preference"
dangerouslySetInnerHTML={{ __html: `(() => {
const isTheme = (value) => value === 'light' || value === 'system' || value === 'dark';
let preference = 'system';
try {
const cookieTheme = document.cookie.split(';').map((entry) => entry.trim()).find((entry) => entry.startsWith('kr-theme='))?.slice('kr-theme='.length);
const savedTheme = isTheme(cookieTheme) ? cookieTheme : localStorage.getItem('kr-theme');
if (isTheme(savedTheme)) preference = savedTheme;
} catch {}
const theme = preference === 'system' ? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : preference;
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
})();` }}
/>
<script
id="locale-canonicalization"
dangerouslySetInnerHTML={{ __html: `(() => {
const canonicalLocale = (value) => {
const language = value?.toLowerCase().replaceAll('_', '-');
if (!language) return null;
if (/^zh(?:-|$)/.test(language)) return /^zh-(tw|hk|mo)(-|$)|-hant(?:-|$)/.test(language) ? 'zh-Hant' : 'zh-Hans';
if (/^en(?:-|$)/.test(language)) return 'en';
if (/^(ja|jp)(?:-|$)/.test(language)) return 'jp';
return null;
};
const parts = location.pathname.split('/');
const firstSegment = parts[1];
const pathLocale = canonicalLocale(firstSegment);
const isUnknownLanguagePath = !pathLocale && /^[a-z]{2,3}(?:-[a-z0-9]+)*$/i.test(firstSegment ?? '');
let locale = pathLocale;
if (!locale) {
try { locale = canonicalLocale(localStorage.getItem('kr-locale')); } catch {}
}
if (!locale) {
for (const language of navigator.languages?.length ? navigator.languages : [navigator.language]) {
locale = canonicalLocale(language);
if (locale) break;
}
}
locale ??= 'en';
const path = pathLocale || isUnknownLanguagePath ? '/' + parts.slice(2).join('/') : location.pathname;
const normalizedPath = path.replace(/\\/$/, '');
const destination = '/' + locale + normalizedPath + '/';
const search = new URLSearchParams(location.search);
search.delete('lang');
const canonicalUrl = destination + (search.size ? '?' + search : '') + location.hash;
if (location.pathname + location.search + location.hash !== canonicalUrl) location.replace(canonicalUrl);
})();` }}
/>
</head>
<body>
<LocaleProvider locale={locale}>
<Suspense fallback={null}><LocaleUrlSync /></Suspense>
<SiteHeader />{children}<SiteFooter />
</LocaleProvider>
</body>
</html>
);
}
+6 -4
View File
@@ -1,8 +1,10 @@
"use client";
import { useRouteLocale } from "@/components/layout/locale-context";
import Image from "next/image";
import { useEffect, useState } from "react";
import { applyLocale, getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
import { applyLocale, getInitialLocale, withLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
import { ThemeToggle } from "@/components/layout/theme-toggle";
const footerColumnHrefs = [
@@ -18,7 +20,7 @@ function isIcpVisibleHost(hostname: string) {
}
export function SiteFooter() {
const [locale, setLocale] = useState<Locale>("zh-CN");
const [locale, setLocale] = useState<Locale>(useRouteLocale());
const [showIcp, setShowIcp] = useState(false);
useEffect(() => {
@@ -77,7 +79,7 @@ export function SiteFooter() {
<h3>{group.title}</h3>
<ul>
{group.links.map((link, linkIndex) => (
<li key={`column-${groupIndex}-link-${linkIndex}`}><a href={footerColumnHrefs[groupIndex][linkIndex]}>{link}</a></li>
<li key={`column-${groupIndex}-link-${linkIndex}`}><a href={withLocale(footerColumnHrefs[groupIndex][linkIndex], locale)}>{link}</a></li>
))}
</ul>
</div>
@@ -100,7 +102,7 @@ export function SiteFooter() {
<div className="site-footer-meta">
<div className="site-footer-legal-row">
{messages.footer.legal.map((item, itemIndex) => (
<a key={`legal-${itemIndex}`} href={footerLegalHrefs[itemIndex]}>{item}</a>
<a key={`legal-${itemIndex}`} href={withLocale(footerLegalHrefs[itemIndex], locale)}>{item}</a>
))}
</div>
<p className="site-footer-description">{messages.footer.description}</p>
+7 -5
View File
@@ -1,18 +1,20 @@
"use client";
import { useRouteLocale } from "@/components/layout/locale-context";
import Link from "next/link";
import Image from "next/image";
import { useEffect, useState } from "react";
import { applyLocale, getInitialLocale, getLocaleMessages, localeLabels, locales, localeShortLabels, type Locale } from "@/lib/i18n";
import { applyLocale, getInitialLocale, withLocale, withoutLocale, getLocaleMessages, localeLabels, locales, localeShortLabels, type Locale } from "@/lib/i18n";
export function SiteHeader() {
const [locale, setLocale] = useState<Locale>("zh-CN");
const [locale, setLocale] = useState<Locale>(useRouteLocale());
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => {
const updateDocumentTitle = (nextLocale: Locale) => {
const messages = getLocaleMessages(nextLocale);
const pathname = window.location.pathname;
const pathname = withoutLocale(window.location.pathname).replace(/\/$/, "") || "/";
const currentTitle = document.title.split(" | ")[0];
const pageLabel = pathname === "/"
? ""
@@ -54,7 +56,7 @@ export function SiteHeader() {
return (
<header className="site-header">
<Link className="site-logo" href="/" aria-label={`Kivotos Railway ${localeLabels[locale]}`}>
<Link className="site-logo" href={withLocale("/", locale)} aria-label={`Kivotos Railway ${localeLabels[locale]}`}>
<Image
className="site-logo-image"
src="/icon/kivotosrailway.svg"
@@ -68,7 +70,7 @@ export function SiteHeader() {
{messages.header.nav.map((label, index) => {
const href = ["/", "/news", "/railway", "/about", "/members"][index] ?? "/";
return (
<Link key={href} href={href} onClick={() => setMenuOpen(false)}>
<Link key={href} href={withLocale(href, locale)} onClick={() => setMenuOpen(false)}>
{label}
</Link>
);
+3 -1
View File
@@ -1,5 +1,7 @@
"use client";
import { useRouteLocale } from "@/components/layout/locale-context";
import Image from "next/image";
import { useEffect, useState } from "react";
import { getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
@@ -43,7 +45,7 @@ function applyTheme(preference: ThemePreference) {
export function ThemeToggle() {
const [themePreference, setThemePreference] = useState<ThemePreference>("system");
const [locale, setLocale] = useState<Locale>("zh-CN");
const [locale, setLocale] = useState<Locale>(useRouteLocale());
useEffect(() => {
const syncTheme = () => {