Add complete i18n infrastructure using next-intl (cookie-driven, without i18n routing) with zh-CN/en dictionary files, locale switcher, and NextIntlClientProvider in root layout. Add class invitation code system with new class_invitation_codes table, data-access layer (generate/validate/consume/revoke), server actions with permission checks, rate limiting, and audit logging. Add class-invitation-manager UI component. Refactor onboarding stepper to use i18n translations and accept new invitation code format (6-char alphanumeric) with backward compatibility for legacy 6-digit codes.
76 lines
2.2 KiB
TypeScript
76 lines
2.2 KiB
TypeScript
"use client"
|
||
|
||
import * as React from "react"
|
||
import { useTransition } from "react"
|
||
import { useLocale, useTranslations } from "next-intl"
|
||
import { Check, Globe } from "lucide-react"
|
||
import { toast } from "sonner"
|
||
|
||
import { Button } from "@/shared/components/ui/button"
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from "@/shared/components/ui/dropdown-menu"
|
||
import { LOCALES, type Locale } from "@/shared/i18n/locale"
|
||
import { setLocaleAction } from "@/i18n/actions"
|
||
import { cn } from "@/shared/lib/utils"
|
||
|
||
/**
|
||
* 语言切换组件(v3 i18n 体系)。
|
||
*
|
||
* 设计:
|
||
* - 不使用 URL 路由段,通过 cookie 持久化用户偏好
|
||
* - 切换时调用 setLocaleAction 写入 cookie + revalidatePath
|
||
* - 用 useTransition 保证切换过程不阻塞 UI
|
||
*/
|
||
export function LocaleSwitcher({ compact = false }: { compact?: boolean }) {
|
||
const locale = useLocale()
|
||
const t = useTranslations("common.locale")
|
||
const [isPending, startTransition] = useTransition()
|
||
|
||
function handleSelect(next: Locale) {
|
||
if (next === locale) return
|
||
startTransition(async () => {
|
||
const result = await setLocaleAction(next)
|
||
if (result.success) {
|
||
toast.success(t("switch"))
|
||
} else {
|
||
toast.error(t("switch"))
|
||
}
|
||
})
|
||
}
|
||
|
||
return (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size={compact ? "icon" : "sm"}
|
||
disabled={isPending}
|
||
aria-label={t("switch")}
|
||
className={cn(compact && "h-9 w-9")}
|
||
>
|
||
<Globe className="h-4 w-4" />
|
||
{!compact ? (
|
||
<span className="ml-1.5 text-sm">{t(locale)}</span>
|
||
) : null}
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
{LOCALES.map((l) => (
|
||
<DropdownMenuItem
|
||
key={l}
|
||
onClick={() => handleSelect(l)}
|
||
className="flex items-center justify-between gap-2"
|
||
>
|
||
<span>{t(l)}</span>
|
||
{l === locale ? <Check className="h-4 w-4" /> : null}
|
||
</DropdownMenuItem>
|
||
))}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
)
|
||
}
|