diff --git a/src/app/[locale]/dashboard/audit-logs/page.tsx b/src/app/[locale]/dashboard/audit-logs/page.tsx
index 8ede2bbc3..2f41d9af8 100644
--- a/src/app/[locale]/dashboard/audit-logs/page.tsx
+++ b/src/app/[locale]/dashboard/audit-logs/page.tsx
@@ -17,7 +17,7 @@ export default async function AuditLogsPage({ params }: { params: Promise<{ loca
return redirect({ href: "/dashboard", locale });
}
- const t = await getTranslations("auditLogs");
+ const t = await getTranslations({ locale, namespace: "auditLogs" });
return (
diff --git a/src/app/[locale]/dashboard/availability/page.tsx b/src/app/[locale]/dashboard/availability/page.tsx
index c00f17210..26801cbd8 100644
--- a/src/app/[locale]/dashboard/availability/page.tsx
+++ b/src/app/[locale]/dashboard/availability/page.tsx
@@ -10,8 +10,13 @@ import { AvailabilityDashboardSkeleton } from "./_components/availability-skelet
export const dynamic = "force-dynamic";
-export default async function AvailabilityPage() {
- const t = await getTranslations("dashboard");
+export default async function AvailabilityPage({
+ params,
+}: {
+ params: Promise<{ locale: string }>;
+}) {
+ const { locale } = await params;
+ const t = await getTranslations({ locale, namespace: "dashboard" });
const session = await getSession();
// Only admin can access availability monitoring
diff --git a/src/app/[locale]/dashboard/layout.tsx b/src/app/[locale]/dashboard/layout.tsx
index 2fe637f75..3346f8995 100644
--- a/src/app/[locale]/dashboard/layout.tsx
+++ b/src/app/[locale]/dashboard/layout.tsx
@@ -28,7 +28,7 @@ export default async function DashboardLayout({
return (
-
+
{children}
diff --git a/src/app/[locale]/dashboard/leaderboard/page.tsx b/src/app/[locale]/dashboard/leaderboard/page.tsx
index 97d518fc6..83288b6d5 100644
--- a/src/app/[locale]/dashboard/leaderboard/page.tsx
+++ b/src/app/[locale]/dashboard/leaderboard/page.tsx
@@ -10,8 +10,9 @@ import { LeaderboardView } from "./_components/leaderboard-view";
export const dynamic = "force-dynamic";
-export default async function LeaderboardPage() {
- const t = await getTranslations("dashboard");
+export default async function LeaderboardPage({ params }: { params: Promise<{ locale: string }> }) {
+ const { locale } = await params;
+ const t = await getTranslations({ locale, namespace: "dashboard" });
// 获取用户 session 和系统设置
const session = await getSession();
const systemSettings = await getSystemSettings();
diff --git a/src/app/[locale]/dashboard/my-quota/page.tsx b/src/app/[locale]/dashboard/my-quota/page.tsx
index 19d9d2f0d..d6d3d9e9b 100644
--- a/src/app/[locale]/dashboard/my-quota/page.tsx
+++ b/src/app/[locale]/dashboard/my-quota/page.tsx
@@ -9,13 +9,13 @@ export const dynamic = "force-dynamic";
export default async function MyQuotaPage({ params }: { params: Promise<{ locale: string }> }) {
// Await params to ensure locale is available in the async context
- await params;
+ const { locale } = await params;
const [quotaResult, systemSettings, tNav, tCommon] = await Promise.all([
getMyQuota(),
getSystemSettings(),
- getTranslations("dashboard.nav"),
- getTranslations("common"),
+ getTranslations({ locale, namespace: "dashboard.nav" }),
+ getTranslations({ locale, namespace: "common" }),
]);
// Handle error state
diff --git a/src/app/[locale]/dashboard/providers/page.tsx b/src/app/[locale]/dashboard/providers/page.tsx
index 2e6a7285a..f287b8b12 100644
--- a/src/app/[locale]/dashboard/providers/page.tsx
+++ b/src/app/[locale]/dashboard/providers/page.tsx
@@ -30,7 +30,7 @@ export default async function DashboardProvidersPage({
// TypeScript: session is guaranteed to be non-null after the redirect check
const currentUser = session!.user;
- const t = await getTranslations("settings");
+ const t = await getTranslations({ locale, namespace: "settings" });
const providers = await getProviders();
return (
diff --git a/src/app/[locale]/dashboard/quotas/layout.tsx b/src/app/[locale]/dashboard/quotas/layout.tsx
index dc0a16c00..6e403256a 100644
--- a/src/app/[locale]/dashboard/quotas/layout.tsx
+++ b/src/app/[locale]/dashboard/quotas/layout.tsx
@@ -2,8 +2,15 @@ import { getTranslations } from "next-intl/server";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Link } from "@/i18n/routing";
-export default async function QuotasLayout({ children }: { children: React.ReactNode }) {
- const t = await getTranslations("quota.layout");
+export default async function QuotasLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode;
+ params: Promise<{ locale: string }>;
+}) {
+ const { locale } = await params;
+ const t = await getTranslations({ locale, namespace: "quota.layout" });
return (
diff --git a/src/app/[locale]/dashboard/quotas/providers/page.tsx b/src/app/[locale]/dashboard/quotas/providers/page.tsx
index 8086ce29e..6b74c94c0 100644
--- a/src/app/[locale]/dashboard/quotas/providers/page.tsx
+++ b/src/app/[locale]/dashboard/quotas/providers/page.tsx
@@ -53,7 +53,7 @@ export default async function ProvidersQuotaPage({
redirect({ href: session ? "/dashboard/my-quota" : "/login", locale });
}
- const t = await getTranslations("quota.providers");
+ const t = await getTranslations({ locale, namespace: "quota.providers" });
return (
@@ -64,18 +64,18 @@ export default async function ProvidersQuotaPage({
}>
-
+
);
}
-async function ProvidersQuotaContent() {
+async function ProvidersQuotaContent({ locale }: { locale: string }) {
const [providers, systemSettings] = await Promise.all([
getProvidersWithQuotas(),
getSystemSettings(),
]);
- const t = await getTranslations("quota.providers");
+ const t = await getTranslations({ locale, namespace: "quota.providers" });
return (
diff --git a/src/app/[locale]/dashboard/quotas/users/page.tsx b/src/app/[locale]/dashboard/quotas/users/page.tsx
index 3f68ff410..be1ac1f26 100644
--- a/src/app/[locale]/dashboard/quotas/users/page.tsx
+++ b/src/app/[locale]/dashboard/quotas/users/page.tsx
@@ -129,7 +129,7 @@ export default async function UsersQuotaPage({ params }: { params: Promise<{ loc
return redirect({ href: session ? "/dashboard/my-quota" : "/login", locale });
}
- const t = await getTranslations("quota.users");
+ const t = await getTranslations({ locale, namespace: "quota.users" });
return (
@@ -162,15 +162,15 @@ export default async function UsersQuotaPage({ params }: { params: Promise<{ loc
/>
}>
-
+
);
}
-async function UsersQuotaContent() {
+async function UsersQuotaContent({ locale }: { locale: string }) {
const [users, systemSettings] = await Promise.all([getUsersWithQuotas(), getSystemSettings()]);
- const t = await getTranslations("quota.users");
+ const t = await getTranslations({ locale, namespace: "quota.users" });
return (
diff --git a/src/app/[locale]/dashboard/rate-limits/page.tsx b/src/app/[locale]/dashboard/rate-limits/page.tsx
index f7bf99e86..18ef80815 100644
--- a/src/app/[locale]/dashboard/rate-limits/page.tsx
+++ b/src/app/[locale]/dashboard/rate-limits/page.tsx
@@ -17,7 +17,7 @@ export default async function RateLimitsPage({ params }: { params: Promise<{ loc
return redirect({ href: "/dashboard", locale });
}
- const t = await getTranslations("dashboard.rateLimits");
+ const t = await getTranslations({ locale, namespace: "dashboard.rateLimits" });
return (
diff --git a/src/app/[locale]/settings/_lib/nav-items.ts b/src/app/[locale]/settings/_lib/nav-items.ts
index 974bb2b39..7171cf634 100644
--- a/src/app/[locale]/settings/_lib/nav-items.ts
+++ b/src/app/[locale]/settings/_lib/nav-items.ts
@@ -108,8 +108,8 @@ export const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [
];
// Helper function to get translated nav items
-export async function getTranslatedNavItems(): Promise {
- const t = await getTranslations("settings");
+export async function getTranslatedNavItems(locale: string): Promise {
+ const t = await getTranslations({ locale, namespace: "settings" });
return SETTINGS_NAV_ITEMS.map((item) => ({
...item,
label: item.labelKey ? t(item.labelKey) : item.label,
diff --git a/src/app/[locale]/settings/client-versions/page.tsx b/src/app/[locale]/settings/client-versions/page.tsx
index 3677b52c6..10e9c6e34 100644
--- a/src/app/[locale]/settings/client-versions/page.tsx
+++ b/src/app/[locale]/settings/client-versions/page.tsx
@@ -21,7 +21,7 @@ export default async function ClientVersionsPage({
// Await params to ensure locale is available in the async context
const { locale } = await params;
- const t = await getTranslations("settings");
+ const t = await getTranslations({ locale, namespace: "settings" });
const session = await getSession();
if (!session || session.user.role !== "admin") {
@@ -55,7 +55,7 @@ export default async function ClientVersionsPage({
iconColor="text-[#E25706]"
>
}>
-
+
@@ -71,8 +71,8 @@ async function ClientVersionsSettingsContent() {
return
;
}
-async function ClientVersionsStatsContent() {
- const t = await getTranslations("settings");
+async function ClientVersionsStatsContent({ locale }: { locale: string }) {
+ const t = await getTranslations({ locale, namespace: "settings" });
const statsResult = await fetchClientVersionStats();
const stats = statsResult.ok ? statsResult.data : [];
diff --git a/src/app/[locale]/settings/config/page.tsx b/src/app/[locale]/settings/config/page.tsx
index bbe54df4b..d139c43eb 100644
--- a/src/app/[locale]/settings/config/page.tsx
+++ b/src/app/[locale]/settings/config/page.tsx
@@ -9,8 +9,13 @@ import { SystemSettingsForm } from "./_components/system-settings-form";
export const dynamic = "force-dynamic";
-export default async function SettingsConfigPage() {
- const t = await getTranslations("settings");
+export default async function SettingsConfigPage({
+ params,
+}: {
+ params: Promise<{ locale: string }>;
+}) {
+ const { locale } = await params;
+ const t = await getTranslations({ locale, namespace: "settings" });
return (
<>
@@ -20,14 +25,14 @@ export default async function SettingsConfigPage() {
icon="settings"
/>
}>
-
+
>
);
}
-async function SettingsConfigContent() {
- const t = await getTranslations("settings");
+async function SettingsConfigContent({ locale }: { locale: string }) {
+ const t = await getTranslations({ locale, namespace: "settings" });
const settings = await getSystemSettings();
return (
diff --git a/src/app/[locale]/settings/error-rules/page.tsx b/src/app/[locale]/settings/error-rules/page.tsx
index b749cfa40..ccb295d84 100644
--- a/src/app/[locale]/settings/error-rules/page.tsx
+++ b/src/app/[locale]/settings/error-rules/page.tsx
@@ -12,8 +12,9 @@ import { RuleListTable } from "./_components/rule-list-table";
export const dynamic = "force-dynamic";
-export default async function ErrorRulesPage() {
- const t = await getTranslations("settings");
+export default async function ErrorRulesPage({ params }: { params: Promise<{ locale: string }> }) {
+ const { locale } = await params;
+ const t = await getTranslations({ locale, namespace: "settings" });
return (
<>
diff --git a/src/app/[locale]/settings/layout.tsx b/src/app/[locale]/settings/layout.tsx
index 034e072dd..a8f6ca0e3 100644
--- a/src/app/[locale]/settings/layout.tsx
+++ b/src/app/[locale]/settings/layout.tsx
@@ -28,11 +28,11 @@ export default async function SettingsLayout({
}
// Get translated navigation items
- const translatedNavItems = await getTranslatedNavItems();
+ const translatedNavItems = await getTranslatedNavItems(locale);
return (
-
+
{/* Desktop: Grid layout with sidebar */}
diff --git a/src/app/[locale]/settings/logs/page.tsx b/src/app/[locale]/settings/logs/page.tsx
index cdad92d44..a274ec394 100644
--- a/src/app/[locale]/settings/logs/page.tsx
+++ b/src/app/[locale]/settings/logs/page.tsx
@@ -5,8 +5,13 @@ import { LogLevelForm } from "./_components/log-level-form";
export const dynamic = "force-dynamic";
-export default async function SettingsLogsPage() {
- const t = await getTranslations("settings");
+export default async function SettingsLogsPage({
+ params,
+}: {
+ params: Promise<{ locale: string }>;
+}) {
+ const { locale } = await params;
+ const t = await getTranslations({ locale, namespace: "settings" });
return (
<>
diff --git a/src/app/[locale]/settings/prices/page.tsx b/src/app/[locale]/settings/prices/page.tsx
index 6ad27dfcc..ad6c4c2f1 100644
--- a/src/app/[locale]/settings/prices/page.tsx
+++ b/src/app/[locale]/settings/prices/page.tsx
@@ -11,20 +11,29 @@ import { UploadPriceDialog } from "./_components/upload-price-dialog";
export const dynamic = "force-dynamic";
+type SettingsPricesSearchParams = {
+ required?: string;
+ page?: string;
+ pageSize?: string;
+ size?: string;
+ search?: string;
+ source?: string;
+ litellmProvider?: string;
+};
+
interface SettingsPricesPageProps {
- searchParams: Promise<{
- required?: string;
- page?: string;
- pageSize?: string;
- size?: string;
- search?: string;
- source?: string;
- litellmProvider?: string;
+ params: Promise<{
+ locale: string;
}>;
+ searchParams: Promise
;
}
-export default async function SettingsPricesPage({ searchParams }: SettingsPricesPageProps) {
- const t = await getTranslations("settings");
+export default async function SettingsPricesPage({
+ params,
+ searchParams,
+}: SettingsPricesPageProps) {
+ const { locale } = await params;
+ const t = await getTranslations({ locale, namespace: "settings" });
return (
<>
@@ -34,14 +43,20 @@ export default async function SettingsPricesPage({ searchParams }: SettingsPrice
icon="dollar-sign"
/>
}>
-
+
>
);
}
-async function SettingsPricesContent({ searchParams }: SettingsPricesPageProps) {
- const t = await getTranslations("settings");
+async function SettingsPricesContent({
+ locale,
+ searchParams,
+}: {
+ locale: string;
+ searchParams: Promise;
+}) {
+ const t = await getTranslations({ locale, namespace: "settings" });
const params = await searchParams;
// 解析分页参数
diff --git a/src/app/[locale]/settings/providers/page.tsx b/src/app/[locale]/settings/providers/page.tsx
index 50e29a289..2d406d78b 100644
--- a/src/app/[locale]/settings/providers/page.tsx
+++ b/src/app/[locale]/settings/providers/page.tsx
@@ -14,8 +14,13 @@ import { SchedulingRulesDialog } from "./_components/scheduling-rules-dialog";
export const dynamic = "force-dynamic";
-export default async function SettingsProvidersPage() {
- const t = await getTranslations("settings");
+export default async function SettingsProvidersPage({
+ params,
+}: {
+ params: Promise<{ locale: string }>;
+}) {
+ const { locale } = await params;
+ const t = await getTranslations({ locale, namespace: "settings" });
const session = await getSession();
const providers = await getProviders();
diff --git a/src/app/[locale]/settings/request-filters/page.tsx b/src/app/[locale]/settings/request-filters/page.tsx
index f101e005f..cfd27572f 100644
--- a/src/app/[locale]/settings/request-filters/page.tsx
+++ b/src/app/[locale]/settings/request-filters/page.tsx
@@ -9,8 +9,13 @@ import { RequestFiltersTableSkeleton } from "./_components/request-filters-skele
export const dynamic = "force-dynamic";
-export default async function RequestFiltersPage() {
- const t = await getTranslations("settings.requestFilters");
+export default async function RequestFiltersPage({
+ params,
+}: {
+ params: Promise<{ locale: string }>;
+}) {
+ const { locale } = await params;
+ const t = await getTranslations({ locale, namespace: "settings.requestFilters" });
return (
<>
diff --git a/src/app/[locale]/settings/sensitive-words/page.tsx b/src/app/[locale]/settings/sensitive-words/page.tsx
index 89a9a3844..ba80222c1 100644
--- a/src/app/[locale]/settings/sensitive-words/page.tsx
+++ b/src/app/[locale]/settings/sensitive-words/page.tsx
@@ -11,8 +11,13 @@ import { WordListTable } from "./_components/word-list-table";
export const dynamic = "force-dynamic";
-export default async function SensitiveWordsPage() {
- const t = await getTranslations("settings");
+export default async function SensitiveWordsPage({
+ params,
+}: {
+ params: Promise<{ locale: string }>;
+}) {
+ const { locale } = await params;
+ const t = await getTranslations({ locale, namespace: "settings" });
return (
<>
diff --git a/src/app/[locale]/settings/status-page/page.tsx b/src/app/[locale]/settings/status-page/page.tsx
index 74d71a359..3be0a7bf6 100644
--- a/src/app/[locale]/settings/status-page/page.tsx
+++ b/src/app/[locale]/settings/status-page/page.tsx
@@ -5,8 +5,13 @@ import { loadStatusPageSettings } from "./loader";
export const dynamic = "force-dynamic";
-export default async function StatusPageSettingsPage() {
- const t = await getTranslations("settings");
+export default async function StatusPageSettingsPage({
+ params,
+}: {
+ params: Promise<{ locale: string }>;
+}) {
+ const { locale } = await params;
+ const t = await getTranslations({ locale, namespace: "settings" });
const settings = await loadStatusPageSettings();
return (
diff --git a/src/app/[locale]/status/[slug]/page.tsx b/src/app/[locale]/status/[slug]/page.tsx
index 208917520..0ad1e3fb7 100644
--- a/src/app/[locale]/status/[slug]/page.tsx
+++ b/src/app/[locale]/status/[slug]/page.tsx
@@ -34,7 +34,7 @@ export default async function PublicStatusGroupPage({
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
- const t = await getTranslations("settings");
+ const t = await getTranslations({ locale, namespace: "settings" });
const {
followServerDefaults,
initialPayload,
diff --git a/src/app/[locale]/usage-doc/layout.tsx b/src/app/[locale]/usage-doc/layout.tsx
index 370ef28e9..d4a89a636 100644
--- a/src/app/[locale]/usage-doc/layout.tsx
+++ b/src/app/[locale]/usage-doc/layout.tsx
@@ -45,7 +45,7 @@ export default async function UsageDocLayout({
{/* 条件渲染头部:已登录显示 DashboardHeader,未登录显示简化版头部 */}
{session ? (
-
+
) : (
diff --git a/src/components/ui/__tests__/language-switcher.test.tsx b/src/components/ui/__tests__/language-switcher.test.tsx
new file mode 100644
index 000000000..1ccf8fa3a
--- /dev/null
+++ b/src/components/ui/__tests__/language-switcher.test.tsx
@@ -0,0 +1,176 @@
+/**
+ * @vitest-environment happy-dom
+ */
+
+import type { ReactNode } from "react";
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { LanguageSwitcher } from "@/components/ui/language-switcher";
+import type { Locale } from "@/i18n/config";
+
+const testState = vi.hoisted(() => ({
+ currentLocale: "zh-CN" as Locale,
+ pathname: "/settings/config",
+ router: {
+ push: vi.fn(),
+ refresh: vi.fn(),
+ },
+}));
+
+vi.mock("next-intl", () => ({
+ useLocale: () => testState.currentLocale,
+}));
+
+vi.mock("@/i18n/routing", () => ({
+ usePathname: () => testState.pathname,
+ useRouter: () => testState.router,
+}));
+
+vi.mock("@/components/ui/dropdown-menu", () => ({
+ DropdownMenu: ({ children }: { children: ReactNode }) =>
{children}
,
+ DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}>,
+ DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
,
+ DropdownMenuItem: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => (
+
+ ),
+}));
+
+function render(node: ReactNode) {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const root = createRoot(container);
+
+ act(() => {
+ root.render(node);
+ });
+
+ return {
+ container,
+ rerender: (nextNode: ReactNode) => {
+ act(() => {
+ root.render(nextNode);
+ });
+ },
+ unmount: () => {
+ act(() => root.unmount());
+ container.remove();
+ },
+ };
+}
+
+function click(element: Element) {
+ act(() => {
+ element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
+ element.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
+ element.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+}
+
+describe("LanguageSwitcher", () => {
+ let view: ReturnType
| null = null;
+
+ beforeEach(() => {
+ window.sessionStorage.clear();
+ testState.currentLocale = "zh-CN";
+ testState.pathname = "/settings/config";
+ testState.router.push.mockReset();
+ testState.router.refresh.mockReset();
+ });
+
+ afterEach(() => {
+ view?.unmount();
+ view = null;
+ });
+
+ test("refreshes the current route after the locale provider catches up", () => {
+ view = render();
+
+ const englishOption = Array.from(view.container.querySelectorAll("button")).find((button) =>
+ button.textContent?.includes("English")
+ );
+
+ expect(englishOption).toBeTruthy();
+ click(englishOption!);
+
+ expect(testState.router.push).toHaveBeenCalledWith("/settings/config", { locale: "en" });
+ expect(testState.router.refresh).not.toHaveBeenCalled();
+
+ testState.currentLocale = "en";
+ view.rerender();
+
+ expect(testState.router.refresh).toHaveBeenCalledTimes(1);
+ const trigger = view.container.querySelector(
+ "button[aria-label='Select language']"
+ );
+ expect(trigger?.disabled).toBe(false);
+ });
+
+ test("restores the pending refresh after the switcher remounts during navigation", () => {
+ view = render();
+
+ const englishOption = Array.from(view.container.querySelectorAll("button")).find((button) =>
+ button.textContent?.includes("English")
+ );
+
+ expect(englishOption).toBeTruthy();
+ click(englishOption!);
+
+ expect(window.sessionStorage.getItem("cch.pendingLocaleRefresh")).toBe("en");
+
+ view.unmount();
+ view = null;
+
+ testState.currentLocale = "en";
+ view = render();
+
+ expect(testState.router.refresh).toHaveBeenCalledTimes(1);
+ expect(window.sessionStorage.getItem("cch.pendingLocaleRefresh")).toBeNull();
+ });
+
+ test("keeps the pending refresh after remount when sessionStorage is blocked", () => {
+ const setItemSpy = vi.spyOn(window.sessionStorage, "setItem").mockImplementation(() => {
+ throw new Error("blocked storage");
+ });
+ const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+
+ view = render();
+
+ const englishOption = Array.from(view.container.querySelectorAll("button")).find((button) =>
+ button.textContent?.includes("English")
+ );
+
+ expect(englishOption).toBeTruthy();
+ click(englishOption!);
+
+ expect(testState.router.push).toHaveBeenCalledWith("/settings/config", { locale: "en" });
+ expect(testState.router.refresh).not.toHaveBeenCalled();
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ "Failed to persist pending locale refresh target:",
+ expect.any(Error)
+ );
+
+ view.unmount();
+ view = null;
+ setItemSpy.mockRestore();
+
+ testState.currentLocale = "en";
+ view = render();
+
+ expect(testState.router.refresh).toHaveBeenCalledTimes(1);
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ test("does not refresh from a stale stored locale marker on mount", () => {
+ window.sessionStorage.setItem("cch.pendingLocaleRefresh", "en");
+ testState.currentLocale = "en";
+
+ view = render();
+
+ expect(testState.router.refresh).not.toHaveBeenCalled();
+ expect(window.sessionStorage.getItem("cch.pendingLocaleRefresh")).toBe("en");
+ });
+});
diff --git a/src/components/ui/language-switcher.tsx b/src/components/ui/language-switcher.tsx
index 1f93b3bf3..23ad5e0a9 100644
--- a/src/components/ui/language-switcher.tsx
+++ b/src/components/ui/language-switcher.tsx
@@ -15,11 +15,56 @@ import { normalizePathnameForLocaleNavigation } from "@/i18n/pathname";
import { usePathname, useRouter } from "@/i18n/routing";
import { cn } from "@/lib/utils/index";
+const pendingLocaleRefreshKey = "cch.pendingLocaleRefresh";
+let activePendingLocaleRefreshTarget: Locale | null = null;
+
interface LanguageSwitcherProps {
className?: string;
size?: "sm" | "default";
}
+function getPendingLocaleRefreshTarget(): Locale | null {
+ if (typeof window === "undefined") {
+ return null;
+ }
+
+ try {
+ const value = window.sessionStorage.getItem(pendingLocaleRefreshKey);
+ return locales.some((locale) => locale === value) ? (value as Locale) : null;
+ } catch (error) {
+ console.error("Failed to read pending locale refresh target:", error);
+ return null;
+ }
+}
+
+function setPendingLocaleRefreshTarget(locale: Locale) {
+ activePendingLocaleRefreshTarget = locale;
+
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ try {
+ window.sessionStorage.setItem(pendingLocaleRefreshKey, locale);
+ } catch (error) {
+ console.error("Failed to persist pending locale refresh target:", error);
+ }
+}
+
+function clearPendingLocaleRefreshTarget() {
+ activePendingLocaleRefreshTarget = null;
+
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ try {
+ window.sessionStorage.removeItem(pendingLocaleRefreshKey);
+ } catch (error) {
+ console.error("Failed to clear pending locale refresh target:", error);
+ }
+}
+
/**
* LanguageSwitcher Component
*
@@ -31,6 +76,23 @@ export function LanguageSwitcher({ className, size = "sm" }: LanguageSwitcherPro
const router = useRouter();
const pathname = usePathname();
const [isTransitioning, setIsTransitioning] = React.useState(false);
+ const [pendingLocale, setPendingLocale] = React.useState(null);
+
+ React.useEffect(() => {
+ const storedRefreshTarget =
+ activePendingLocaleRefreshTarget === null ? null : getPendingLocaleRefreshTarget();
+ const refreshTarget = pendingLocale ?? activePendingLocaleRefreshTarget ?? storedRefreshTarget;
+
+ if (refreshTarget !== currentLocale) {
+ return;
+ }
+
+ // Locale route 已切换后刷新当前 RSC 树,避免布局与服务端标题继续显示旧语言。
+ router.refresh();
+ clearPendingLocaleRefreshTarget();
+ setPendingLocale(null);
+ setIsTransitioning(false);
+ }, [currentLocale, pendingLocale, router]);
const handleLocaleChange = React.useCallback(
(newLocale: Locale) => {
@@ -39,11 +101,15 @@ export function LanguageSwitcher({ className, size = "sm" }: LanguageSwitcherPro
}
setIsTransitioning(true);
+ setPendingLocale(newLocale);
+ setPendingLocaleRefreshTarget(newLocale);
try {
router.push(normalizePathnameForLocaleNavigation(pathname), { locale: newLocale });
} catch (error) {
console.error("Failed to switch locale:", error);
+ clearPendingLocaleRefreshTarget();
+ setPendingLocale(null);
setIsTransitioning(false);
}
},
diff --git a/tests/unit/i18n/locale-server-translations.test.ts b/tests/unit/i18n/locale-server-translations.test.ts
new file mode 100644
index 000000000..b9fd24dbb
--- /dev/null
+++ b/tests/unit/i18n/locale-server-translations.test.ts
@@ -0,0 +1,41 @@
+import { readdirSync, readFileSync } from "node:fs";
+import { basename, join } from "node:path";
+import { describe, expect, test } from "vitest";
+
+function walk(dir: string): string[] {
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
+ const fullPath = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ return walk(fullPath);
+ }
+ return /\.(ts|tsx)$/.test(entry.name) ? [fullPath] : [];
+ });
+}
+
+function isRouteOrServerChromeFile(filePath: string): boolean {
+ const fileName = basename(filePath);
+
+ return (
+ fileName === "page.tsx" ||
+ fileName === "layout.tsx" ||
+ filePath.endsWith("dashboard-header.tsx") ||
+ filePath.endsWith("dashboard-sections.tsx") ||
+ filePath.endsWith("settings/_lib/nav-items.ts")
+ );
+}
+
+describe("locale server translations", () => {
+ test("route pages and server chrome pass locale explicitly to getTranslations", () => {
+ const files = walk("src/app/[locale]").filter(isRouteOrServerChromeFile);
+ const violations = files.flatMap((file) => {
+ const content = readFileSync(file, "utf8");
+ return content
+ .split("\n")
+ .map((line, index) => ({ line, lineNumber: index + 1 }))
+ .filter(({ line }) => /getTranslations\(\s*["']/.test(line))
+ .map(({ line, lineNumber }) => `${file}:${lineNumber}: ${line.trim()}`);
+ });
+
+ expect(violations).toEqual([]);
+ });
+});