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

32
src/i18n/actions.ts Normal file
View File

@@ -0,0 +1,32 @@
"use server"
import { cookies } from "next/headers"
import { revalidatePath } from "next/cache"
import {
LOCALE_COOKIE,
LOCALE_COOKIE_OPTIONS,
type Locale,
isLocale,
} from "@/shared/i18n/locale"
/**
* 切换语言:写入 cookie + 触发页面刷新。
*
* 设计说明:
* - 不使用 URL 路由段,避免破坏现有路由组结构
* - cookie 持久化用户偏好SSR 时由 i18n/request.ts 读取
* - revalidatePath 确保所有页面用新 locale 重新渲染
*/
export async function setLocaleAction(locale: string): Promise<{ success: boolean }> {
if (!isLocale(locale)) {
return { success: false }
}
const cookieStore = await cookies()
cookieStore.set(LOCALE_COOKIE, locale as Locale, LOCALE_COOKIE_OPTIONS)
// 刷新所有页面,让 SSR 用新 locale 重新渲染
revalidatePath("/", "layout")
return { success: true }
}

40
src/i18n/request.ts Normal file
View File

@@ -0,0 +1,40 @@
import { getRequestConfig } from "next-intl/server";
import { cookies } from "next/headers";
import { DEFAULT_LOCALE, LOCALE_COOKIE, isLocale } from "@/shared/i18n/locale";
/**
* next-intl 请求配置without i18n routing 模式)。
*
* locale 来源优先级:
* 1. cookie `NEXT_LOCALE`(用户主动切换时写入)
* 2. 默认值 `zh-CN`
*
* 不使用 Accept-Language 自动协商,避免 SSR 与客户端 hydration 不一致。
* 用户切换语言时通过 setLocaleAction 写入 cookie + router.refresh()。
*/
export default getRequestConfig(async () => {
const cookieStore = await cookies();
const cookieValue = cookieStore.get(LOCALE_COOKIE)?.value;
const locale = isLocale(cookieValue) ? cookieValue : DEFAULT_LOCALE;
// 按命名空间拆分加载,避免单文件过大
const [common, auth, onboarding, classes, errors] = await Promise.all([
import(`@/shared/i18n/messages/${locale}/common.json`),
import(`@/shared/i18n/messages/${locale}/auth.json`),
import(`@/shared/i18n/messages/${locale}/onboarding.json`),
import(`@/shared/i18n/messages/${locale}/classes.json`),
import(`@/shared/i18n/messages/${locale}/errors.json`),
]);
return {
locale,
messages: {
common: common.default,
auth: auth.default,
onboarding: onboarding.default,
classes: classes.default,
errors: errors.default,
},
};
});