feat: 首次登录引导与注册修复
All checks were successful
CI / build-and-test (push) Successful in 20m12s
CI / deploy (push) Successful in 1m18s

This commit is contained in:
SpecialX
2026-01-12 10:49:30 +08:00
parent 15fcf2bc78
commit 8577280ab2
12 changed files with 653 additions and 25 deletions

View File

@@ -0,0 +1,307 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { useSession } from "next-auth/react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { Textarea } from "@/shared/components/ui/textarea"
import { cn } from "@/shared/lib/utils"
type Role = "student" | "teacher" | "parent" | "admin"
const TEACHER_SUBJECTS = ["语文", "数学", "英语", "美术", "体育", "科学", "社会", "音乐"] as const
type TeacherSubject = (typeof TEACHER_SUBJECTS)[number]
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null
}
export function OnboardingGate() {
const router = useRouter()
const { status, data: session } = useSession()
const [required, setRequired] = useState(false)
const [currentRole, setCurrentRole] = useState<Role>("student")
const [open, setOpen] = useState(false)
const [step, setStep] = useState(0)
const [isSubmitting, setIsSubmitting] = useState(false)
const [role, setRole] = useState<Role>("student")
const [name, setName] = useState("")
const [phone, setPhone] = useState("")
const [address, setAddress] = useState("")
const [classCodes, setClassCodes] = useState("")
const [teacherSubjects, setTeacherSubjects] = useState<TeacherSubject[]>([])
const canClose = useMemo(() => !required, [required])
useEffect(() => {
if (status !== "authenticated") return
let cancelled = false
;(async () => {
const res = await fetch("/api/onboarding/status", { cache: "no-store" }).catch(() => null)
const json = res ? await res.json().catch(() => null) : null
if (cancelled) return
if (isRecord(json)) {
const required = Boolean(json.required)
const role = String(json.role ?? "student") as Role
setRequired(required)
setCurrentRole(role)
setRole(role === "admin" ? "admin" : "student")
setName(String(session?.user?.name ?? "").trim())
if (required) {
setOpen(true)
setStep(0)
}
}
})()
return () => {
cancelled = true
}
}, [status, session?.user?.name])
useEffect(() => {
if (!open) return
if (!required) return
setOpen(true)
}, [open, required])
const title =
step === 0 ? "角色选择" : step === 1 ? "通用信息" : step === 2 ? "角色信息(可跳过)" : "完成"
const description =
step === 0
? "请选择你在系统中的角色"
: step === 1
? "填写姓名、电话、住址等信息"
: step === 2
? "不同角色可配置班级代码、教学科目等"
: "配置完成,可以进入系统"
const canNextFromStep0 = role.length > 0
const canNextFromStep1 = name.trim().length > 0 && phone.trim().length > 0
const onNext = async () => {
if (step === 0) {
if (!canNextFromStep0) return
setStep(1)
return
}
if (step === 1) {
if (!canNextFromStep1) {
toast.error("请填写姓名与电话")
return
}
if (role === "admin") {
setStep(3)
} else {
setStep(2)
}
return
}
if (step === 2) {
setStep(3)
return
}
}
const onBack = () => {
if (step === 0) return
setStep((s) => Math.max(0, s - 1))
}
const toggleSubject = (subject: TeacherSubject) => {
setTeacherSubjects((prev) => (prev.includes(subject) ? prev.filter((s) => s !== subject) : [...prev, subject]))
}
const onFinish = async () => {
setIsSubmitting(true)
try {
const res = await fetch("/api/onboarding/complete", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
role,
name,
phone,
address,
classCodes,
teacherSubjects,
}),
})
const json = await res.json().catch(() => null)
if (!res.ok || !isRecord(json) || json.success !== true) {
const msg = isRecord(json) ? String(json.message ?? "") : ""
throw new Error(msg || "提交失败")
}
toast.success("配置完成")
setRequired(false)
setOpen(false)
router.push("/dashboard")
router.refresh()
} catch (e) {
const msg = e instanceof Error ? e.message : "提交失败"
toast.error(msg)
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog
open={open}
onOpenChange={(v) => {
if (canClose) setOpen(v)
else setOpen(true)
}}
>
<DialogContent className="sm:max-w-[720px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className={cn("h-1 flex-1 rounded", step >= 0 ? "bg-primary" : "bg-muted")} />
<div className={cn("h-1 flex-1 rounded", step >= 1 ? "bg-primary" : "bg-muted")} />
<div className={cn("h-1 flex-1 rounded", step >= 2 ? "bg-primary" : "bg-muted")} />
<div className={cn("h-1 flex-1 rounded", step >= 3 ? "bg-primary" : "bg-muted")} />
</div>
{step === 0 ? (
<div className="grid gap-2">
<Label>Role</Label>
{currentRole === "admin" ? (
<div className="rounded-md border px-3 py-2 text-sm">admin</div>
) : (
<Select value={role} onValueChange={(v) => setRole(v as Role)}>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="student">student</SelectItem>
<SelectItem value="teacher">teacher</SelectItem>
<SelectItem value="parent">parent</SelectItem>
</SelectContent>
</Select>
)}
</div>
) : null}
{step === 1 ? (
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="onb_name"></Label>
<Input id="onb_name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="grid gap-2">
<Label htmlFor="onb_phone"></Label>
<Input id="onb_phone" value={phone} onChange={(e) => setPhone(e.target.value)} />
</div>
<div className="grid gap-2">
<Label htmlFor="onb_address"></Label>
<Input id="onb_address" value={address} onChange={(e) => setAddress(e.target.value)} />
</div>
</div>
) : null}
{step === 2 ? (
<div className="grid gap-4">
{role === "teacher" ? (
<>
<div className="grid gap-2">
<Label htmlFor="onb_codes_teacher"></Label>
<Textarea
id="onb_codes_teacher"
value={classCodes}
onChange={(e) => setClassCodes(e.target.value)}
placeholder="每行一个或用逗号分隔"
/>
</div>
<div className="grid gap-2">
<Label></Label>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{TEACHER_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>
</div>
</>
) : null}
{role === "student" ? (
<div className="grid gap-2">
<Label htmlFor="onb_codes_student"></Label>
<Textarea
id="onb_codes_student"
value={classCodes}
onChange={(e) => setClassCodes(e.target.value)}
placeholder="每行一个或用逗号分隔"
/>
</div>
) : null}
{role === "parent" ? (
<div className="rounded-md border px-3 py-2 text-sm text-muted-foreground">
</div>
) : null}
</div>
) : null}
{step === 3 ? (
<div className="rounded-md border px-3 py-3 text-sm">
<div className="font-medium"></div>
<div className="text-muted-foreground"></div>
</div>
) : null}
</div>
<DialogFooter>
<div className="flex w-full 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}>
</Button>
{step === 2 ? (
<Button
type="button"
variant="secondary"
onClick={() => setStep(3)}
disabled={isSubmitting}
>
</Button>
) : null}
</div>
<div className="flex gap-2 justify-end">
{step < 3 ? (
<Button type="button" onClick={onNext} disabled={isSubmitting}>
</Button>
) : (
<Button type="button" onClick={onFinish} disabled={isSubmitting}>
</Button>
)}
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -31,11 +31,21 @@ export const users = mysqlTable("users", {
// Credentials Auth (Optional)
password: varchar("password", { length: 255 }),
phone: varchar("phone", { length: 30 }),
address: varchar("address", { length: 255 }),
gender: varchar("gender", { length: 20 }),
age: int("age"),
gradeId: varchar("grade_id", { length: 128 }),
departmentId: varchar("department_id", { length: 128 }),
onboardedAt: timestamp("onboarded_at", { mode: "date" }),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
emailIdx: index("email_idx").on(table.email),
gradeIdIdx: index("users_grade_id_idx").on(table.gradeId),
departmentIdIdx: index("users_department_id_idx").on(table.departmentId),
}));
// Auth.js: Accounts (OAuth providers)