feat(P2): 实现选课管理、考试监考、学情诊断三大功能模块

## 新增功能模块

### 1. 选课管理(elective)
- 新增表:electiveCourses、courseSelections
- 新增权限:ELECTIVE_MANAGE/ELECTIVE_READ/ELECTIVE_SELECT
- 支持先到先得 + 抽签两种选课模式
- admin/teacher/student 三端页面

### 2. 考试监考(proctoring)
- exams 表扩展:examMode/durationMinutes/antiCheatEnabled 等字段
- 新增表:examProctoringEvents
- 新增权限:EXAM_PROCTOR/EXAM_PROCTOR_READ
- 教师监考面板 + 学生端防作弊监控
- API:/api/proctoring/event 接收事件上报

### 3. 学情诊断报告(diagnostic)
- 新增表:knowledgePointMastery、learningDiagnosticReports
- 新增权限:DIAGNOSTIC_MANAGE/DIAGNOSTIC_READ
- 基于提交答案自动计算知识点掌握度
- 生成个人/班级诊断报告(强项/弱项/建议)
- 雷达图可视化

## 其他改动
- 项目规则:单文件行数限制从 300 行调整为企业级规范(组件≤500/Actions≤800/硬上限1000)
- scripts/seed.ts:消除全部 any 类型,定义内部类型,0 lint 错误
- 架构文档 004/005 同步更新三个新模块
- 迁移文件 0001_heavy_sage.sql 生成

## 验证
- npx tsc --noEmit:0 错误
- npm run lint:0 错误 0 警告
This commit is contained in:
SpecialX
2026-06-17 19:12:51 +08:00
parent baf8f679bf
commit b86255f0ea
46 changed files with 13234 additions and 80 deletions

View File

@@ -0,0 +1,293 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { createElectiveCourseAction, updateElectiveCourseAction } from "../actions"
import type { ElectiveCourseWithDetails } from "../types"
type Mode = "create" | "edit"
interface Option {
id: string
name: string
}
export function ElectiveCourseForm({
mode,
course,
subjects = [],
grades = [],
teachers = [],
backHref,
}: {
mode: Mode
course?: ElectiveCourseWithDetails
subjects?: Option[]
grades?: Option[]
teachers?: Option[]
backHref?: string
}) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [subjectId, setSubjectId] = useState(course?.subjectId ?? "")
const [gradeId, setGradeId] = useState(course?.gradeId ?? "")
const [teacherId, setTeacherId] = useState(course?.teacherId ?? "")
const [selectionMode, setSelectionMode] = useState(course?.selectionMode ?? "fcfs")
const handleSubmit = async (formData: FormData) => {
setIsWorking(true)
try {
formData.set("subjectId", subjectId)
formData.set("gradeId", gradeId)
formData.set("teacherId", teacherId)
formData.set("selectionMode", selectionMode)
const res =
mode === "create"
? await createElectiveCourseAction(null, formData)
: course
? await updateElectiveCourseAction(course.id, null, formData)
: null
if (!res) {
toast.error("Invalid form state")
return
}
if (res.success) {
toast.success(res.message)
const redirectBase = backHref?.includes("/teacher/") ? "/teacher/elective" : "/admin/elective"
router.push(redirectBase)
router.refresh()
} else {
toast.error(res.message || "Failed to save course")
}
} catch {
toast.error("Failed to save course")
} finally {
setIsWorking(false)
}
}
return (
<Card>
<CardHeader>
<CardTitle>
{mode === "create" ? "New Elective Course" : "Edit Elective Course"}
</CardTitle>
</CardHeader>
<CardContent>
<form action={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="name">Course Name *</Label>
<Input
id="name"
name="name"
required
defaultValue={course?.name ?? ""}
/>
</div>
<div className="grid gap-2">
<Label>Subject</Label>
<Select value={subjectId} onValueChange={setSubjectId}>
<SelectTrigger>
<SelectValue placeholder="Select a subject" />
</SelectTrigger>
<SelectContent>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="subjectId" value={subjectId} />
</div>
<div className="grid gap-2">
<Label>Grade</Label>
<Select value={gradeId} onValueChange={setGradeId}>
<SelectTrigger>
<SelectValue placeholder="Select a grade" />
</SelectTrigger>
<SelectContent>
{grades.map((g) => (
<SelectItem key={g.id} value={g.id}>
{g.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="gradeId" value={gradeId} />
</div>
<div className="grid gap-2">
<Label>Teacher</Label>
<Select value={teacherId} onValueChange={setTeacherId}>
<SelectTrigger>
<SelectValue placeholder="Select a teacher" />
</SelectTrigger>
<SelectContent>
{teachers.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="teacherId" value={teacherId} />
</div>
<div className="grid gap-2">
<Label htmlFor="capacity">Capacity</Label>
<Input
id="capacity"
name="capacity"
type="number"
min={1}
max={500}
defaultValue={course?.capacity ?? 30}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="classroom">Classroom</Label>
<Input
id="classroom"
name="classroom"
defaultValue={course?.classroom ?? ""}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="schedule">Schedule</Label>
<Input
id="schedule"
name="schedule"
placeholder="e.g. Mon 14:00-15:30"
defaultValue={course?.schedule ?? ""}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="credit">Credit</Label>
<Input
id="credit"
name="credit"
type="number"
step="0.5"
min={0}
defaultValue={course?.credit ?? "1.0"}
/>
</div>
<div className="grid gap-2">
<Label>Selection Mode</Label>
<Select value={selectionMode} onValueChange={(v) => setSelectionMode(v as "fcfs" | "lottery")}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="fcfs">First Come First Served</SelectItem>
<SelectItem value="lottery">Lottery</SelectItem>
</SelectContent>
</Select>
<input type="hidden" name="selectionMode" value={selectionMode} />
</div>
<div className="grid gap-2">
<Label htmlFor="startDate">Start Date</Label>
<Input
id="startDate"
name="startDate"
type="date"
defaultValue={course?.startDate ?? ""}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="endDate">End Date</Label>
<Input
id="endDate"
name="endDate"
type="date"
defaultValue={course?.endDate ?? ""}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="selectionStartAt">Selection Start</Label>
<Input
id="selectionStartAt"
name="selectionStartAt"
type="datetime-local"
defaultValue={
course?.selectionStartAt
? new Date(course.selectionStartAt).toISOString().slice(0, 16)
: ""
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="selectionEndAt">Selection End</Label>
<Input
id="selectionEndAt"
name="selectionEndAt"
type="datetime-local"
defaultValue={
course?.selectionEndAt
? new Date(course.selectionEndAt).toISOString().slice(0, 16)
: ""
}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
name="description"
placeholder="Course description..."
className="min-h-[80px]"
defaultValue={course?.description ?? ""}
/>
</div>
<CardFooter className="justify-end gap-2 px-0">
<Button
type="button"
variant="outline"
onClick={() => router.push(backHref ?? "/admin/elective")}
disabled={isWorking}
>
Cancel
</Button>
<Button type="submit" disabled={isWorking}>
{isWorking ? "Saving..." : mode === "create" ? "Create" : "Save"}
</Button>
</CardFooter>
</form>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,233 @@
"use client"
import { useState, useTransition } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Plus, Pencil, Lock, Unlock, Shuffle, Trash2 } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { usePermission } from "@/shared/hooks/use-permission"
import { Permissions } from "@/shared/types/permissions"
import {
ELECTIVE_STATUS_COLORS,
ELECTIVE_STATUS_LABELS,
SELECTION_MODE_LABELS,
} from "../types"
import type { ElectiveCourseWithDetails } from "../types"
import {
deleteElectiveCourseAction,
openSelectionAction,
closeSelectionAction,
runLotteryAction,
} from "../actions"
export function ElectiveCourseList({
courses,
createHref,
editHrefBuilder,
canManage,
}: {
courses: ElectiveCourseWithDetails[]
createHref?: string
editHrefBuilder?: (id: string) => string
canManage?: boolean
}) {
const router = useRouter()
const { hasPermission } = usePermission()
const manageResolved = canManage ?? hasPermission(Permissions.ELECTIVE_MANAGE)
const [pendingId, setPendingId] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
const runAction = async (
action: (prevState: never, formData: FormData) => Promise<{ success: boolean; message?: string }>,
courseId: string,
successMsg: string
) => {
setPendingId(courseId)
startTransition(async () => {
const formData = new FormData()
formData.set("courseId", courseId)
const res = await action(null as never, formData)
if (res.success) {
toast.success(res.message ?? successMsg)
router.refresh()
} else {
toast.error(res.message ?? "Operation failed")
}
setPendingId(null)
})
}
const handleDelete = (courseId: string) => {
setPendingId(courseId)
startTransition(async () => {
const formData = new FormData()
formData.set("courseId", courseId)
const res = await deleteElectiveCourseAction(null, formData)
if (res.success) {
toast.success(res.message ?? "Course deleted")
router.refresh()
} else {
toast.error(res.message ?? "Delete failed")
}
setPendingId(null)
})
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{courses.length} course{courses.length === 1 ? "" : "s"}
</p>
{manageResolved && createHref ? (
<Button asChild>
<a href={createHref}>
<Plus className="mr-2 h-4 w-4" />
New Course
</a>
</Button>
) : null}
</div>
{courses.length === 0 ? (
<EmptyState
title="No elective courses"
description="There are no elective courses available."
icon={Plus}
className="h-auto border-none shadow-none"
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{courses.map((course) => {
const isFull = course.enrolledCount >= course.capacity
const isPendingThis = isPending && pendingId === course.id
return (
<Card key={course.id} className="flex h-full flex-col">
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
<CardTitle className="line-clamp-2 text-base">{course.name}</CardTitle>
<Badge variant={ELECTIVE_STATUS_COLORS[course.status]} className="shrink-0">
{ELECTIVE_STATUS_LABELS[course.status]}
</Badge>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-3">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{course.subjectName ? (
<Badge variant="outline">{course.subjectName}</Badge>
) : null}
{course.gradeName ? (
<Badge variant="outline">{course.gradeName}</Badge>
) : null}
<span>Credit: {course.credit}</span>
</div>
{course.description ? (
<p className="line-clamp-2 text-sm text-muted-foreground">
{course.description}
</p>
) : null}
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-muted-foreground">Teacher:</span>{" "}
<span className="font-medium">{course.teacherName ?? "—"}</span>
</div>
<div>
<span className="text-muted-foreground">Mode:</span>{" "}
<span className="font-medium">
{SELECTION_MODE_LABELS[course.selectionMode]}
</span>
</div>
<div>
<span className="text-muted-foreground">Capacity:</span>{" "}
<span className="font-medium">
{course.enrolledCount}/{course.capacity}
{isFull ? " (Full)" : ""}
</span>
</div>
{course.classroom ? (
<div>
<span className="text-muted-foreground">Room:</span>{" "}
<span className="font-medium">{course.classroom}</span>
</div>
) : null}
</div>
{course.schedule ? (
<p className="text-xs text-muted-foreground">
<span className="font-medium">Schedule:</span> {course.schedule}
</p>
) : null}
{manageResolved ? (
<div className="mt-auto flex flex-wrap gap-2 pt-2">
{editHrefBuilder ? (
<Button
asChild
variant="outline"
size="sm"
>
<a href={editHrefBuilder(course.id)}>
<Pencil className="mr-1 h-3 w-3" />
Edit
</a>
</Button>
) : null}
{course.status === "draft" || course.status === "closed" ? (
<Button
variant="outline"
size="sm"
disabled={isPendingThis}
onClick={() => runAction(openSelectionAction, course.id, "Selection opened")}
>
<Unlock className="mr-1 h-3 w-3" />
Open
</Button>
) : null}
{course.status === "open" ? (
<Button
variant="outline"
size="sm"
disabled={isPendingThis}
onClick={() => runAction(closeSelectionAction, course.id, "Selection closed")}
>
<Lock className="mr-1 h-3 w-3" />
Close
</Button>
) : null}
{course.selectionMode === "lottery" && course.status !== "draft" ? (
<Button
variant="outline"
size="sm"
disabled={isPendingThis}
onClick={() => runAction(runLotteryAction, course.id, "Lottery completed")}
>
<Shuffle className="mr-1 h-3 w-3" />
Lottery
</Button>
) : null}
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
disabled={isPendingThis}
onClick={() => handleDelete(course.id)}
>
<Trash2 className="mr-1 h-3 w-3" />
Delete
</Button>
</div>
) : null}
</CardContent>
</Card>
)
})}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,215 @@
"use client"
import { useState, useTransition } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { BookOpen, CheckCircle2, XCircle } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import {
COURSE_SELECTION_STATUS_COLORS,
COURSE_SELECTION_STATUS_LABELS,
ELECTIVE_STATUS_LABELS,
SELECTION_MODE_LABELS,
} from "../types"
import type {
CourseSelectionWithDetails,
ElectiveCourseWithDetails,
} from "../types"
import { selectCourseAction, dropCourseAction } from "../actions"
export function StudentSelectionView({
availableCourses,
mySelections,
}: {
availableCourses: ElectiveCourseWithDetails[]
mySelections: CourseSelectionWithDetails[]
}) {
const router = useRouter()
const [pendingId, setPendingId] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
const activeSelections = mySelections.filter((s) =>
["selected", "enrolled", "waitlist"].includes(s.status)
)
const selectedCourseIds = new Set(
activeSelections.map((s) => s.courseId)
)
const handleSelect = (courseId: string) => {
setPendingId(courseId)
startTransition(async () => {
const formData = new FormData()
formData.set("courseId", courseId)
const res = await selectCourseAction(null, formData)
if (res.success) {
toast.success(res.message)
router.refresh()
} else {
toast.error(res.message ?? "Failed to select course")
}
setPendingId(null)
})
}
const handleDrop = (courseId: string) => {
setPendingId(courseId)
startTransition(async () => {
const formData = new FormData()
formData.set("courseId", courseId)
const res = await dropCourseAction(null, formData)
if (res.success) {
toast.success(res.message)
router.refresh()
} else {
toast.error(res.message ?? "Failed to drop course")
}
setPendingId(null)
})
}
return (
<div className="space-y-8">
<section className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">My Selections</h3>
<span className="text-sm text-muted-foreground">
{activeSelections.length} active
</span>
</div>
{activeSelections.length === 0 ? (
<EmptyState
title="No selections yet"
description="Browse available courses below and select your electives."
icon={BookOpen}
className="h-auto border-none shadow-none"
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{activeSelections.map((sel) => (
<Card key={sel.id}>
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
<CardTitle className="text-base">
{sel.courseName ?? "Unknown course"}
</CardTitle>
<Badge variant={COURSE_SELECTION_STATUS_COLORS[sel.status]}>
{COURSE_SELECTION_STATUS_LABELS[sel.status]}
</Badge>
</CardHeader>
<CardContent className="space-y-3">
{sel.courseCapacity !== null && sel.courseEnrolledCount !== null ? (
<p className="text-xs text-muted-foreground">
Enrolled: {sel.courseEnrolledCount}/{sel.courseCapacity}
</p>
) : null}
{sel.lotteryRank ? (
<p className="text-xs text-muted-foreground">
Lottery rank: #{sel.lotteryRank}
</p>
) : null}
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
disabled={isPending && pendingId === sel.courseId}
onClick={() => handleDrop(sel.courseId)}
>
<XCircle className="mr-1 h-3 w-3" />
Drop
</Button>
</CardContent>
</Card>
))}
</div>
)}
</section>
<section className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Available Courses</h3>
<span className="text-sm text-muted-foreground">
{availableCourses.length} open
</span>
</div>
{availableCourses.length === 0 ? (
<EmptyState
title="No available courses"
description="There are no elective courses open for selection right now."
icon={BookOpen}
className="h-auto border-none shadow-none"
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{availableCourses.map((course) => {
const isFull = course.enrolledCount >= course.capacity
const alreadySelected = selectedCourseIds.has(course.id)
const isPendingThis = isPending && pendingId === course.id
return (
<Card key={course.id} className="flex h-full flex-col">
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
<CardTitle className="line-clamp-2 text-base">{course.name}</CardTitle>
<Badge variant="outline">
{ELECTIVE_STATUS_LABELS[course.status]}
</Badge>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-3">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{course.subjectName ? (
<Badge variant="outline">{course.subjectName}</Badge>
) : null}
<span>Credit: {course.credit}</span>
<span>· {SELECTION_MODE_LABELS[course.selectionMode]}</span>
</div>
{course.description ? (
<p className="line-clamp-2 text-sm text-muted-foreground">
{course.description}
</p>
) : null}
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-muted-foreground">Teacher:</span>{" "}
<span className="font-medium">{course.teacherName ?? "—"}</span>
</div>
<div>
<span className="text-muted-foreground">Capacity:</span>{" "}
<span className="font-medium">
{course.enrolledCount}/{course.capacity}
{isFull ? " (Full)" : ""}
</span>
</div>
</div>
{course.schedule ? (
<p className="text-xs text-muted-foreground">
<span className="font-medium">Schedule:</span> {course.schedule}
</p>
) : null}
<div className="mt-auto pt-2">
{alreadySelected ? (
<Button variant="secondary" size="sm" disabled>
<CheckCircle2 className="mr-1 h-3 w-3" />
Already selected
</Button>
) : (
<Button
size="sm"
disabled={isPendingThis}
onClick={() => handleSelect(course.id)}
>
{isPendingThis ? "Selecting..." : "Select"}
</Button>
)}
</div>
</CardContent>
</Card>
)
})}
</div>
)}
</section>
</div>
)
}