diff --git a/src/app/[locale]/dashboard/_components/dashboard-header.tsx b/src/app/[locale]/dashboard/_components/dashboard-header.tsx index 55d13cbed..b9caf6e16 100644 --- a/src/app/[locale]/dashboard/_components/dashboard-header.tsx +++ b/src/app/[locale]/dashboard/_components/dashboard-header.tsx @@ -1,4 +1,4 @@ -import { useTranslations } from "next-intl"; +import { getTranslations } from "next-intl/server"; import { VersionUpdateNotifier } from "@/components/customs/version-update-notifier"; import { Button } from "@/components/ui/button"; import { LanguageSwitcher } from "@/components/ui/language-switcher"; @@ -11,10 +11,11 @@ import { UserMenu } from "./user-menu"; interface DashboardHeaderProps { session: AuthSession | null; + locale: string; } -export function DashboardHeader({ session }: DashboardHeaderProps) { - const t = useTranslations("dashboard.nav"); +export async function DashboardHeader({ session, locale }: DashboardHeaderProps) { + const t = await getTranslations({ locale, namespace: "dashboard.nav" }); const isAdmin = session?.user.role === "admin"; const NAV_ITEMS: (DashboardNavItem & { adminOnly?: boolean })[] = [ diff --git a/src/app/[locale]/dashboard/_components/dashboard-sections.tsx b/src/app/[locale]/dashboard/_components/dashboard-sections.tsx index 8f514949a..8c29560cc 100644 --- a/src/app/[locale]/dashboard/_components/dashboard-sections.tsx +++ b/src/app/[locale]/dashboard/_components/dashboard-sections.tsx @@ -32,7 +32,13 @@ export async function DashboardStatisticsSection() { ); } -export async function DashboardLeaderboardSection({ isAdmin }: { isAdmin: boolean }) { +export async function DashboardLeaderboardSection({ + isAdmin, + locale, +}: { + isAdmin: boolean; + locale: string; +}) { const systemSettings = await getCachedSystemSettings(); const canViewLeaderboard = isAdmin || systemSettings.allowGlobalUsageView; @@ -40,7 +46,7 @@ export async function DashboardLeaderboardSection({ isAdmin }: { isAdmin: boolea return null; } - const t = await getTranslations("dashboard"); + const t = await getTranslations({ locale, namespace: "dashboard" }); return (
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([]); + }); +});