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:
451
src/modules/onboarding/components/onboarding-stepper.tsx
Normal file
451
src/modules/onboarding/components/onboarding-stepper.tsx
Normal file
@@ -0,0 +1,451 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useSession } from "next-auth/react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { DEFAULT_CLASS_SUBJECTS, type ClassSubject } from "@/modules/classes/types"
|
||||
import { completeOnboardingAction } from "@/modules/onboarding/actions"
|
||||
import type { OnboardingStatus } from "@/modules/onboarding/types"
|
||||
|
||||
interface OnboardingStepperProps {
|
||||
initialStatus: OnboardingStatus
|
||||
}
|
||||
|
||||
/**
|
||||
* v3 i18n:所有文案通过 useTranslations 读取,支持 zh-CN / en 切换。
|
||||
*/
|
||||
const STEPS_KEYS = ["roleConfirm", "basicInfo", "roleInfo", "complete"] as const
|
||||
|
||||
interface ChildRow {
|
||||
childEmail: string
|
||||
childBirthDate: string
|
||||
childPhoneSuffix: string
|
||||
childRelation: string
|
||||
}
|
||||
|
||||
const EMPTY_CHILD_ROW: ChildRow = {
|
||||
childEmail: "",
|
||||
childBirthDate: "",
|
||||
childPhoneSuffix: "",
|
||||
childRelation: "",
|
||||
}
|
||||
|
||||
export function OnboardingStepper({ initialStatus }: OnboardingStepperProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { update } = useSession()
|
||||
const t = useTranslations("onboarding")
|
||||
const tCommon = useTranslations("common.actions")
|
||||
|
||||
// P1-1:URL query 参数持久化当前步骤
|
||||
const initialStep = clampStep(Number(searchParams.get("step") ?? "0"))
|
||||
const [step, setStep] = React.useState(initialStep)
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
||||
|
||||
const [name, setName] = React.useState(initialStatus.name ?? "")
|
||||
const [phone, setPhone] = React.useState("")
|
||||
const [address, setAddress] = React.useState("")
|
||||
const [classCodes, setClassCodes] = React.useState("")
|
||||
const [teacherSubjects, setTeacherSubjects] = React.useState<ClassSubject[]>([])
|
||||
const [children, setChildren] = React.useState<ChildRow[]>([{ ...EMPTY_CHILD_ROW }])
|
||||
|
||||
const primaryRole = initialStatus.roles.primary
|
||||
const isAdmin = primaryRole === "admin"
|
||||
const isStudent = primaryRole === "student"
|
||||
const isTeacher = primaryRole === "teacher"
|
||||
const isParent = primaryRole === "parent"
|
||||
|
||||
const stepKeys = isAdmin ? STEPS_KEYS.filter((_, i) => i !== 2) : STEPS_KEYS
|
||||
const maxStep = stepKeys.length - 1
|
||||
|
||||
const canNext = React.useMemo(() => {
|
||||
if (step === 1) {
|
||||
return name.trim().length > 0 && phone.trim().length > 0
|
||||
}
|
||||
if (step === 2 && isParent) {
|
||||
const validChildren = children.filter(
|
||||
(c) => c.childEmail.trim() && c.childBirthDate.trim() && c.childPhoneSuffix.trim()
|
||||
)
|
||||
return validChildren.length > 0
|
||||
}
|
||||
return true
|
||||
}, [step, name, phone, isParent, children])
|
||||
|
||||
const toggleSubject = (subject: ClassSubject) => {
|
||||
setTeacherSubjects((prev) =>
|
||||
prev.includes(subject) ? prev.filter((s) => s !== subject) : [...prev, subject]
|
||||
)
|
||||
}
|
||||
|
||||
const goToStep = React.useCallback(
|
||||
(next: number) => {
|
||||
const clamped = Math.max(0, Math.min(maxStep, next))
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.set("step", String(clamped))
|
||||
router.replace(`/onboarding?${params.toString()}`, { scroll: false })
|
||||
setStep(clamped)
|
||||
},
|
||||
[maxStep, router, searchParams]
|
||||
)
|
||||
|
||||
const onNext = () => {
|
||||
if (step === 1 && !canNext) {
|
||||
toast.error(t("validation.needNamePhone"))
|
||||
return
|
||||
}
|
||||
if (step === 2 && isParent && !canNext) {
|
||||
toast.error(t("validation.needOneChild"))
|
||||
return
|
||||
}
|
||||
goToStep(step + 1)
|
||||
}
|
||||
|
||||
const onBack = () => {
|
||||
goToStep(step - 1)
|
||||
}
|
||||
|
||||
const canSkipStep2 = isAdmin || isStudent || isTeacher
|
||||
const onSkip = () => {
|
||||
if (isParent) return
|
||||
goToStep(isAdmin ? 2 : 3)
|
||||
}
|
||||
|
||||
const addChildRow = () => {
|
||||
setChildren((prev) => [...prev, { ...EMPTY_CHILD_ROW }])
|
||||
}
|
||||
const removeChildRow = (idx: number) => {
|
||||
setChildren((prev) => (prev.length === 1 ? prev : prev.filter((_, i) => i !== idx)))
|
||||
}
|
||||
const updateChildRow = (idx: number, patch: Partial<ChildRow>) => {
|
||||
setChildren((prev) => prev.map((row, i) => (i === idx ? { ...row, ...patch } : row)))
|
||||
}
|
||||
|
||||
const onFinish = async () => {
|
||||
if (isParent) {
|
||||
const validChildren = children.filter(
|
||||
(c) => c.childEmail.trim() && c.childBirthDate.trim() && c.childPhoneSuffix.trim()
|
||||
)
|
||||
if (validChildren.length === 0) {
|
||||
toast.error(t("validation.needOneChild"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.set("name", name.trim())
|
||||
formData.set("phone", phone.trim())
|
||||
formData.set("address", address.trim())
|
||||
// v3:邀请码统一大写化后提交
|
||||
formData.set("classCodes", classCodes.trim().toUpperCase())
|
||||
formData.set("teacherSubjects", JSON.stringify(teacherSubjects))
|
||||
const validChildren = children.filter(
|
||||
(c) => c.childEmail.trim() && c.childBirthDate.trim() && c.childPhoneSuffix.trim()
|
||||
)
|
||||
formData.set("children", JSON.stringify(validChildren))
|
||||
|
||||
const result = await completeOnboardingAction(null, formData)
|
||||
if (!result.success) {
|
||||
toast.error(result.message ?? t("toast.submitFailed"))
|
||||
return
|
||||
}
|
||||
|
||||
if (result.message && result.message.includes("绑定失败")) {
|
||||
toast.warning(result.message)
|
||||
} else {
|
||||
toast.success(t("toast.completeSuccess"))
|
||||
}
|
||||
|
||||
await update?.()
|
||||
const target = result.data?.defaultPath ?? "/dashboard"
|
||||
router.push(target)
|
||||
router.refresh()
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : t("toast.submitFailed")
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const title = t(`steps.${stepKeys[step]}`)
|
||||
const description =
|
||||
step === 0
|
||||
? t("role.adminAssigned")
|
||||
: step === 1
|
||||
? t("form.name") + " · " + t("form.phone") + " · " + t("form.address")
|
||||
: step === 2
|
||||
? isParent
|
||||
? t("parent.bindHint")
|
||||
: t("steps.roleInfo")
|
||||
: t("complete.readyHint")
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-1.5">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
role="progressbar"
|
||||
aria-label={t("progress.label")}
|
||||
aria-valuenow={step + 1}
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={stepKeys.length}
|
||||
>
|
||||
{stepKeys.map((_, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={cn(
|
||||
"h-1 flex-1 rounded transition-colors",
|
||||
step >= idx ? "bg-primary" : "bg-muted"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step 0: 角色确认(只读) */}
|
||||
{step === 0 ? (
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("role.yourRole")}</Label>
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2.5 text-sm">
|
||||
<span className="font-medium">{t(`role.${primaryRole}`)}</span>
|
||||
{initialStatus.roles.all.length > 1 ? (
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
({t("role.allRoles", { roles: initialStatus.roles.all.join("、") })})
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("role.adminAssigned")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Step 1: 基础信息 */}
|
||||
{step === 1 ? (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="onb_name">{t("form.name")} *</Label>
|
||||
<Input
|
||||
id="onb_name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={50}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="onb_phone">{t("form.phone")} *</Label>
|
||||
<Input
|
||||
id="onb_phone"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder="11 位手机号"
|
||||
inputMode="tel"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="onb_address">{t("form.address")}</Label>
|
||||
<Input
|
||||
id="onb_address"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Step 2: 角色信息 */}
|
||||
{step === 2 ? (
|
||||
<div className="grid gap-4">
|
||||
{isTeacher ? (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="onb_codes_teacher">{t("teacher.classCodesOptional")}</Label>
|
||||
<Textarea
|
||||
id="onb_codes_teacher"
|
||||
value={classCodes}
|
||||
onChange={(e) => setClassCodes(e.target.value)}
|
||||
placeholder={t("teacher.classCodesPlaceholder")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("teacher.classCodesHint")}</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("teacher.subjectsOptional")}</Label>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{DEFAULT_CLASS_SUBJECTS.map((s) => (
|
||||
<label key={s} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={teacherSubjects.includes(s)}
|
||||
onCheckedChange={() => toggleSubject(s)}
|
||||
/>
|
||||
{s}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("teacher.subjectsHint")}</p>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{isStudent ? (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="onb_codes_student">{t("student.classCodesOptional")}</Label>
|
||||
<Textarea
|
||||
id="onb_codes_student"
|
||||
value={classCodes}
|
||||
onChange={(e) => setClassCodes(e.target.value)}
|
||||
placeholder={t("student.classCodesPlaceholder")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("student.classCodesHint")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isParent ? (
|
||||
<div className="grid gap-4">
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2.5 text-sm text-muted-foreground">
|
||||
{t("parent.bindHint")}
|
||||
</div>
|
||||
{children.map((row, idx) => (
|
||||
<div key={idx} className="grid gap-3 rounded-md border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{t("parent.childN", { index: idx + 1 })}</span>
|
||||
{children.length > 1 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeChildRow(idx)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{tCommon("remove")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`onb_child_email_${idx}`}>{t("parent.childEmail")} *</Label>
|
||||
<Input
|
||||
id={`onb_child_email_${idx}`}
|
||||
type="email"
|
||||
value={row.childEmail}
|
||||
onChange={(e) => updateChildRow(idx, { childEmail: e.target.value })}
|
||||
placeholder="student@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`onb_child_birth_${idx}`}>{t("parent.childBirthDate")} *</Label>
|
||||
<Input
|
||||
id={`onb_child_birth_${idx}`}
|
||||
type="date"
|
||||
value={row.childBirthDate}
|
||||
onChange={(e) => updateChildRow(idx, { childBirthDate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`onb_child_phone_${idx}`}>{t("parent.childPhoneSuffix")} *</Label>
|
||||
<Input
|
||||
id={`onb_child_phone_${idx}`}
|
||||
value={row.childPhoneSuffix}
|
||||
onChange={(e) =>
|
||||
updateChildRow(idx, {
|
||||
childPhoneSuffix: e.target.value.replace(/\D/g, "").slice(0, 4),
|
||||
})
|
||||
}
|
||||
placeholder="4 位数字"
|
||||
inputMode="numeric"
|
||||
maxLength={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`onb_child_relation_${idx}`}>{t("parent.childRelation")}</Label>
|
||||
<Input
|
||||
id={`onb_child_relation_${idx}`}
|
||||
value={row.childRelation}
|
||||
onChange={(e) => updateChildRow(idx, { childRelation: e.target.value })}
|
||||
placeholder={t("parent.childRelationPlaceholder")}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{children.length < 10 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addChildRow}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("parent.addChild")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Step 3: 完成 */}
|
||||
{step === (isAdmin ? 2 : 3) ? (
|
||||
<div className="rounded-md border bg-muted/30 px-4 py-4 text-sm">
|
||||
<div className="font-medium">{t("complete.ready")}</div>
|
||||
<div className="mt-1 text-muted-foreground">{t("complete.readyHint")}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onBack}
|
||||
disabled={step === 0 || isSubmitting}
|
||||
>
|
||||
{tCommon("previous")}
|
||||
</Button>
|
||||
{step === 2 && canSkipStep2 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onSkip}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{tCommon("skip")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{step < maxStep ? (
|
||||
<Button type="button" onClick={onNext} disabled={isSubmitting}>
|
||||
{tCommon("next")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" onClick={onFinish} disabled={isSubmitting}>
|
||||
{isSubmitting ? tCommon("submitting") : tCommon("finish")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function clampStep(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
return Math.max(0, Math.min(3, Math.floor(value)))
|
||||
}
|
||||
85
src/modules/onboarding/schema.ts
Normal file
85
src/modules/onboarding/schema.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { z } from "zod"
|
||||
|
||||
/**
|
||||
* Onboarding 输入校验 schema。
|
||||
*
|
||||
* 设计原则(参考 K12 教务铁律):
|
||||
* - 角色字段不在用户可提交的 schema 中,角色由管理员预分配,服务端从 usersToRoles 读取。
|
||||
* - 班级代码仅作为"确认/补绑"用途,服务端调用 modules/classes data-access 做强校验。
|
||||
*
|
||||
* v3 修复(对标 PowerSchool Access ID + Access Password):
|
||||
* - P0-2 家长绑定验证增强:从"邮箱+生日"(生日仅 365 种可能)升级为"邮箱+生日+手机号后4位"三因子,
|
||||
* 组合空间提升至 365 × 10000 = 3.65M 种,显著降低枚举攻击风险。
|
||||
* - P1-4 家长多子女:children 数组替代单个 childEmail/childBindingCode,支持一次绑定多个子女。
|
||||
*/
|
||||
const childSchema = z.object({
|
||||
childEmail: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "请填写子女邮箱")
|
||||
.email("子女邮箱格式错误"),
|
||||
childBirthDate: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "请填写子女生日")
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "子女生日格式错误(YYYY-MM-DD)"),
|
||||
childPhoneSuffix: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "请填写子女手机号后 4 位")
|
||||
.regex(/^\d{4}$/, "子女手机号后 4 位格式错误"),
|
||||
childRelation: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(50, "关系字段长度不能超过 50 个字符")
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
})
|
||||
|
||||
export const OnboardingSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "请填写姓名")
|
||||
.max(50, "姓名长度不能超过 50 个字符"),
|
||||
phone: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "请填写电话")
|
||||
.regex(/^1\d{10}$/, "请输入有效的手机号(11 位,以 1 开头)"),
|
||||
address: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(200, "住址长度不能超过 200 个字符")
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
// 学生/教师补绑班级的邀请码列表(可选,每项为 6 位字母数字,v3 新格式)
|
||||
// v3:从 6 位数字升级为 6 位字母数字(剔除歧义字符 0/O/1/I/L),兼容旧 6 位数字码
|
||||
classCodes: z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(
|
||||
/^[A-Z2-9]{6}$|^\d{6}$/,
|
||||
"班级邀请码格式错误(6 位字母数字或 6 位数字)"
|
||||
)
|
||||
)
|
||||
.max(10, "单次最多绑定 10 个班级")
|
||||
.optional()
|
||||
.default([]),
|
||||
// 教师任课科目(可选,v3 修复 P0-3:服务端循环为每个科目绑定)
|
||||
teacherSubjects: z
|
||||
.array(z.string().trim().min(1))
|
||||
.max(10)
|
||||
.optional()
|
||||
.default([]),
|
||||
// 家长绑定子女列表(P1-4 多子女支持)
|
||||
children: z
|
||||
.array(childSchema)
|
||||
.max(10, "单次最多绑定 10 个子女")
|
||||
.optional()
|
||||
.default([]),
|
||||
})
|
||||
|
||||
export type OnboardingInput = z.infer<typeof OnboardingSchema>
|
||||
Reference in New Issue
Block a user