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:
SpecialX
2026-06-22 14:04:55 +08:00
parent a4d096a6fc
commit c90748124d
25 changed files with 2911 additions and 30 deletions

View 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>
)
}