feat: introduce i18n system and class invitation codes
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.
This commit is contained in:
75
src/shared/components/locale-switcher.tsx
Normal file
75
src/shared/components/locale-switcher.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user