Some checks failed
CI / build-deploy (push) Has been cancelled
- RBAC: 新增30个权限点、DataScope行级权限、requirePermission守卫,所有57+ Server Action接入权限校验 - UI拆分: exam-form(1623行→11文件)、textbook-reader(744行→7文件),均降至300行以内 - 测试: 新增5个单元测试文件(19用例),修复4个集成测试文件(38用例全部通过) - 架构文档: 新增架构影响地图(004/005)、标准功能清单(006)、差距审计报告(007) - 项目规则: 架构图优先规则,改码必同步图 - 安全: rehype-sanitize净化、AES加密API Key、权限路由守卫 - 无障碍: skip-link、aria-label、prefers-reduced-motion - 性能: next/font优化、next/image、代码分割
314 lines
11 KiB
TypeScript
314 lines
11 KiB
TypeScript
"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"
|
||
import { Permissions } from "@/shared/types/permissions"
|
||
|
||
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, update } = useSession()
|
||
const [required, setRequired] = useState(false)
|
||
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)
|
||
setRole(role === "admin" ? "admin" : role)
|
||
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 permissions = (session?.user?.permissions ?? []) as string[]
|
||
const isAdmin = permissions.includes(Permissions.SETTINGS_ADMIN)
|
||
const isTeacher = permissions.includes(Permissions.EXAM_CREATE)
|
||
const isStudent = permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)
|
||
const isParent = !permissions.includes(Permissions.EXAM_CREATE) && !permissions.includes(Permissions.HOMEWORK_SUBMIT) && permissions.includes(Permissions.EXAM_READ)
|
||
|
||
const onNext = async () => {
|
||
if (step === 0) {
|
||
if (!canNextFromStep0) return
|
||
setStep(1)
|
||
return
|
||
}
|
||
if (step === 1) {
|
||
if (!canNextFromStep1) {
|
||
toast.error("请填写姓名与电话")
|
||
return
|
||
}
|
||
if (isAdmin) {
|
||
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 || "提交失败")
|
||
}
|
||
|
||
await update?.()
|
||
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>
|
||
{isAdmin ? (
|
||
<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">
|
||
{isTeacher ? (
|
||
<>
|
||
<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}
|
||
|
||
{isStudent ? (
|
||
<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}
|
||
|
||
{isParent ? (
|
||
<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>
|
||
)
|
||
}
|
||
|