feat: 首次登录引导与注册修复
This commit is contained in:
307
src/shared/components/onboarding-gate.tsx
Normal file
307
src/shared/components/onboarding-gate.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user