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

@@ -613,22 +613,26 @@ export async function enrollStudentByInvitationCode(studentId: string, invitatio
const sid = studentId.trim()
const code = invitationCode.trim()
if (!sid) throw new Error("Missing student id")
if (!/^\d{6}$/.test(code)) throw new Error("Invalid invitation code")
if (!code) throw new Error("Invalid invitation code")
const [cls] = await db
.select({ id: classes.id })
.from(classes)
.where(eq(classes.invitationCode, code))
.limit(1)
if (!cls) throw new Error("Invalid invitation code")
// v3优先走新邀请码体系validateInvitationCode 内部含 fallback 到旧 classes.invitationCode
const { validateInvitationCode, consumeInvitationCode } = await import("./data-access-invitations")
const result = await validateInvitationCode(code)
if (!result.valid || !result.classId) {
throw new Error("Invalid invitation code")
}
await db
.insert(classEnrollments)
.values({ classId: cls.id, studentId: sid, status: "active" })
.values({ classId: result.classId, studentId: sid, status: "active" })
.onDuplicateKeyUpdate({ set: { status: "active" } })
return cls.id
// 消耗新表邀请码(旧表无计数,跳过)
if (result.codeId) {
await consumeInvitationCode(code)
}
return result.classId
}
export async function enrollTeacherByInvitationCode(
@@ -639,7 +643,7 @@ export async function enrollTeacherByInvitationCode(
const tid = teacherId.trim()
const code = invitationCode.trim()
if (!tid) throw new Error("Missing teacher id")
if (!/^\d{6}$/.test(code)) throw new Error("Invalid invitation code")
if (!code) throw new Error("Invalid invitation code")
const [teacher] = await db
.select({ id: users.id })
@@ -651,10 +655,17 @@ export async function enrollTeacherByInvitationCode(
if (!teacher) throw new Error("Teacher not found")
// v3优先走新邀请码体系validateInvitationCode 内部含 fallback 到旧 classes.invitationCode
const { validateInvitationCode, consumeInvitationCode } = await import("./data-access-invitations")
const result = await validateInvitationCode(code)
if (!result.valid || !result.classId) {
throw new Error("Invalid invitation code")
}
const [cls] = await db
.select({ id: classes.id, teacherId: classes.teacherId })
.from(classes)
.where(eq(classes.invitationCode, code))
.where(eq(classes.id, result.classId))
.limit(1)
if (!cls) throw new Error("Invalid invitation code")
@@ -747,6 +758,11 @@ export async function enrollTeacherByInvitationCode(
if (!assigned) throw new Error("Class already has assigned teachers")
}
// 消耗新表邀请码(旧表无计数,跳过)
if (result.codeId) {
await consumeInvitationCode(code)
}
return cls.id
}