Files
NextEdu/src/shared/components/onboarding-gate.tsx
SpecialX 125f7ec54c
Some checks failed
CI / build-deploy (push) Has been cancelled
refactor: RBAC权限系统重构 + UI组件拆分 + 测试修复 + 架构文档
- 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、代码分割
2026-06-16 23:38:33 +08:00

314 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
)
}