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,41 @@
import { notFound } from "next/navigation"
import { getElectiveCourseById, getSubjectOptions } from "@/modules/elective/data-access"
import { getGrades, getStaffOptions } from "@/modules/school/data-access"
import { ElectiveCourseForm } from "@/modules/elective/components/elective-course-form"
export const dynamic = "force-dynamic"
export default async function EditElectiveCoursePage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const [course, subjects, grades, teachers] = await Promise.all([
getElectiveCourseById(id),
getSubjectOptions(),
getGrades(),
getStaffOptions(),
])
if (!course) notFound()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">Edit Elective Course</h2>
<p className="text-muted-foreground">Update the elective course details below.</p>
</div>
<ElectiveCourseForm
mode="edit"
course={course}
subjects={subjects}
grades={grades.map((g) => ({ id: g.id, name: g.name }))}
teachers={teachers.map((t) => ({ id: t.id, name: t.name }))}
backHref="/admin/elective"
/>
</div>
)
}

View File

@@ -0,0 +1,29 @@
import { getGrades, getStaffOptions } from "@/modules/school/data-access"
import { getSubjectOptions } from "@/modules/elective/data-access"
import { ElectiveCourseForm } from "@/modules/elective/components/elective-course-form"
export const dynamic = "force-dynamic"
export default async function CreateElectiveCoursePage() {
const [subjects, grades, teachers] = await Promise.all([
getSubjectOptions(),
getGrades(),
getStaffOptions(),
])
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">New Elective Course</h2>
<p className="text-muted-foreground">Create a new elective course.</p>
</div>
<ElectiveCourseForm
mode="create"
subjects={subjects}
grades={grades.map((g) => ({ id: g.id, name: g.name }))}
teachers={teachers.map((t) => ({ id: t.id, name: t.name }))}
backHref="/admin/elective"
/>
</div>
)
}

View File

@@ -0,0 +1,44 @@
import { getElectiveCourses } from "@/modules/elective/data-access"
import { ElectiveCourseList } from "@/modules/elective/components/elective-course-list"
import type { ElectiveCourseStatus } from "@/modules/elective/types"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
const isValidStatus = (v?: string): v is ElectiveCourseStatus =>
v === "draft" || v === "open" || v === "closed" || v === "cancelled"
export default async function AdminElectivePage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const sp = await searchParams
const statusParam = getParam(sp, "status")
const status = isValidStatus(statusParam) ? statusParam : undefined
const courses = await getElectiveCourses({ status })
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Elective Courses</h2>
<p className="text-muted-foreground">
Manage elective courses, open/close selection, and run lottery.
</p>
</div>
<ElectiveCourseList
courses={courses}
canManage
createHref="/admin/elective/create"
editHrefBuilder={(id) => `/admin/elective/${id}/edit`}
/>
</div>
)
}

View File

@@ -0,0 +1,31 @@
import { Stethoscope } from "lucide-react"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { getStudentMasterySummary } from "@/modules/diagnostic/data-access"
import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports"
import { StudentDiagnosticView } from "@/modules/diagnostic/components/student-diagnostic-view"
export const dynamic = "force-dynamic"
export default async function StudentDiagnosticPage() {
const ctx = await getAuthContext()
const [summary, reports] = await Promise.all([
getStudentMasterySummary(ctx.userId),
getDiagnosticReports({ studentId: ctx.userId }),
])
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Stethoscope className="h-6 w-6" />
My Diagnostic
</h2>
<p className="text-muted-foreground">
Your knowledge point mastery analysis and diagnostic reports.
</p>
</div>
<StudentDiagnosticView summary={summary} reports={reports} />
</div>
)
}

View File

@@ -0,0 +1,49 @@
import { auth } from "@/auth"
import { Inbox } from "lucide-react"
import { getAvailableCoursesForStudent, getStudentSelections } from "@/modules/elective/data-access-selections"
import { StudentSelectionView } from "@/modules/elective/components/student-selection-view"
import { EmptyState } from "@/shared/components/ui/empty-state"
export const dynamic = "force-dynamic"
export default async function StudentElectivePage() {
const session = await auth()
const studentId = String(session?.user?.id ?? "")
if (!studentId) {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">Elective Courses</h2>
<p className="text-muted-foreground">Browse and select elective courses.</p>
</div>
<EmptyState
title="Sign in required"
description="Please sign in to view elective courses."
icon={Inbox}
/>
</div>
)
}
const [availableCourses, mySelections] = await Promise.all([
getAvailableCoursesForStudent(studentId),
getStudentSelections(studentId),
])
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">Elective Courses</h2>
<p className="text-muted-foreground">
Browse available electives and manage your selections.
</p>
</div>
<StudentSelectionView
availableCourses={availableCourses}
mySelections={mySelections}
/>
</div>
)
}

View File

@@ -0,0 +1,45 @@
import { notFound } from "next/navigation"
import { Stethoscope } from "lucide-react"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { getClassMasterySummary } from "@/modules/diagnostic/data-access"
import { ClassDiagnosticView } from "@/modules/diagnostic/components/class-diagnostic-view"
export const dynamic = "force-dynamic"
export default async function ClassDiagnosticPage({
params,
}: {
params: Promise<{ classId: string }>
}) {
const { classId } = await params
const ctx = await getAuthContext()
// DataScope 校验:教师只能查看所教班级,学生/家长不可访问
if (ctx.dataScope.type === "class_taught" && !ctx.dataScope.classIds.includes(classId)) {
notFound()
}
if (ctx.dataScope.type === "class_members" || ctx.dataScope.type === "children") {
notFound()
}
const summary = await getClassMasterySummary(classId)
if (!summary) {
notFound()
}
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Stethoscope className="h-6 w-6" />
Class Diagnostic
</h2>
<p className="text-muted-foreground">
Class-level knowledge point mastery overview and student attention list.
</p>
</div>
<ClassDiagnosticView summary={summary} />
</div>
)
}

View File

@@ -0,0 +1,48 @@
import { getAuthContext } from "@/shared/lib/auth-guard"
import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports"
import { ReportList } from "@/modules/diagnostic/components/report-list"
import type { DiagnosticReportType, DiagnosticReportStatus } from "@/modules/diagnostic/types"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
export default async function TeacherDiagnosticPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const sp = await searchParams
const ctx = await getAuthContext()
const reportType = getParam(sp, "reportType")
const status = getParam(sp, "status")
const reports = await getDiagnosticReports({
reportType: reportType && reportType !== "all" ? (reportType as DiagnosticReportType) : undefined,
status: status && status !== "all" ? (status as DiagnosticReportStatus) : undefined,
})
// 学生角色仅查看自己的报告;其他角色查看全部
const visibleReports =
ctx.dataScope.type === "class_members"
? reports.filter((r) => r.studentId === ctx.userId)
: reports
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="text-2xl font-bold tracking-tight">Learning Diagnostic</h2>
<p className="text-muted-foreground">
View and manage diagnostic reports based on knowledge point mastery.
</p>
</div>
<ReportList reports={visibleReports} />
</div>
)
}

View File

@@ -0,0 +1,65 @@
import { notFound } from "next/navigation"
import { Stethoscope } from "lucide-react"
import { getAuthContext } from "@/shared/lib/auth-guard"
import {
getStudentMasterySummary,
getKnowledgePointStats,
} from "@/modules/diagnostic/data-access"
import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports"
import { StudentDiagnosticView } from "@/modules/diagnostic/components/student-diagnostic-view"
import type { MasteryRadarPoint } from "@/modules/diagnostic/types"
export const dynamic = "force-dynamic"
export default async function StudentDiagnosticPage({
params,
}: {
params: Promise<{ studentId: string }>
}) {
const { studentId } = await params
const ctx = await getAuthContext()
// DataScope 二次校验:学生只能看自己,家长只能看子女
if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) {
notFound()
}
if (ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)) {
notFound()
}
const [summary, reports] = await Promise.all([
getStudentMasterySummary(studentId),
getDiagnosticReports({ studentId }),
])
// 班级平均掌握度(用于雷达图对比)
let classAverageMastery: MasteryRadarPoint[] | undefined
if (summary) {
// 通过学生所在班级获取班级平均
const classStats = await getKnowledgePointStats()
classAverageMastery = classStats.map((k) => ({
knowledgePoint: k.knowledgePointName,
student: 0,
classAverage: k.averageMastery,
}))
}
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Stethoscope className="h-6 w-6" />
Student Diagnostic
</h2>
<p className="text-muted-foreground">
Knowledge point mastery analysis and diagnostic reports.
</p>
</div>
<StudentDiagnosticView
summary={summary}
reports={reports}
classAverageMastery={classAverageMastery}
/>
</div>
)
}

View File

@@ -0,0 +1,50 @@
import { auth } from "@/auth"
import { getElectiveCourses } from "@/modules/elective/data-access"
import { ElectiveCourseList } from "@/modules/elective/components/elective-course-list"
import type { ElectiveCourseStatus } from "@/modules/elective/types"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
const isValidStatus = (v?: string): v is ElectiveCourseStatus =>
v === "draft" || v === "open" || v === "closed" || v === "cancelled"
export default async function TeacherElectivePage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const session = await auth()
const teacherId = String(session?.user?.id ?? "")
const sp = await searchParams
const statusParam = getParam(sp, "status")
const status = isValidStatus(statusParam) ? statusParam : undefined
const courses = teacherId
? await getElectiveCourses({ teacherId, status })
: []
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">My Elective Courses</h2>
<p className="text-muted-foreground">
View and manage the elective courses you teach.
</p>
</div>
<ElectiveCourseList
courses={courses}
canManage
createHref="/admin/elective/create"
editHrefBuilder={(id) => `/admin/elective/${id}/edit`}
/>
</div>
)
}

View File

@@ -0,0 +1,55 @@
import { notFound } from "next/navigation"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { ProctoringDashboard } from "@/modules/proctoring/components/proctoring-dashboard"
import {
getExamForProctoring,
getExamProctoringSummary,
getStudentProctoringStatuses,
getRecentProctoringEvents,
} from "@/modules/proctoring/data-access"
import type { ProctoringDashboardData } from "@/modules/proctoring/types"
export const dynamic = "force-dynamic"
export default async function ExamProctoringPage({
params,
}: {
params: Promise<{ id: string }>
}) {
try {
await requirePermission(Permissions.EXAM_PROCTOR)
} catch (error) {
if (error instanceof PermissionDeniedError) {
return (
<div className="p-10 text-center text-muted-foreground">
exam:proctor
</div>
)
}
throw error
}
const { id } = await params
const exam = await getExamForProctoring(id)
if (!exam) return notFound()
// 并行拉取面板初始数据
const [summary, students, recentEvents] = await Promise.all([
getExamProctoringSummary(id),
getStudentProctoringStatuses(id),
getRecentProctoringEvents(id, 20),
])
const initialData: ProctoringDashboardData = {
summary,
students,
recentEvents,
}
return (
<div className="flex h-full flex-col space-y-4 p-4">
<ProctoringDashboard examId={id} initialData={initialData} />
</div>
)
}

View File

@@ -0,0 +1,91 @@
import { NextResponse } from "next/server"
import { z } from "zod"
import { requireAuth, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { db } from "@/shared/db"
import { examSubmissions } from "@/shared/db/schema"
import { and, eq } from "drizzle-orm"
import { recordProctoringEvent } from "@/modules/proctoring/data-access"
import type { ProctoringEventType } from "@/modules/proctoring/types"
export const dynamic = "force-dynamic"
const EventSchema = z.object({
submissionId: z.string().min(1),
eventType: z.enum([
"tab_switch",
"window_blur",
"copy_attempt",
"paste_attempt",
"right_click",
"devtools_open",
"fullscreen_exit",
"idle_timeout",
]) as z.ZodType<ProctoringEventType>,
eventDetail: z.string().optional(),
})
export async function POST(req: Request) {
try {
const ctx = await requireAuth()
const body = await req.json().catch(() => null)
if (!body) {
return NextResponse.json(
{ success: false, message: "Invalid JSON body" },
{ status: 400 },
)
}
const parsed = EventSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{
success: false,
message: parsed.error.issues[0]?.message ?? "Invalid payload",
},
{ status: 400 },
)
}
// 安全校验submission 必须属于当前学生
const submission = await db.query.examSubmissions.findFirst({
where: and(
eq(examSubmissions.id, parsed.data.submissionId),
eq(examSubmissions.studentId, ctx.userId),
),
columns: {
id: true,
examId: true,
},
})
if (!submission) {
return NextResponse.json(
{ success: false, message: "Submission not found for current user" },
{ status: 404 },
)
}
await recordProctoringEvent({
submissionId: parsed.data.submissionId,
studentId: ctx.userId,
examId: submission.examId,
eventType: parsed.data.eventType,
eventDetail: parsed.data.eventDetail,
})
return NextResponse.json({ success: true })
} catch (error) {
if (error instanceof PermissionDeniedError) {
return NextResponse.json(
{ success: false, message: error.message },
{ status: 401 },
)
}
console.error("POST /api/proctoring/event error:", error)
return NextResponse.json(
{ success: false, message: "Failed to record proctoring event" },
{ status: 500 },
)
}
}

View File

@@ -0,0 +1,148 @@
"use server"
import { revalidatePath } from "next/cache"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import {
generateDiagnosticReport,
generateClassDiagnosticReport,
getDiagnosticReports,
getDiagnosticReportById,
publishDiagnosticReport,
deleteDiagnosticReport,
} from "./data-access-reports"
import type { DiagnosticReportQueryParams } from "./types"
/** 生成学生个人诊断报告 */
export async function generateStudentReportAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.DIAGNOSTIC_MANAGE)
const studentId = formData.get("studentId")
const period = formData.get("period")
if (typeof studentId !== "string" || studentId.length === 0) {
return { success: false, message: "Missing studentId" }
}
if (typeof period !== "string" || period.length === 0) {
return { success: false, message: "Missing period" }
}
const id = await generateDiagnosticReport(studentId, period, ctx.userId)
revalidatePath("/teacher/diagnostic")
revalidatePath(`/teacher/diagnostic/student/${studentId}`)
return { success: true, message: "Diagnostic report generated", data: id }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
/** 生成班级诊断报告 */
export async function generateClassReportAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.DIAGNOSTIC_MANAGE)
const classId = formData.get("classId")
const period = formData.get("period")
if (typeof classId !== "string" || classId.length === 0) {
return { success: false, message: "Missing classId" }
}
if (typeof period !== "string" || period.length === 0) {
return { success: false, message: "Missing period" }
}
const id = await generateClassDiagnosticReport(classId, period, ctx.userId)
revalidatePath("/teacher/diagnostic")
revalidatePath(`/teacher/diagnostic/class/${classId}`)
return { success: true, message: "Class diagnostic report generated", data: id }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
/** 发布诊断报告 */
export async function publishReportAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.DIAGNOSTIC_MANAGE)
const id = formData.get("id")
if (typeof id !== "string" || id.length === 0) {
return { success: false, message: "Missing report id" }
}
await publishDiagnosticReport(id)
revalidatePath("/teacher/diagnostic")
return { success: true, message: "Report published" }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
/** 删除诊断报告 */
export async function deleteReportAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.DIAGNOSTIC_MANAGE)
const id = formData.get("id")
if (typeof id !== "string" || id.length === 0) {
return { success: false, message: "Missing report id" }
}
await deleteDiagnosticReport(id)
revalidatePath("/teacher/diagnostic")
return { success: true, message: "Report deleted" }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
/** 查询诊断报告列表(读权限) */
export async function getDiagnosticReportsAction(
params: DiagnosticReportQueryParams
): Promise<ActionState<Awaited<ReturnType<typeof getDiagnosticReports>>>> {
try {
await requirePermission(Permissions.DIAGNOSTIC_READ)
const reports = await getDiagnosticReports(params)
return { success: true, data: reports }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
/** 获取诊断报告详情(读权限) */
export async function getDiagnosticReportByIdAction(
id: string
): Promise<ActionState<Awaited<ReturnType<typeof getDiagnosticReportById>>>> {
try {
await requirePermission(Permissions.DIAGNOSTIC_READ)
const report = await getDiagnosticReportById(id)
return { success: true, data: report }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}

View File

@@ -0,0 +1,267 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Users, AlertTriangle, TrendingUp, FileText } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { EmptyState } from "@/shared/components/ui/empty-state"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { usePermission } from "@/shared/hooks"
import { Permissions } from "@/shared/types/permissions"
import { generateClassReportAction } from "../actions"
import type { ClassMasterySummary } from "../types"
interface ClassDiagnosticViewProps {
summary: ClassMasterySummary | null
}
/** 掌握度热力图颜色 */
function masteryColor(level: number): string {
if (level >= 80) return "bg-green-500"
if (level >= 60) return "bg-yellow-500"
if (level >= 40) return "bg-orange-500"
return "bg-red-500"
}
export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
const router = useRouter()
const { hasPermission } = usePermission()
const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE)
const [period, setPeriod] = useState(new Date().toISOString().slice(0, 7))
const [isGenerating, setIsGenerating] = useState(false)
const handleGenerate = async () => {
if (!summary) return
setIsGenerating(true)
const formData = new FormData()
formData.set("classId", summary.classId)
formData.set("period", period)
const result = await generateClassReportAction(null, formData)
setIsGenerating(false)
if (result.success) {
toast.success(result.message)
router.refresh()
} else {
toast.error(result.message || "Failed to generate class report")
}
}
if (!summary) {
return (
<EmptyState
title="No class data"
description="Unable to load class mastery summary."
icon={Users}
className="border-none shadow-none"
/>
)
}
return (
<div className="space-y-6">
{/* 概览 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Class</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{summary.className}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Students</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{summary.studentCount}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Avg Mastery</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{summary.averageMastery.toFixed(1)}%</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Need Attention</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold text-red-600">{summary.studentsNeedingAttention.length}</p>
</CardContent>
</Card>
</div>
{/* 知识点掌握度热力图 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
Knowledge Point Mastery Heatmap
</CardTitle>
<CardDescription>
Average mastery level per knowledge point (green 80%, yellow 60-79%, orange 40-59%, red &lt;40%).
</CardDescription>
</CardHeader>
<CardContent>
{summary.knowledgePointStats.length === 0 ? (
<p className="text-sm text-muted-foreground">No knowledge point data available.</p>
) : (
<div className="flex flex-wrap gap-2">
{summary.knowledgePointStats.map((kp) => (
<div
key={kp.knowledgePointId}
className={`flex flex-col items-center justify-center rounded-md px-3 py-2 text-white ${masteryColor(kp.averageMastery)}`}
title={`${kp.knowledgePointName}: ${kp.averageMastery.toFixed(1)}% (mastered ${kp.masteredCount}/${kp.totalStudents})`}
>
<span className="max-w-[120px] truncate text-xs font-medium">
{kp.knowledgePointName}
</span>
<span className="text-sm font-bold">{kp.averageMastery.toFixed(0)}%</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* 知识点排名表 */}
<Card>
<CardHeader>
<CardTitle>Knowledge Point Ranking</CardTitle>
</CardHeader>
<CardContent>
{summary.knowledgePointStats.length === 0 ? (
<p className="text-sm text-muted-foreground">No data.</p>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Knowledge Point</TableHead>
<TableHead className="text-right">Avg Mastery</TableHead>
<TableHead className="text-right">Mastered (80%)</TableHead>
<TableHead className="text-right">Not Mastered (&lt;60%)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[...summary.knowledgePointStats]
.sort((a, b) => b.averageMastery - a.averageMastery)
.map((kp) => (
<TableRow key={kp.knowledgePointId}>
<TableCell className="font-medium">{kp.knowledgePointName}</TableCell>
<TableCell className="text-right font-mono">
<Badge variant={kp.averageMastery >= 80 ? "default" : kp.averageMastery >= 60 ? "secondary" : "destructive"}>
{kp.averageMastery.toFixed(1)}%
</Badge>
</TableCell>
<TableCell className="text-right text-green-600">{kp.masteredCount}</TableCell>
<TableCell className="text-right text-red-600">{kp.notMasteredCount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* 需重点关注的学生 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-red-600" />
Students Needing Attention (avg &lt;60%)
</CardTitle>
<CardDescription>Students with low overall mastery.</CardDescription>
</CardHeader>
<CardContent>
{summary.studentsNeedingAttention.length === 0 ? (
<p className="text-sm text-muted-foreground">All students are above the attention threshold.</p>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead className="text-right">Avg Mastery</TableHead>
<TableHead className="text-right">Weak Points</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{summary.studentsNeedingAttention.map((s) => (
<TableRow key={s.studentId}>
<TableCell className="font-medium">{s.studentName}</TableCell>
<TableCell className="text-right">
<Badge variant="destructive">{s.averageMastery.toFixed(1)}%</Badge>
</TableCell>
<TableCell className="text-right text-red-600">{s.weakCount}</TableCell>
<TableCell>
<Button asChild variant="ghost" size="sm">
<Link href={`/teacher/diagnostic/student/${s.studentId}`}>
<FileText className="mr-1 h-3 w-3" />
View
</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* 生成班级报告 */}
{canManage ? (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-4 w-4" />
Generate Class Diagnostic Report
</CardTitle>
<CardDescription>
Generate a class-level diagnostic report with aggregated analysis.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-end gap-3">
<div className="grid gap-2">
<Label htmlFor="class-period" className="text-xs">Period (YYYY-MM)</Label>
<Input
id="class-period"
type="month"
value={period}
onChange={(e) => setPeriod(e.target.value)}
className="w-[180px]"
/>
</div>
<Button onClick={handleGenerate} disabled={isGenerating}>
{isGenerating ? "Generating..." : "Generate Class Report"}
</Button>
</div>
</CardContent>
</Card>
) : null}
</div>
)
}

View File

@@ -0,0 +1,114 @@
"use client"
import { RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, Legend } from "recharts"
import { Target } from "lucide-react"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/shared/components/ui/chart"
import { EmptyState } from "@/shared/components/ui/empty-state"
import type { MasteryRadarPoint } from "@/modules/diagnostic/types"
const chartConfig = {
student: { label: "Student", color: "hsl(var(--primary))" },
classAverage: { label: "Class Avg", color: "hsl(var(--chart-2))" },
}
interface MasteryRadarChartProps {
data: MasteryRadarPoint[]
}
export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
if (!data || data.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Target className="h-4 w-4" />
Knowledge Point Mastery
</CardTitle>
<CardDescription>
Radar chart of mastery level (0-100) across knowledge points.
</CardDescription>
</CardHeader>
<CardContent>
<EmptyState
icon={Target}
title="No mastery data"
description="No knowledge point mastery records found for this student."
className="border-none h-60"
/>
</CardContent>
</Card>
)
}
// 知识点名称过长时截断显示
const chartData = data.map((d) => ({
...d,
shortName: d.knowledgePoint.length > 8 ? `${d.knowledgePoint.slice(0, 8)}...` : d.knowledgePoint,
}))
const hasClassAverage = data.some((d) => d.classAverage !== undefined)
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Target className="h-4 w-4" />
Knowledge Point Mastery
</CardTitle>
<CardDescription>
Radar chart of mastery level (0-100) across knowledge points.
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="mx-auto h-[360px] w-full max-w-[520px]">
<RadarChart data={chartData} outerRadius="75%">
<PolarGrid strokeDasharray="4 4" strokeOpacity={0.4} />
<PolarAngleAxis
dataKey="shortName"
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
/>
<PolarRadiusAxis
domain={[0, 100]}
tickCount={5}
tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
axisLine={false}
/>
<ChartTooltip content={<ChartTooltipContent className="w-[220px]" />} />
{hasClassAverage ? <Legend /> : null}
<Radar
name="Student"
dataKey="student"
stroke="var(--color-student)"
fill="var(--color-student)"
fillOpacity={0.35}
strokeWidth={2}
/>
{hasClassAverage ? (
<Radar
name="Class Avg"
dataKey="classAverage"
stroke="var(--color-classAverage)"
fill="var(--color-classAverage)"
fillOpacity={0.15}
strokeWidth={2}
strokeDasharray="4 4"
/>
) : null}
</RadarChart>
</ChartContainer>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,262 @@
"use client"
import { useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { useCallback } from "react"
import { toast } from "sonner"
import { FileText, Trash2, Send } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Label } from "@/shared/components/ui/label"
import { EmptyState } from "@/shared/components/ui/empty-state"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { formatDate } from "@/shared/lib/utils"
import { usePermission } from "@/shared/hooks"
import { Permissions } from "@/shared/types/permissions"
import { publishReportAction, deleteReportAction } from "../actions"
import type { DiagnosticReportWithDetails } from "../types"
const typeLabels: Record<string, string> = {
individual: "Individual",
class: "Class",
grade: "Grade",
}
const statusColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
draft: "secondary",
published: "default",
archived: "outline",
}
interface ReportListProps {
reports: DiagnosticReportWithDetails[]
}
export function ReportList({ reports }: ReportListProps) {
const router = useRouter()
const searchParams = useSearchParams()
const { hasPermission } = usePermission()
const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE)
const [deleteId, setDeleteId] = useState<string | null>(null)
const [publishId, setPublishId] = useState<string | null>(null)
const [isBusy, setIsBusy] = useState(false)
const updateParam = useCallback(
(key: string, value: string) => {
const params = new URLSearchParams(searchParams.toString())
if (value && value !== "all") {
params.set(key, value)
} else {
params.delete(key)
}
router.push(`?${params.toString()}`)
},
[router, searchParams]
)
const handlePublish = async () => {
if (!publishId) return
setIsBusy(true)
const formData = new FormData()
formData.set("id", publishId)
const result = await publishReportAction(null, formData)
setIsBusy(false)
if (result.success) {
toast.success(result.message)
setPublishId(null)
router.refresh()
} else {
toast.error(result.message || "Failed to publish")
}
}
const handleDelete = async () => {
if (!deleteId) return
setIsBusy(true)
const formData = new FormData()
formData.set("id", deleteId)
const result = await deleteReportAction(null, formData)
setIsBusy(false)
if (result.success) {
toast.success(result.message)
setDeleteId(null)
router.refresh()
} else {
toast.error(result.message || "Failed to delete")
}
}
const reportType = searchParams.get("reportType") ?? "all"
const status = searchParams.get("status") ?? "all"
return (
<div className="space-y-4">
{/* 过滤器 */}
<div className="grid grid-cols-1 gap-4 rounded-lg border bg-card p-4 md:grid-cols-2">
<div className="grid gap-2">
<Label className="text-xs">Report Type</Label>
<Select value={reportType} onValueChange={(v) => updateParam("reportType", v)}>
<SelectTrigger className="h-9">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
<SelectItem value="individual">Individual</SelectItem>
<SelectItem value="class">Class</SelectItem>
<SelectItem value="grade">Grade</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label className="text-xs">Status</Label>
<Select value={status} onValueChange={(v) => updateParam("status", v)}>
<SelectTrigger className="h-9">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="published">Published</SelectItem>
<SelectItem value="archived">Archived</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{reports.length === 0 ? (
<EmptyState
title="No diagnostic reports"
description="Generate diagnostic reports to see them here."
icon={FileText}
className="border-none shadow-none"
/>
) : (
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Student / Target</TableHead>
<TableHead>Period</TableHead>
<TableHead className="text-right">Score</TableHead>
<TableHead>Status</TableHead>
<TableHead>Generated By</TableHead>
<TableHead>Date</TableHead>
{canManage ? <TableHead className="w-24">Actions</TableHead> : null}
</TableRow>
</TableHeader>
<TableBody>
{reports.map((r) => (
<TableRow key={r.id}>
<TableCell>
<Badge variant="outline">{typeLabels[r.reportType] ?? r.reportType}</Badge>
</TableCell>
<TableCell className="font-medium">{r.studentName}</TableCell>
<TableCell>{r.period ?? "-"}</TableCell>
<TableCell className="text-right font-mono">
{r.overallScore !== null ? `${r.overallScore.toFixed(1)}%` : "-"}
</TableCell>
<TableCell>
<Badge variant={statusColors[r.status] ?? "secondary"}>{r.status}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{r.generatedByName ?? "-"}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(r.createdAt)}</TableCell>
{canManage ? (
<TableCell>
<div className="flex gap-1">
{r.status === "draft" ? (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-green-600"
onClick={() => setPublishId(r.id)}
title="Publish"
>
<Send className="h-4 w-4" />
</Button>
) : null}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => setDeleteId(r.id)}
title="Delete"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
) : null}
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* 发布确认 */}
<Dialog open={publishId !== null} onOpenChange={(open) => !open && setPublishId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Publish Report</DialogTitle>
<DialogDescription>
Once published, the report will be visible to students. Continue?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setPublishId(null)} disabled={isBusy}>
Cancel
</Button>
<Button onClick={handlePublish} disabled={isBusy}>
{isBusy ? "Publishing..." : "Publish"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认 */}
<Dialog open={deleteId !== null} onOpenChange={(open) => !open && setDeleteId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Report</DialogTitle>
<DialogDescription>
Are you sure you want to delete this diagnostic report? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)} disabled={isBusy}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={isBusy}>
{isBusy ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,229 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Award, AlertTriangle, Lightbulb, FileText, TrendingUp } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { usePermission } from "@/shared/hooks"
import { Permissions } from "@/shared/types/permissions"
import { MasteryRadarChart } from "./mastery-radar-chart"
import { generateStudentReportAction } from "../actions"
import type { DiagnosticReportWithDetails, MasteryRadarPoint, StudentMasterySummary } from "../types"
interface StudentDiagnosticViewProps {
summary: StudentMasterySummary | null
reports: DiagnosticReportWithDetails[]
classAverageMastery?: MasteryRadarPoint[]
}
export function StudentDiagnosticView({ summary, reports, classAverageMastery }: StudentDiagnosticViewProps) {
const router = useRouter()
const { hasPermission } = usePermission()
const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE)
const [period, setPeriod] = useState(new Date().toISOString().slice(0, 7))
const [isGenerating, setIsGenerating] = useState(false)
const handleGenerate = async () => {
if (!summary) return
setIsGenerating(true)
const formData = new FormData()
formData.set("studentId", summary.studentId)
formData.set("period", period)
const result = await generateStudentReportAction(null, formData)
setIsGenerating(false)
if (result.success) {
toast.success(result.message)
router.refresh()
} else {
toast.error(result.message || "Failed to generate report")
}
}
if (!summary) {
return (
<EmptyState
title="No diagnostic data"
description="Unable to load student mastery data."
icon={FileText}
className="border-none shadow-none"
/>
)
}
const radarData: MasteryRadarPoint[] = summary.allMastery.map((m) => {
const classAvg = classAverageMastery?.find((c) => c.knowledgePoint === m.knowledgePointName)
return {
knowledgePoint: m.knowledgePointName,
student: Math.round(m.masteryLevel * 100) / 100,
classAverage: classAvg?.classAverage,
}
})
const publishedReports = reports.filter((r) => r.status === "published")
const latestReport = publishedReports[0] ?? reports[0] ?? null
return (
<div className="space-y-6">
{/* 概览卡片 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Student</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{summary.studentName}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Overall Mastery</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{summary.averageMastery.toFixed(1)}%</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Strengths</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold text-green-600">{summary.strengths.length}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Weaknesses</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold text-red-600">{summary.weaknesses.length}</p>
</CardContent>
</Card>
</div>
{/* 雷达图 */}
<MasteryRadarChart data={radarData} />
{/* 强项 / 弱项 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Award className="h-4 w-4 text-green-600" />
Strengths (80%)
</CardTitle>
<CardDescription>Knowledge points with high mastery.</CardDescription>
</CardHeader>
<CardContent>
{summary.strengths.length === 0 ? (
<p className="text-sm text-muted-foreground">No strengths identified yet.</p>
) : (
<ul className="space-y-2">
{summary.strengths.map((m) => (
<li key={m.knowledgePointId} className="flex items-center justify-between">
<span className="text-sm">{m.knowledgePointName}</span>
<Badge variant="default" className="bg-green-600">{m.masteryLevel.toFixed(1)}%</Badge>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-red-600" />
Weaknesses (&lt;60%)
</CardTitle>
<CardDescription>Knowledge points needing attention.</CardDescription>
</CardHeader>
<CardContent>
{summary.weaknesses.length === 0 ? (
<p className="text-sm text-muted-foreground">No weaknesses identified.</p>
) : (
<ul className="space-y-2">
{summary.weaknesses.map((m) => (
<li key={m.knowledgePointId} className="flex items-center justify-between">
<span className="text-sm">{m.knowledgePointName}</span>
<Badge variant="destructive">{m.masteryLevel.toFixed(1)}%</Badge>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
{/* 生成报告 */}
{canManage ? (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
Generate Diagnostic Report
</CardTitle>
<CardDescription>
Generate an AI-analyzed diagnostic report for this student.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-end gap-3">
<div className="grid gap-2">
<Label htmlFor="period" className="text-xs">Period (YYYY-MM)</Label>
<Input
id="period"
type="month"
value={period}
onChange={(e) => setPeriod(e.target.value)}
className="w-[180px]"
/>
</div>
<Button onClick={handleGenerate} disabled={isGenerating}>
{isGenerating ? "Generating..." : "Generate Report"}
</Button>
</div>
</CardContent>
</Card>
) : null}
{/* 最新报告 / 建议 */}
{latestReport ? (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Lightbulb className="h-4 w-4" />
Diagnostic Report
<Badge variant={latestReport.status === "published" ? "default" : "secondary"}>
{latestReport.status}
</Badge>
</CardTitle>
<CardDescription>
Period: {latestReport.period ?? "-"} · Overall: {latestReport.overallScore?.toFixed(1) ?? "-"}%
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{latestReport.summary ? (
<p className="text-sm">{latestReport.summary}</p>
) : null}
{latestReport.recommendations && latestReport.recommendations.length > 0 ? (
<div>
<h4 className="mb-2 text-sm font-semibold">Recommendations</h4>
<ul className="space-y-1.5">
{latestReport.recommendations.map((rec, i) => (
<li key={i} className="text-sm text-muted-foreground"> {rec}</li>
))}
</ul>
</div>
) : null}
</CardContent>
</Card>
) : null}
</div>
)
}

View File

@@ -0,0 +1,202 @@
import "server-only"
import { and, desc, eq, inArray } from "drizzle-orm"
import { db } from "@/shared/db"
import { learningDiagnosticReports, users } from "@/shared/db/schema"
import { getClassMasterySummary, getStudentMasterySummary } from "./data-access"
import type {
DiagnosticReport,
DiagnosticReportQueryParams,
DiagnosticReportWithDetails,
} from "./types"
const toNumber = (v: unknown): number => {
const n = typeof v === "number" ? v : Number(v)
return Number.isFinite(n) ? n : 0
}
const round2 = (n: number): number => Math.round(n * 100) / 100
const serializeReport = (r: typeof learningDiagnosticReports.$inferSelect): DiagnosticReport => ({
id: r.id,
studentId: r.studentId,
generatedBy: r.generatedBy,
reportType: r.reportType,
period: r.period,
summary: r.summary,
strengths: (r.strengths as string[] | null) ?? null,
weaknesses: (r.weaknesses as string[] | null) ?? null,
recommendations: (r.recommendations as string[] | null) ?? null,
overallScore: r.overallScore !== null ? toNumber(r.overallScore) : null,
status: r.status,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
})
/** 生成个人诊断报告 */
export async function generateDiagnosticReport(
studentId: string,
period: string,
generatedBy: string
): Promise<string> {
const summary = await getStudentMasterySummary(studentId)
if (!summary) throw new Error("Student not found")
const overallScore = summary.averageMastery
const strengths = summary.strengths.map((m) => `${m.knowledgePointName} (${m.masteryLevel.toFixed(1)}%)`)
const weaknesses = summary.weaknesses.map((m) => `${m.knowledgePointName} (${m.masteryLevel.toFixed(1)}%)`)
const recommendations = summary.weaknesses.map(
(m) => `建议复习「${m.knowledgePointName}」知识点,多做相关练习以提升掌握度(当前 ${m.masteryLevel.toFixed(1)}%)。`
)
if (recommendations.length === 0) {
recommendations.push("整体掌握情况良好,建议保持当前学习节奏并挑战更高难度题目。")
}
const summaryText = `学生 ${summary.studentName}${period} 期间整体掌握度 ${overallScore.toFixed(1)}%,共评估 ${summary.totalKnowledgePoints} 个知识点,强项 ${strengths.length} 个,弱项 ${weaknesses.length} 个。`
const { createId } = await import("@paralleldrive/cuid2")
const id = createId()
await db.insert(learningDiagnosticReports).values({
id,
studentId,
generatedBy,
reportType: "individual",
period,
summary: summaryText,
strengths,
weaknesses,
recommendations,
overallScore: String(overallScore),
status: "draft",
})
return id
}
/** 生成班级诊断报告 */
export async function generateClassDiagnosticReport(
classId: string,
period: string,
generatedBy: string
): Promise<string> {
const summary = await getClassMasterySummary(classId)
if (!summary) throw new Error("Class not found")
const topWeak = summary.knowledgePointStats
.filter((k) => k.averageMastery < 60)
.sort((a, b) => a.averageMastery - b.averageMastery)
.slice(0, 5)
const strengths = summary.knowledgePointStats
.filter((k) => k.averageMastery >= 80)
.map((k) => `${k.knowledgePointName} (均 ${k.averageMastery.toFixed(1)}%)`)
const weaknesses = topWeak.map((k) => `${k.knowledgePointName} (均 ${k.averageMastery.toFixed(1)}%)`)
const recommendations = topWeak.map(
(k) => `班级在「${k.knowledgePointName}」整体掌握度偏低(${k.averageMastery.toFixed(1)}%),建议安排专项复习与巩固练习。`
)
if (recommendations.length === 0) {
recommendations.push("班级整体掌握情况良好,建议保持当前教学节奏。")
}
const summaryText = `班级 ${summary.className}${period} 期间整体掌握度 ${summary.averageMastery.toFixed(1)}%,学生 ${summary.studentCount} 人,需重点关注 ${summary.studentsNeedingAttention.length} 人。`
const { createId } = await import("@paralleldrive/cuid2")
const id = createId()
await db.insert(learningDiagnosticReports).values({
id,
studentId: generatedBy, // 班级报告 studentId 存生成者 IDschema 要求 NOT NULL
generatedBy,
reportType: "class",
period,
summary: summaryText,
strengths,
weaknesses,
recommendations,
overallScore: String(summary.averageMastery),
status: "draft",
})
return id
}
/** 查询诊断报告列表 */
export async function getDiagnosticReports(
filters: DiagnosticReportQueryParams
): Promise<DiagnosticReportWithDetails[]> {
const conditions = []
if (filters.studentId) conditions.push(eq(learningDiagnosticReports.studentId, filters.studentId))
if (filters.reportType) conditions.push(eq(learningDiagnosticReports.reportType, filters.reportType))
if (filters.status) conditions.push(eq(learningDiagnosticReports.status, filters.status))
if (filters.period) conditions.push(eq(learningDiagnosticReports.period, filters.period))
const rows = await db
.select({
report: learningDiagnosticReports,
studentName: users.name,
})
.from(learningDiagnosticReports)
.leftJoin(users, eq(users.id, learningDiagnosticReports.studentId))
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(learningDiagnosticReports.createdAt))
const generatorIds = Array.from(
new Set(rows.map((r) => r.report.generatedBy).filter((id): id is string => id !== null))
)
const generatorMap = new Map<string, string>()
if (generatorIds.length > 0) {
const generators = await db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, generatorIds))
for (const g of generators) generatorMap.set(g.id, g.name ?? "Unknown")
}
return rows.map((r) => ({
...serializeReport(r.report),
studentName: r.studentName ?? "Unknown",
generatedByName: r.report.generatedBy ? generatorMap.get(r.report.generatedBy) ?? "Unknown" : null,
}))
}
/** 获取报告详情 */
export async function getDiagnosticReportById(
id: string
): Promise<DiagnosticReportWithDetails | null> {
const [row] = await db
.select({ report: learningDiagnosticReports, studentName: users.name })
.from(learningDiagnosticReports)
.leftJoin(users, eq(users.id, learningDiagnosticReports.studentId))
.where(eq(learningDiagnosticReports.id, id))
.limit(1)
if (!row) return null
let generatedByName: string | null = null
if (row.report.generatedBy) {
const [gen] = await db
.select({ name: users.name })
.from(users)
.where(eq(users.id, row.report.generatedBy))
.limit(1)
generatedByName = gen?.name ?? null
}
return {
...serializeReport(row.report),
studentName: row.studentName ?? "Unknown",
generatedByName,
}
}
/** 发布诊断报告 */
export async function publishDiagnosticReport(id: string): Promise<void> {
await db
.update(learningDiagnosticReports)
.set({ status: "published", updatedAt: new Date() })
.where(eq(learningDiagnosticReports.id, id))
}
/** 删除诊断报告 */
export async function deleteDiagnosticReport(id: string): Promise<void> {
await db.delete(learningDiagnosticReports).where(eq(learningDiagnosticReports.id, id))
}
// 防止 round2 未使用警告(保留以备扩展)
void round2

View File

@@ -0,0 +1,254 @@
import "server-only"
import { and, asc, desc, eq, inArray } from "drizzle-orm"
import { db } from "@/shared/db"
import {
classEnrollments,
classes,
examSubmissions,
knowledgePointMastery,
knowledgePoints,
questionsToKnowledgePoints,
submissionAnswers,
users,
} from "@/shared/db/schema"
import type {
ClassMasterySummary,
KnowledgePointMastery,
KnowledgePointStat,
MasteryWithKnowledgePoint,
StudentMasterySummary,
} from "./types"
const toNumber = (v: unknown): number => {
const n = typeof v === "number" ? v : Number(v)
return Number.isFinite(n) ? n : 0
}
const round2 = (n: number): number => Math.round(n * 100) / 100
const serializeMastery = (r: typeof knowledgePointMastery.$inferSelect): KnowledgePointMastery => ({
id: r.id,
studentId: r.studentId,
knowledgePointId: r.knowledgePointId,
masteryLevel: toNumber(r.masteryLevel),
totalQuestions: r.totalQuestions,
correctQuestions: r.correctQuestions,
lastAssessedAt: r.lastAssessedAt.toISOString(),
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
})
/** 获取学生在所有知识点的掌握度(含知识点名称) */
export async function getStudentMastery(studentId: string): Promise<MasteryWithKnowledgePoint[]> {
const rows = await db
.select({
mastery: knowledgePointMastery,
kpName: knowledgePoints.name,
kpDescription: knowledgePoints.description,
})
.from(knowledgePointMastery)
.leftJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId))
.where(eq(knowledgePointMastery.studentId, studentId))
.orderBy(desc(knowledgePointMastery.masteryLevel))
return rows.map((r) => ({
...serializeMastery(r.mastery),
knowledgePointName: r.kpName ?? "Unknown",
knowledgePointDescription: r.kpDescription,
}))
}
/** 获取学生掌握度摘要(含强项/弱项分析) */
export async function getStudentMasterySummary(studentId: string): Promise<StudentMasterySummary | null> {
const [student] = await db.select({ name: users.name }).from(users).where(eq(users.id, studentId)).limit(1)
if (!student) return null
const allMastery = await getStudentMastery(studentId)
const averageMastery =
allMastery.length > 0
? round2(allMastery.reduce((acc, m) => acc + m.masteryLevel, 0) / allMastery.length)
: 0
return {
studentId,
studentName: student.name ?? "Unknown",
averageMastery,
totalKnowledgePoints: allMastery.length,
strengths: allMastery.filter((m) => m.masteryLevel >= 80),
weaknesses: allMastery.filter((m) => m.masteryLevel < 60),
allMastery,
}
}
/** 从提交答案更新掌握度(正确率作为掌握度) */
export async function updateMasteryFromSubmission(submissionId: string): Promise<void> {
const [submission] = await db
.select({ studentId: examSubmissions.studentId })
.from(examSubmissions)
.where(eq(examSubmissions.id, submissionId))
.limit(1)
if (!submission) return
const answers = await db
.select({
questionId: submissionAnswers.questionId,
score: submissionAnswers.score,
})
.from(submissionAnswers)
.where(eq(submissionAnswers.submissionId, submissionId))
if (answers.length === 0) return
const questionIds = Array.from(new Set(answers.map((a) => a.questionId)))
const kpLinks = await db
.select({
questionId: questionsToKnowledgePoints.questionId,
knowledgePointId: questionsToKnowledgePoints.knowledgePointId,
})
.from(questionsToKnowledgePoints)
.where(inArray(questionsToKnowledgePoints.questionId, questionIds))
const kpStats = new Map<string, { total: number; correct: number }>()
for (const link of kpLinks) {
const answer = answers.find((a) => a.questionId === link.questionId)
if (!answer) continue
const stat = kpStats.get(link.knowledgePointId) ?? { total: 0, correct: 0 }
stat.total += 1
if ((answer.score ?? 0) > 0) stat.correct += 1
kpStats.set(link.knowledgePointId, stat)
}
const now = new Date()
for (const [kpId, stat] of kpStats.entries()) {
const masteryLevel = stat.total > 0 ? round2((stat.correct / stat.total) * 100) : 0
await db
.insert(knowledgePointMastery)
.values({
studentId: submission.studentId,
knowledgePointId: kpId,
masteryLevel: String(masteryLevel),
totalQuestions: stat.total,
correctQuestions: stat.correct,
lastAssessedAt: now,
})
.onDuplicateKeyUpdate({
set: {
masteryLevel: String(masteryLevel),
totalQuestions: stat.total,
correctQuestions: stat.correct,
lastAssessedAt: now,
updatedAt: now,
},
})
}
}
/** 获取班级掌握度摘要 */
export async function getClassMasterySummary(classId: string): Promise<ClassMasterySummary | null> {
const [classRow] = await db.select({ id: classes.id, name: classes.name }).from(classes).where(eq(classes.id, classId)).limit(1)
if (!classRow) return null
const students = await db
.select({ id: users.id, name: users.name })
.from(classEnrollments)
.innerJoin(users, eq(users.id, classEnrollments.studentId))
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active")))
.orderBy(asc(users.name))
if (students.length === 0) {
return { classId, className: classRow.name, studentCount: 0, averageMastery: 0, knowledgePointStats: [], studentsNeedingAttention: [] }
}
const studentIds = students.map((s) => s.id)
const masteryRows = await db
.select({ mastery: knowledgePointMastery, kpName: knowledgePoints.name })
.from(knowledgePointMastery)
.leftJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId))
.where(inArray(knowledgePointMastery.studentId, studentIds))
const byKp = new Map<string, { name: string; levels: number[]; mastered: number; notMastered: number }>()
const byStudent = new Map<string, { levels: number[]; weakCount: number }>()
for (const s of students) byStudent.set(s.id, { levels: [], weakCount: 0 })
for (const r of masteryRows) {
const level = toNumber(r.mastery.masteryLevel)
const kpId = r.mastery.knowledgePointId
const kpEntry = byKp.get(kpId) ?? { name: r.kpName ?? "Unknown", levels: [], mastered: 0, notMastered: 0 }
kpEntry.levels.push(level)
if (level >= 80) kpEntry.mastered += 1
if (level < 60) kpEntry.notMastered += 1
byKp.set(kpId, kpEntry)
const stuEntry = byStudent.get(r.mastery.studentId)
if (stuEntry) {
stuEntry.levels.push(level)
if (level < 60) stuEntry.weakCount += 1
}
}
const knowledgePointStats: KnowledgePointStat[] = Array.from(byKp.entries()).map(([kpId, e]) => ({
knowledgePointId: kpId,
knowledgePointName: e.name,
averageMastery: e.levels.length > 0 ? round2(e.levels.reduce((a, b) => a + b, 0) / e.levels.length) : 0,
masteredCount: e.mastered,
notMasteredCount: e.notMastered,
totalStudents: students.length,
}))
const allLevels = masteryRows.map((r) => toNumber(r.mastery.masteryLevel))
const averageMastery = allLevels.length > 0 ? round2(allLevels.reduce((a, b) => a + b, 0) / allLevels.length) : 0
const studentsNeedingAttention = students
.map((s) => {
const e = byStudent.get(s.id)!
const avg = e.levels.length > 0 ? round2(e.levels.reduce((a, b) => a + b, 0) / e.levels.length) : 0
return { studentId: s.id, studentName: s.name ?? "Unknown", averageMastery: avg, weakCount: e.weakCount }
})
.filter((s) => s.averageMastery < 60)
.sort((a, b) => a.averageMastery - b.averageMastery)
return { classId, className: classRow.name, studentCount: students.length, averageMastery, knowledgePointStats, studentsNeedingAttention }
}
/** 获取知识点统计(按班级或年级聚合) */
export async function getKnowledgePointStats(classId?: string, gradeId?: string): Promise<KnowledgePointStat[]> {
let studentIds: string[] = []
if (classId) {
const rows = await db.select({ studentId: classEnrollments.studentId }).from(classEnrollments).where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active")))
studentIds = rows.map((r) => r.studentId)
} else if (gradeId) {
const rows = await db.select({ id: users.id }).from(users).where(eq(users.gradeId, gradeId))
studentIds = rows.map((r) => r.id)
}
if (studentIds.length === 0) return []
const masteryRows = await db
.select({ mastery: knowledgePointMastery, kpName: knowledgePoints.name })
.from(knowledgePointMastery)
.leftJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId))
.where(inArray(knowledgePointMastery.studentId, studentIds))
const byKp = new Map<string, { name: string; levels: number[]; mastered: number; notMastered: number }>()
for (const r of masteryRows) {
const level = toNumber(r.mastery.masteryLevel)
const kpId = r.mastery.knowledgePointId
const e = byKp.get(kpId) ?? { name: r.kpName ?? "Unknown", levels: [], mastered: 0, notMastered: 0 }
e.levels.push(level)
if (level >= 80) e.mastered += 1
if (level < 60) e.notMastered += 1
byKp.set(kpId, e)
}
return Array.from(byKp.entries()).map(([kpId, e]) => ({
knowledgePointId: kpId,
knowledgePointName: e.name,
averageMastery: e.levels.length > 0 ? round2(e.levels.reduce((a, b) => a + b, 0) / e.levels.length) : 0,
masteredCount: e.mastered,
notMasteredCount: e.notMastered,
totalStudents: studentIds.length,
}))
}

View File

@@ -0,0 +1,97 @@
// Learning Diagnostic Module Types
export type DiagnosticReportType = "individual" | "class" | "grade"
export type DiagnosticReportStatus = "draft" | "published" | "archived"
/** 知识点掌握度记录 */
export interface KnowledgePointMastery {
id: string
studentId: string
knowledgePointId: string
masteryLevel: number // 0-100
totalQuestions: number
correctQuestions: number
lastAssessedAt: string
createdAt: string
updatedAt: string
}
/** 含知识点名称的掌握度join knowledgePoints 后) */
export interface MasteryWithKnowledgePoint extends KnowledgePointMastery {
knowledgePointName: string
knowledgePointDescription: string | null
}
/** 学生掌握度摘要 */
export interface StudentMasterySummary {
studentId: string
studentName: string
averageMastery: number
totalKnowledgePoints: number
strengths: MasteryWithKnowledgePoint[] // 掌握度 >= 80
weaknesses: MasteryWithKnowledgePoint[] // 掌握度 < 60
allMastery: MasteryWithKnowledgePoint[]
}
/** 诊断报告 */
export interface DiagnosticReport {
id: string
studentId: string
generatedBy: string | null
reportType: DiagnosticReportType
period: string | null
summary: string | null
strengths: string[] | null
weaknesses: string[] | null
recommendations: string[] | null
overallScore: number | null
status: DiagnosticReportStatus
createdAt: string
updatedAt: string
}
/** 含学生名的诊断报告join users 后) */
export interface DiagnosticReportWithDetails extends DiagnosticReport {
studentName: string
generatedByName: string | null
}
/** 班级掌握度摘要 */
export interface ClassMasterySummary {
classId: string
className: string
studentCount: number
averageMastery: number
knowledgePointStats: KnowledgePointStat[]
studentsNeedingAttention: Array<{
studentId: string
studentName: string
averageMastery: number
weakCount: number
}>
}
/** 知识点统计 */
export interface KnowledgePointStat {
knowledgePointId: string
knowledgePointName: string
averageMastery: number
masteredCount: number // 掌握度 >= 80
notMasteredCount: number // 掌握度 < 60
totalStudents: number
}
/** 报告查询过滤参数 */
export interface DiagnosticReportQueryParams {
studentId?: string
reportType?: DiagnosticReportType
status?: DiagnosticReportStatus
period?: string
}
/** 雷达图数据点 */
export interface MasteryRadarPoint {
knowledgePoint: string
student: number // 0-100
classAverage?: number // 0-100
}

View File

@@ -0,0 +1,304 @@
"use server"
import { revalidatePath } from "next/cache"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import {
CreateElectiveCourseSchema,
UpdateElectiveCourseSchema,
SelectCourseSchema,
DropCourseSchema,
RunLotterySchema,
} from "./schema"
import {
getElectiveCourses,
getElectiveCourseById,
createElectiveCourse,
updateElectiveCourse,
deleteElectiveCourse,
openSelection,
closeSelection,
} from "./data-access"
import { runLottery, selectCourse, dropCourse } from "./data-access-operations"
import {
getStudentSelections,
getAvailableCoursesForStudent,
} from "./data-access-selections"
import type {
ElectiveCourseWithDetails,
CourseSelectionWithDetails,
GetElectiveCoursesParams,
} from "./types"
const handleError = (e: unknown): ActionState<never> => {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
const revalidateElectivePaths = (id?: string) => {
revalidatePath("/admin/elective")
revalidatePath("/teacher/elective")
revalidatePath("/student/elective")
if (id) {
revalidatePath(`/admin/elective/${id}`)
revalidatePath(`/admin/elective/${id}/edit`)
}
}
const requireCourseId = (formData: FormData): string => {
const id = String(formData.get("courseId") ?? "")
if (!id) throw new Error("Course ID is required")
return id
}
export async function createElectiveCourseAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE)
const parsed = CreateElectiveCourseSchema.safeParse({
name: formData.get("name"),
subjectId: formData.get("subjectId") || undefined,
teacherId: formData.get("teacherId") || ctx.userId,
gradeId: formData.get("gradeId") || undefined,
description: formData.get("description") || undefined,
capacity: formData.get("capacity") || undefined,
classroom: formData.get("classroom") || undefined,
schedule: formData.get("schedule") || undefined,
startDate: formData.get("startDate") || undefined,
endDate: formData.get("endDate") || undefined,
selectionStartAt: formData.get("selectionStartAt") || undefined,
selectionEndAt: formData.get("selectionEndAt") || undefined,
selectionMode: formData.get("selectionMode") || undefined,
credit: formData.get("credit") || undefined,
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
const id = await createElectiveCourse(parsed.data, ctx.userId)
revalidateElectivePaths(id)
return { success: true, message: "Elective course created", data: id }
} catch (e) {
return handleError(e)
}
}
export async function updateElectiveCourseAction(
id: string,
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.ELECTIVE_MANAGE)
const existing = await getElectiveCourseById(id)
if (!existing) return { success: false, message: "Course not found" }
const parsed = UpdateElectiveCourseSchema.safeParse({
name: formData.get("name") || undefined,
subjectId: formData.get("subjectId") || undefined,
teacherId: formData.get("teacherId") || undefined,
gradeId: formData.get("gradeId") || undefined,
description: formData.get("description") || undefined,
capacity: formData.get("capacity") || undefined,
classroom: formData.get("classroom") || undefined,
schedule: formData.get("schedule") || undefined,
startDate: formData.get("startDate") || undefined,
endDate: formData.get("endDate") || undefined,
selectionStartAt: formData.get("selectionStartAt") || undefined,
selectionEndAt: formData.get("selectionEndAt") || undefined,
status: formData.get("status") || undefined,
selectionMode: formData.get("selectionMode") || undefined,
credit: formData.get("credit") || undefined,
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
await updateElectiveCourse(id, parsed.data)
revalidateElectivePaths(id)
return { success: true, message: "Elective course updated", data: id }
} catch (e) {
return handleError(e)
}
}
export async function deleteElectiveCourseAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.ELECTIVE_MANAGE)
const id = requireCourseId(formData)
const existing = await getElectiveCourseById(id)
if (!existing) return { success: false, message: "Course not found" }
await deleteElectiveCourse(id)
revalidateElectivePaths()
return { success: true, message: "Elective course deleted" }
} catch (e) {
return handleError(e)
}
}
export async function openSelectionAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.ELECTIVE_MANAGE)
const courseId = requireCourseId(formData)
await openSelection(courseId)
revalidateElectivePaths(courseId)
return { success: true, message: "Selection opened" }
} catch (e) {
return handleError(e)
}
}
export async function closeSelectionAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.ELECTIVE_MANAGE)
const courseId = requireCourseId(formData)
await closeSelection(courseId)
revalidateElectivePaths(courseId)
return { success: true, message: "Selection closed" }
} catch (e) {
return handleError(e)
}
}
export async function runLotteryAction(
prevState: ActionState<{ enrolled: number; waitlist: number }> | null,
formData: FormData
): Promise<ActionState<{ enrolled: number; waitlist: number }>> {
try {
await requirePermission(Permissions.ELECTIVE_MANAGE)
const parsed = RunLotterySchema.safeParse({
courseId: formData.get("courseId"),
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
const result = await runLottery(parsed.data.courseId)
revalidateElectivePaths(parsed.data.courseId)
return {
success: true,
message: `Lottery completed: ${result.enrolled} enrolled, ${result.waitlist} waitlisted`,
data: result,
}
} catch (e) {
return handleError(e)
}
}
export async function selectCourseAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.ELECTIVE_SELECT)
const parsed = SelectCourseSchema.safeParse({
courseId: formData.get("courseId"),
priority: formData.get("priority") || undefined,
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
const result = await selectCourse(parsed.data.courseId, ctx.userId, parsed.data.priority)
revalidateElectivePaths(parsed.data.courseId)
return { success: true, message: result.message, data: result.status }
} catch (e) {
return handleError(e)
}
}
export async function dropCourseAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.ELECTIVE_SELECT)
const parsed = DropCourseSchema.safeParse({
courseId: formData.get("courseId"),
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
await dropCourse(parsed.data.courseId, ctx.userId)
revalidateElectivePaths(parsed.data.courseId)
return { success: true, message: "Course dropped" }
} catch (e) {
return handleError(e)
}
}
export async function getElectiveCoursesAction(
params?: GetElectiveCoursesParams
): Promise<ActionState<ElectiveCourseWithDetails[]>> {
try {
const ctx = await requirePermission(Permissions.ELECTIVE_READ)
const data = await getElectiveCourses({
...params,
scope: ctx.dataScope,
currentUserId: ctx.userId,
})
return { success: true, data }
} catch (e) {
return handleError(e)
}
}
export async function getStudentSelectionsAction(
studentId: string
): Promise<ActionState<CourseSelectionWithDetails[]>> {
try {
const ctx = await requirePermission(Permissions.ELECTIVE_READ)
if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) {
return { success: false, message: "Can only view your own selections" }
}
if (ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)) {
return { success: false, message: "Can only view your children's selections" }
}
const data = await getStudentSelections(studentId)
return { success: true, data }
} catch (e) {
return handleError(e)
}
}
export async function getAvailableCoursesAction(): Promise<ActionState<ElectiveCourseWithDetails[]>> {
try {
const ctx = await requirePermission(Permissions.ELECTIVE_SELECT)
const data = await getAvailableCoursesForStudent(ctx.userId)
return { success: true, data }
} catch (e) {
return handleError(e)
}
}

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>
)
}

View File

@@ -0,0 +1,217 @@
import "server-only"
import { createId } from "@paralleldrive/cuid2"
import { and, asc, eq, inArray } from "drizzle-orm"
import { db } from "@/shared/db"
import {
courseSelections,
electiveCourses,
} from "@/shared/db/schema"
import type { CourseSelectionStatus } from "./types"
export async function runLottery(courseId: string): Promise<{
enrolled: number
waitlist: number
}> {
const [course] = await db
.select()
.from(electiveCourses)
.where(eq(electiveCourses.id, courseId))
.limit(1)
if (!course) throw new Error("Course not found")
const selections = await db
.select()
.from(courseSelections)
.where(
and(
eq(courseSelections.courseId, courseId),
eq(courseSelections.status, "selected")
)
)
.orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt))
if (selections.length === 0) {
return { enrolled: 0, waitlist: 0 }
}
const shuffled = [...selections].sort(() => Math.random() - 0.5)
const capacity = course.capacity
const now = new Date()
let enrolledCount = 0
let waitlistCount = 0
for (let i = 0; i < shuffled.length; i++) {
const sel = shuffled[i]
const rank = i + 1
if (i < capacity) {
await db
.update(courseSelections)
.set({
status: "enrolled",
lotteryRank: rank,
enrolledAt: now,
updatedAt: now,
})
.where(eq(courseSelections.id, sel.id))
enrolledCount++
} else {
await db
.update(courseSelections)
.set({
status: "waitlist",
lotteryRank: rank,
updatedAt: now,
})
.where(eq(courseSelections.id, sel.id))
waitlistCount++
}
}
await db
.update(electiveCourses)
.set({ enrolledCount, status: "closed", updatedAt: now })
.where(eq(electiveCourses.id, courseId))
return { enrolled: enrolledCount, waitlist: waitlistCount }
}
export async function selectCourse(
courseId: string,
studentId: string,
priority?: number
): Promise<{ status: CourseSelectionStatus; message: string }> {
const [course] = await db
.select()
.from(electiveCourses)
.where(eq(electiveCourses.id, courseId))
.limit(1)
if (!course) throw new Error("Course not found")
if (course.status !== "open") throw new Error("Course selection is not open")
const now = new Date()
if (course.selectionStartAt && now < course.selectionStartAt) {
throw new Error("Selection has not started yet")
}
if (course.selectionEndAt && now > course.selectionEndAt) {
throw new Error("Selection has ended")
}
const [existing] = await db
.select()
.from(courseSelections)
.where(
and(
eq(courseSelections.courseId, courseId),
eq(courseSelections.studentId, studentId),
inArray(courseSelections.status, ["selected", "enrolled", "waitlist"])
)
)
.limit(1)
if (existing) throw new Error("Already selected this course")
const id = createId()
let status: CourseSelectionStatus = "selected"
let enrolledAt: Date | null = null
if (course.selectionMode === "fcfs" && course.enrolledCount < course.capacity) {
status = "enrolled"
enrolledAt = now
await db
.update(electiveCourses)
.set({
enrolledCount: course.enrolledCount + 1,
updatedAt: now,
})
.where(eq(electiveCourses.id, courseId))
} else if (course.selectionMode === "fcfs") {
status = "waitlist"
}
await db.insert(courseSelections).values({
id,
courseId,
studentId,
status,
priority: priority ?? 1,
selectedAt: now,
enrolledAt,
})
return {
status,
message:
status === "enrolled"
? "Enrolled successfully"
: status === "waitlist"
? "Added to waitlist"
: "Selection submitted",
}
}
export async function dropCourse(
courseId: string,
studentId: string
): Promise<void> {
const [existing] = await db
.select()
.from(courseSelections)
.where(
and(
eq(courseSelections.courseId, courseId),
eq(courseSelections.studentId, studentId),
inArray(courseSelections.status, ["selected", "enrolled", "waitlist"])
)
)
.limit(1)
if (!existing) throw new Error("No active selection found")
const now = new Date()
await db
.update(courseSelections)
.set({ status: "dropped", droppedAt: now, updatedAt: now })
.where(eq(courseSelections.id, existing.id))
if (existing.status === "enrolled") {
const [course] = await db
.select()
.from(electiveCourses)
.where(eq(electiveCourses.id, courseId))
.limit(1)
if (course && course.selectionMode === "fcfs") {
const newEnrolledCount = Math.max(0, course.enrolledCount - 1)
await db
.update(electiveCourses)
.set({ enrolledCount: newEnrolledCount, updatedAt: now })
.where(eq(electiveCourses.id, courseId))
const [nextWait] = await db
.select()
.from(courseSelections)
.where(
and(
eq(courseSelections.courseId, courseId),
eq(courseSelections.status, "waitlist")
)
)
.orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt))
.limit(1)
if (nextWait) {
await db
.update(courseSelections)
.set({
status: "enrolled",
enrolledAt: now,
updatedAt: now,
})
.where(eq(courseSelections.id, nextWait.id))
await db
.update(electiveCourses)
.set({ enrolledCount: newEnrolledCount + 1, updatedAt: now })
.where(eq(electiveCourses.id, courseId))
}
}
}
}

View File

@@ -0,0 +1,189 @@
import "server-only"
import { and, asc, desc, eq, sql, type SQL } from "drizzle-orm"
import { db } from "@/shared/db"
import {
classes,
classEnrollments,
courseSelections,
electiveCourses,
grades,
subjects,
users,
} from "@/shared/db/schema"
import type {
CourseSelectionStatus,
CourseSelectionWithDetails,
ElectiveCourseStatus,
ElectiveCourseWithDetails,
} from "./types"
const toIso = (d: Date | null | undefined): string | null =>
d ? d.toISOString() : null
const toIsoRequired = (d: Date): string => d.toISOString()
const mapCourseRow = (
r: typeof electiveCourses.$inferSelect & {
teacherName: string | null
subjectName: string | null
gradeName: string | null
}
): ElectiveCourseWithDetails => ({
id: r.id,
name: r.name,
subjectId: r.subjectId,
teacherId: r.teacherId,
gradeId: r.gradeId,
description: r.description,
capacity: r.capacity,
enrolledCount: r.enrolledCount,
classroom: r.classroom,
schedule: r.schedule,
startDate: r.startDate ? new Date(r.startDate).toISOString().slice(0, 10) : null,
endDate: r.endDate ? new Date(r.endDate).toISOString().slice(0, 10) : null,
selectionStartAt: toIso(r.selectionStartAt),
selectionEndAt: toIso(r.selectionEndAt),
status: r.status,
selectionMode: r.selectionMode,
credit: String(r.credit),
createdAt: toIsoRequired(r.createdAt),
updatedAt: toIsoRequired(r.updatedAt),
teacherName: r.teacherName,
subjectName: r.subjectName,
gradeName: r.gradeName,
})
const buildCourseSelect = () =>
db
.select({
id: electiveCourses.id,
name: electiveCourses.name,
subjectId: electiveCourses.subjectId,
teacherId: electiveCourses.teacherId,
gradeId: electiveCourses.gradeId,
description: electiveCourses.description,
capacity: electiveCourses.capacity,
enrolledCount: electiveCourses.enrolledCount,
classroom: electiveCourses.classroom,
schedule: electiveCourses.schedule,
startDate: electiveCourses.startDate,
endDate: electiveCourses.endDate,
selectionStartAt: electiveCourses.selectionStartAt,
selectionEndAt: electiveCourses.selectionEndAt,
status: electiveCourses.status,
selectionMode: electiveCourses.selectionMode,
credit: electiveCourses.credit,
createdAt: electiveCourses.createdAt,
updatedAt: electiveCourses.updatedAt,
teacherName: users.name,
subjectName: subjects.name,
gradeName: grades.name,
})
.from(electiveCourses)
.leftJoin(users, eq(users.id, electiveCourses.teacherId))
.leftJoin(subjects, eq(subjects.id, electiveCourses.subjectId))
.leftJoin(grades, eq(grades.id, electiveCourses.gradeId))
const mapSelectionRow = (
r: typeof courseSelections.$inferSelect & {
courseName: string | null
studentName: string | null
courseCapacity: number | null
courseEnrolledCount: number | null
courseStatus: (typeof electiveCourses.status.enumValues)[number] | null
}
): CourseSelectionWithDetails => ({
id: r.id,
courseId: r.courseId,
studentId: r.studentId,
status: r.status as CourseSelectionStatus,
priority: r.priority,
selectedAt: toIsoRequired(r.selectedAt),
enrolledAt: toIso(r.enrolledAt),
droppedAt: toIso(r.droppedAt),
lotteryRank: r.lotteryRank,
createdAt: toIsoRequired(r.createdAt),
updatedAt: toIsoRequired(r.updatedAt),
courseName: r.courseName,
studentName: r.studentName,
courseCapacity: r.courseCapacity,
courseEnrolledCount: r.courseEnrolledCount,
courseStatus: r.courseStatus as ElectiveCourseStatus | null,
})
const selectionDetailSelect = () =>
db
.select({
id: courseSelections.id,
courseId: courseSelections.courseId,
studentId: courseSelections.studentId,
status: courseSelections.status,
priority: courseSelections.priority,
selectedAt: courseSelections.selectedAt,
enrolledAt: courseSelections.enrolledAt,
droppedAt: courseSelections.droppedAt,
lotteryRank: courseSelections.lotteryRank,
createdAt: courseSelections.createdAt,
updatedAt: courseSelections.updatedAt,
courseName: electiveCourses.name,
studentName: users.name,
courseCapacity: electiveCourses.capacity,
courseEnrolledCount: electiveCourses.enrolledCount,
courseStatus: electiveCourses.status,
})
.from(courseSelections)
.leftJoin(electiveCourses, eq(electiveCourses.id, courseSelections.courseId))
.leftJoin(users, eq(users.id, courseSelections.studentId))
export async function getCourseSelections(
courseId: string
): Promise<CourseSelectionWithDetails[]> {
const rows = await selectionDetailSelect()
.where(eq(courseSelections.courseId, courseId))
.orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt))
return rows.map(mapSelectionRow)
}
export async function getStudentSelections(
studentId: string
): Promise<CourseSelectionWithDetails[]> {
const rows = await selectionDetailSelect()
.where(eq(courseSelections.studentId, studentId))
.orderBy(desc(courseSelections.selectedAt))
return rows.map(mapSelectionRow)
}
export async function getStudentGradeId(studentId: string): Promise<string | null> {
const [row] = await db
.select({ gradeId: classes.gradeId })
.from(classEnrollments)
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
.where(
and(
eq(classEnrollments.studentId, studentId),
eq(classEnrollments.status, "active")
)
)
.limit(1)
return row?.gradeId ?? null
}
export async function getAvailableCoursesForStudent(
studentId: string,
gradeId?: string | null
): Promise<ElectiveCourseWithDetails[]> {
const resolvedGradeId = gradeId ?? (await getStudentGradeId(studentId))
const conditions: SQL[] = [eq(electiveCourses.status, "open")]
if (resolvedGradeId) {
conditions.push(
sql`(${electiveCourses.gradeId} = ${resolvedGradeId} OR ${electiveCourses.gradeId} IS NULL)`
)
}
const rows = await buildCourseSelect()
.where(and(...conditions))
.orderBy(desc(electiveCourses.createdAt))
return rows.map(mapCourseRow)
}

View File

@@ -0,0 +1,242 @@
import "server-only"
import { cache } from "react"
import { createId } from "@paralleldrive/cuid2"
import { and, asc, desc, eq, inArray, sql, type SQL } from "drizzle-orm"
import { db } from "@/shared/db"
import {
electiveCourses,
grades,
subjects,
users,
} from "@/shared/db/schema"
import type { DataScope } from "@/shared/types/permissions"
import type {
ElectiveCourseStatus,
ElectiveCourseWithDetails,
GetElectiveCoursesParams,
} from "./types"
import type {
CreateElectiveCourseInput,
UpdateElectiveCourseInput,
} from "./schema"
const toIso = (d: Date | null | undefined): string | null =>
d ? d.toISOString() : null
const toIsoRequired = (d: Date): string => d.toISOString()
const buildScopeFilter = (scope: DataScope, userId?: string): SQL | null => {
if (scope.type === "all") return null
if (scope.type === "owned" && userId) return eq(electiveCourses.teacherId, userId)
if (scope.type === "class_taught" && userId) {
return eq(electiveCourses.teacherId, userId)
}
if (scope.type === "grade_managed") {
return scope.gradeIds.length > 0
? inArray(electiveCourses.gradeId, scope.gradeIds)
: sql`1=0`
}
if (scope.type === "class_members") return null
if (scope.type === "children") return null
return sql`1=0`
}
const mapCourseRow = (
r: typeof electiveCourses.$inferSelect & {
teacherName: string | null
subjectName: string | null
gradeName: string | null
}
): ElectiveCourseWithDetails => ({
id: r.id,
name: r.name,
subjectId: r.subjectId,
teacherId: r.teacherId,
gradeId: r.gradeId,
description: r.description,
capacity: r.capacity,
enrolledCount: r.enrolledCount,
classroom: r.classroom,
schedule: r.schedule,
startDate: r.startDate ? new Date(r.startDate).toISOString().slice(0, 10) : null,
endDate: r.endDate ? new Date(r.endDate).toISOString().slice(0, 10) : null,
selectionStartAt: toIso(r.selectionStartAt),
selectionEndAt: toIso(r.selectionEndAt),
status: r.status,
selectionMode: r.selectionMode,
credit: String(r.credit),
createdAt: toIsoRequired(r.createdAt),
updatedAt: toIsoRequired(r.updatedAt),
teacherName: r.teacherName,
subjectName: r.subjectName,
gradeName: r.gradeName,
})
const buildCourseSelect = () =>
db
.select({
id: electiveCourses.id,
name: electiveCourses.name,
subjectId: electiveCourses.subjectId,
teacherId: electiveCourses.teacherId,
gradeId: electiveCourses.gradeId,
description: electiveCourses.description,
capacity: electiveCourses.capacity,
enrolledCount: electiveCourses.enrolledCount,
classroom: electiveCourses.classroom,
schedule: electiveCourses.schedule,
startDate: electiveCourses.startDate,
endDate: electiveCourses.endDate,
selectionStartAt: electiveCourses.selectionStartAt,
selectionEndAt: electiveCourses.selectionEndAt,
status: electiveCourses.status,
selectionMode: electiveCourses.selectionMode,
credit: electiveCourses.credit,
createdAt: electiveCourses.createdAt,
updatedAt: electiveCourses.updatedAt,
teacherName: users.name,
subjectName: subjects.name,
gradeName: grades.name,
})
.from(electiveCourses)
.leftJoin(users, eq(users.id, electiveCourses.teacherId))
.leftJoin(subjects, eq(subjects.id, electiveCourses.subjectId))
.leftJoin(grades, eq(grades.id, electiveCourses.gradeId))
export const getElectiveCourses = cache(
async (
params?: GetElectiveCoursesParams & { scope?: DataScope; currentUserId?: string }
): Promise<ElectiveCourseWithDetails[]> => {
try {
const conditions: SQL[] = []
if (params?.status)
conditions.push(
eq(electiveCourses.status, params.status as ElectiveCourseStatus)
)
if (params?.gradeId) conditions.push(eq(electiveCourses.gradeId, params.gradeId))
if (params?.subjectId)
conditions.push(eq(electiveCourses.subjectId, params.subjectId))
if (params?.teacherId)
conditions.push(eq(electiveCourses.teacherId, params.teacherId))
if (params?.scope) {
const scopeFilter = buildScopeFilter(params.scope, params.currentUserId)
if (scopeFilter) conditions.push(scopeFilter)
}
const query = buildCourseSelect()
const rows = await (conditions.length > 0
? query.where(and(...conditions))
: query
).orderBy(desc(electiveCourses.createdAt))
return rows.map(mapCourseRow)
} catch {
return []
}
}
)
export const getElectiveCourseById = cache(
async (id: string): Promise<ElectiveCourseWithDetails | null> => {
try {
const [row] = await buildCourseSelect()
.where(eq(electiveCourses.id, id))
.limit(1)
if (!row) return null
return mapCourseRow(row)
} catch {
return null
}
}
)
export async function createElectiveCourse(
data: CreateElectiveCourseInput,
teacherId: string
): Promise<string> {
const id = createId()
await db.insert(electiveCourses).values({
id,
name: data.name,
subjectId: data.subjectId,
teacherId: data.teacherId ?? teacherId,
gradeId: data.gradeId,
description: data.description,
capacity: data.capacity,
enrolledCount: 0,
classroom: data.classroom,
schedule: data.schedule,
startDate: data.startDate ? new Date(data.startDate) : null,
endDate: data.endDate ? new Date(data.endDate) : null,
selectionStartAt: data.selectionStartAt ? new Date(data.selectionStartAt) : null,
selectionEndAt: data.selectionEndAt ? new Date(data.selectionEndAt) : null,
status: "draft",
selectionMode: data.selectionMode,
credit: data.credit,
})
return id
}
export async function updateElectiveCourse(
id: string,
data: Partial<UpdateElectiveCourseInput>
): Promise<void> {
const update: Partial<typeof electiveCourses.$inferSelect> = {}
if (data.name !== undefined) update.name = data.name
if (data.subjectId !== undefined) update.subjectId = data.subjectId
if (data.teacherId !== undefined) update.teacherId = data.teacherId
if (data.gradeId !== undefined) update.gradeId = data.gradeId
if (data.description !== undefined) update.description = data.description
if (data.capacity !== undefined) update.capacity = data.capacity
if (data.classroom !== undefined) update.classroom = data.classroom
if (data.schedule !== undefined) update.schedule = data.schedule
if (data.startDate !== undefined)
update.startDate = data.startDate ? new Date(data.startDate) : null
if (data.endDate !== undefined)
update.endDate = data.endDate ? new Date(data.endDate) : null
if (data.selectionStartAt !== undefined)
update.selectionStartAt = data.selectionStartAt ? new Date(data.selectionStartAt) : null
if (data.selectionEndAt !== undefined)
update.selectionEndAt = data.selectionEndAt ? new Date(data.selectionEndAt) : null
if (data.status !== undefined) update.status = data.status
if (data.selectionMode !== undefined) update.selectionMode = data.selectionMode
if (data.credit !== undefined) update.credit = data.credit
if (Object.keys(update).length === 0) return
await db.update(electiveCourses).set(update).where(eq(electiveCourses.id, id))
}
export async function deleteElectiveCourse(id: string): Promise<void> {
await db.delete(electiveCourses).where(eq(electiveCourses.id, id))
}
export async function openSelection(courseId: string): Promise<void> {
await db
.update(electiveCourses)
.set({ status: "open", updatedAt: new Date() })
.where(eq(electiveCourses.id, courseId))
}
export async function closeSelection(courseId: string): Promise<void> {
await db
.update(electiveCourses)
.set({ status: "closed", updatedAt: new Date() })
.where(eq(electiveCourses.id, courseId))
}
export async function getSubjectOptions(): Promise<{ id: string; name: string }[]> {
try {
const rows = await db
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.orderBy(asc(subjects.order), asc(subjects.name))
return rows.map((r) => ({ id: r.id, name: r.name }))
} catch {
return []
}
}
export type { ElectiveCourseWithDetails }

View File

@@ -0,0 +1,133 @@
import { z } from "zod"
export const ElectiveCourseStatusEnum = z.enum([
"draft",
"open",
"closed",
"cancelled",
])
export const ElectiveSelectionModeEnum = z.enum(["fcfs", "lottery"])
export const CourseSelectionStatusEnum = z.enum([
"selected",
"enrolled",
"waitlist",
"dropped",
"rejected",
])
const emptyToNull = (v: string | undefined | null) =>
v && v.length > 0 ? v : null
const optionalStringToNull = (v: string | undefined | null) =>
v === undefined ? undefined : emptyToNull(v)
export const CreateElectiveCourseSchema = z
.object({
name: z.string().trim().min(1).max(255),
subjectId: z.string().trim().optional().nullable(),
teacherId: z.string().trim().min(1),
gradeId: z.string().trim().optional().nullable(),
description: z.string().trim().optional().nullable(),
capacity: z.coerce.number().int().min(1).max(500).optional(),
classroom: z.string().trim().optional().nullable(),
schedule: z.string().trim().optional().nullable(),
startDate: z.string().trim().optional().nullable(),
endDate: z.string().trim().optional().nullable(),
selectionStartAt: z.string().trim().optional().nullable(),
selectionEndAt: z.string().trim().optional().nullable(),
selectionMode: ElectiveSelectionModeEnum.optional(),
credit: z.string().trim().optional().nullable(),
})
.transform((v) => ({
name: v.name,
subjectId: optionalStringToNull(v.subjectId) ?? null,
teacherId: v.teacherId,
gradeId: optionalStringToNull(v.gradeId) ?? null,
description: optionalStringToNull(v.description),
capacity: v.capacity ?? 30,
classroom: optionalStringToNull(v.classroom),
schedule: optionalStringToNull(v.schedule),
startDate: optionalStringToNull(v.startDate),
endDate: optionalStringToNull(v.endDate),
selectionStartAt: optionalStringToNull(v.selectionStartAt),
selectionEndAt: optionalStringToNull(v.selectionEndAt),
selectionMode: v.selectionMode ?? "fcfs",
credit: v.credit && v.credit.length > 0 ? v.credit : "1.0",
}))
export type CreateElectiveCourseInput = z.infer<typeof CreateElectiveCourseSchema>
export const UpdateElectiveCourseSchema = z
.object({
name: z.string().trim().min(1).max(255).optional(),
subjectId: z.string().trim().optional().nullable(),
teacherId: z.string().trim().min(1).optional(),
gradeId: z.string().trim().optional().nullable(),
description: z.string().trim().optional().nullable(),
capacity: z.coerce.number().int().min(1).max(500).optional(),
classroom: z.string().trim().optional().nullable(),
schedule: z.string().trim().optional().nullable(),
startDate: z.string().trim().optional().nullable(),
endDate: z.string().trim().optional().nullable(),
selectionStartAt: z.string().trim().optional().nullable(),
selectionEndAt: z.string().trim().optional().nullable(),
status: ElectiveCourseStatusEnum.optional(),
selectionMode: ElectiveSelectionModeEnum.optional(),
credit: z.string().trim().optional().nullable(),
})
.transform((v) => ({
...v,
subjectId:
v.subjectId !== undefined ? optionalStringToNull(v.subjectId) : undefined,
gradeId:
v.gradeId !== undefined ? optionalStringToNull(v.gradeId) : undefined,
description:
v.description !== undefined
? optionalStringToNull(v.description)
: undefined,
classroom:
v.classroom !== undefined ? optionalStringToNull(v.classroom) : undefined,
schedule:
v.schedule !== undefined ? optionalStringToNull(v.schedule) : undefined,
startDate:
v.startDate !== undefined ? optionalStringToNull(v.startDate) : undefined,
endDate:
v.endDate !== undefined ? optionalStringToNull(v.endDate) : undefined,
selectionStartAt:
v.selectionStartAt !== undefined
? optionalStringToNull(v.selectionStartAt)
: undefined,
selectionEndAt:
v.selectionEndAt !== undefined
? optionalStringToNull(v.selectionEndAt)
: undefined,
credit:
v.credit !== undefined
? v.credit && v.credit.length > 0
? v.credit
: "1.0"
: undefined,
}))
export type UpdateElectiveCourseInput = z.infer<typeof UpdateElectiveCourseSchema>
export const SelectCourseSchema = z.object({
courseId: z.string().trim().min(1),
priority: z.coerce.number().int().min(1).max(10).optional(),
})
export type SelectCourseInput = z.infer<typeof SelectCourseSchema>
export const DropCourseSchema = z.object({
courseId: z.string().trim().min(1),
})
export type DropCourseInput = z.infer<typeof DropCourseSchema>
export const RunLotterySchema = z.object({
courseId: z.string().trim().min(1),
})
export type RunLotteryInput = z.infer<typeof RunLotterySchema>

View File

@@ -0,0 +1,108 @@
export type ElectiveCourseStatus = "draft" | "open" | "closed" | "cancelled"
export type ElectiveSelectionMode = "fcfs" | "lottery"
export type CourseSelectionStatus =
| "selected"
| "enrolled"
| "waitlist"
| "dropped"
| "rejected"
export interface ElectiveCourse {
id: string
name: string
subjectId: string | null
teacherId: string
gradeId: string | null
description: string | null
capacity: number
enrolledCount: number
classroom: string | null
schedule: string | null
startDate: string | null
endDate: string | null
selectionStartAt: string | null
selectionEndAt: string | null
status: ElectiveCourseStatus
selectionMode: ElectiveSelectionMode
credit: string
createdAt: string
updatedAt: string
}
export interface ElectiveCourseWithDetails extends ElectiveCourse {
teacherName: string | null
subjectName: string | null
gradeName: string | null
}
export interface CourseSelection {
id: string
courseId: string
studentId: string
status: CourseSelectionStatus
priority: number | null
selectedAt: string
enrolledAt: string | null
droppedAt: string | null
lotteryRank: number | null
createdAt: string
updatedAt: string
}
export interface CourseSelectionWithDetails extends CourseSelection {
courseName: string | null
studentName: string | null
courseCapacity: number | null
courseEnrolledCount: number | null
courseStatus: ElectiveCourseStatus | null
}
export interface GetElectiveCoursesParams {
status?: ElectiveCourseStatus
gradeId?: string
subjectId?: string
teacherId?: string
}
export const ELECTIVE_STATUS_LABELS: Record<ElectiveCourseStatus, string> = {
draft: "Draft",
open: "Open",
closed: "Closed",
cancelled: "Cancelled",
}
export const ELECTIVE_STATUS_COLORS: Record<
ElectiveCourseStatus,
"default" | "secondary" | "destructive" | "outline"
> = {
draft: "secondary",
open: "default",
closed: "outline",
cancelled: "destructive",
}
export const SELECTION_MODE_LABELS: Record<ElectiveSelectionMode, string> = {
fcfs: "First Come First Served",
lottery: "Lottery",
}
export const COURSE_SELECTION_STATUS_LABELS: Record<CourseSelectionStatus, string> = {
selected: "Selected",
enrolled: "Enrolled",
waitlist: "Waitlist",
dropped: "Dropped",
rejected: "Rejected",
}
export const COURSE_SELECTION_STATUS_COLORS: Record<
CourseSelectionStatus,
"default" | "secondary" | "destructive" | "outline"
> = {
selected: "secondary",
enrolled: "default",
waitlist: "outline",
dropped: "destructive",
rejected: "destructive",
}

View File

@@ -16,7 +16,9 @@ import {
GraduationCap,
Mail,
CalendarCheck,
CalendarClock
CalendarClock,
Stethoscope,
BookMarked
} from "lucide-react"
import type { LucideIcon } from "lucide-react"
import { Permissions } from "@/shared/types/permissions"
@@ -83,6 +85,12 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
href: "/admin/announcements",
permission: Permissions.ANNOUNCEMENT_MANAGE,
},
{
title: "Electives",
icon: BookMarked,
href: "/admin/elective",
permission: Permissions.ELECTIVE_MANAGE,
},
{
title: "Messages",
icon: Mail,
@@ -180,6 +188,18 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
href: "/teacher/schedule-changes",
permission: Permissions.SCHEDULE_ADJUST,
},
{
title: "Diagnostic",
icon: Stethoscope,
href: "/teacher/diagnostic",
permission: Permissions.DIAGNOSTIC_READ,
},
{
title: "Electives",
icon: BookMarked,
href: "/teacher/elective",
permission: Permissions.ELECTIVE_MANAGE,
},
{
title: "Management",
icon: Briefcase,
@@ -238,6 +258,18 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
href: "/student/attendance",
permission: Permissions.ATTENDANCE_READ,
},
{
title: "Diagnostic",
icon: Stethoscope,
href: "/student/diagnostic",
permission: Permissions.DIAGNOSTIC_READ,
},
{
title: "Electives",
icon: BookMarked,
href: "/student/elective",
permission: Permissions.ELECTIVE_SELECT,
},
{
title: "Announcements",
icon: Megaphone,

View File

@@ -0,0 +1,144 @@
"use server"
import { ActionState } from "@/shared/types/action-state"
import {
requirePermission,
requireAuth,
PermissionDeniedError,
} from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { z } from "zod"
import { db } from "@/shared/db"
import { examSubmissions } from "@/shared/db/schema"
import { and, eq } from "drizzle-orm"
import {
recordProctoringEvent,
getExamProctoringSummary,
getStudentProctoringStatuses,
getRecentProctoringEvents,
getExamForProctoring,
} from "./data-access"
import type {
ProctoringDashboardData,
ProctoringEventType,
} from "./types"
const ProctoringEventSchema = z.object({
submissionId: z.string().min(1),
examId: z.string().min(1),
eventType: z.enum([
"tab_switch",
"window_blur",
"copy_attempt",
"paste_attempt",
"right_click",
"devtools_open",
"fullscreen_exit",
"idle_timeout",
]) as z.ZodType<ProctoringEventType>,
eventDetail: z.string().optional(),
})
const failState = <T>(message: string): ActionState<T> => ({
success: false,
message,
})
const successState = <T>(data: T, message?: string): ActionState<T> => ({
success: true,
message,
data,
})
/**
* 学生端上报监考事件
* 使用 requireAuth() 因为是学生上报自己的事件,不需要管理权限
*/
export async function recordProctoringEventAction(
prevState: ActionState<{ id: string }> | null,
formData: FormData,
): Promise<ActionState<{ id: string }>> {
try {
const ctx = await requireAuth()
const parsed = ProctoringEventSchema.safeParse({
submissionId: formData.get("submissionId"),
examId: formData.get("examId"),
eventType: formData.get("eventType"),
eventDetail: formData.get("eventDetail") ?? undefined,
})
if (!parsed.success) {
return failState<{ id: string }>(
parsed.error.issues[0]?.message ?? "Invalid event payload",
)
}
// 安全校验submission 必须属于当前学生
const submission = await db.query.examSubmissions.findFirst({
where: and(
eq(examSubmissions.id, parsed.data.submissionId),
eq(examSubmissions.studentId, ctx.userId),
),
})
if (!submission) {
return failState<{ id: string }>("Submission not found for current user")
}
const event = await recordProctoringEvent({
submissionId: parsed.data.submissionId,
studentId: ctx.userId,
examId: parsed.data.examId,
eventType: parsed.data.eventType,
eventDetail: parsed.data.eventDetail,
})
return successState({ id: event.id }, "Event recorded")
} catch (error) {
if (error instanceof PermissionDeniedError) {
return failState<{ id: string }>(error.message)
}
console.error("recordProctoringEventAction error:", error)
return failState<{ id: string }>("Failed to record proctoring event")
}
}
/**
* 获取监考面板数据(教师/管理员使用)
* 需要 EXAM_PROCTOR 权限
*/
export async function getProctoringDashboardAction(
examId: string,
): Promise<ActionState<ProctoringDashboardData>> {
try {
await requirePermission(Permissions.EXAM_PROCTOR)
if (!examId) {
return failState<ProctoringDashboardData>("Exam ID is required")
}
const exam = await getExamForProctoring(examId)
if (!exam) {
return failState<ProctoringDashboardData>("Exam not found")
}
const [summary, students, recentEvents] = await Promise.all([
getExamProctoringSummary(examId),
getStudentProctoringStatuses(examId),
getRecentProctoringEvents(examId, 20),
])
return successState<ProctoringDashboardData>({
summary,
students,
recentEvents,
})
} catch (error) {
if (error instanceof PermissionDeniedError) {
return failState<ProctoringDashboardData>(error.message)
}
console.error("getProctoringDashboardAction error:", error)
return failState<ProctoringDashboardData>("Failed to load proctoring dashboard")
}
}

View File

@@ -0,0 +1,225 @@
"use client"
import { useEffect, useRef, useState, useCallback } from "react"
import { AlertTriangle, X } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { recordProctoringEventAction } from "../actions"
import type { ProctoringEventType } from "../types"
import { PROCTORING_EVENT_LABELS } from "../types"
const IDLE_TIMEOUT_MS = 5 * 60 * 1000 // 5 分钟
const REPORT_THROTTLE_MS = 1500 // 同类事件最小上报间隔
type AntiCheatMonitorProps = {
examId: string
submissionId: string
/** 是否启用防作弊监控(通常 proctored 模式才启用) */
enabled: boolean
/** 是否强制全屏proctored 模式下为 true */
forceFullscreen?: boolean
}
type WarningState = {
visible: boolean
message: string
eventType?: ProctoringEventType
}
/**
* 学生端防作弊监控组件
* 监听各类异常行为并上报到服务端
*/
export function AntiCheatMonitor({
examId,
submissionId,
enabled,
forceFullscreen = false,
}: AntiCheatMonitorProps) {
const [warning, setWarning] = useState<WarningState>({ visible: false, message: "" })
const [isFullscreen, setIsFullscreen] = useState(false)
const lastReportRef = useRef<Map<ProctoringEventType, number>>(new Map())
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const reportEvent = useCallback(
async (eventType: ProctoringEventType, detail?: string) => {
// 节流:同类事件在短时间内只上报一次
const now = Date.now()
const last = lastReportRef.current.get(eventType) ?? 0
if (now - last < REPORT_THROTTLE_MS) return
lastReportRef.current.set(eventType, now)
try {
const formData = new FormData()
formData.set("submissionId", submissionId)
formData.set("examId", examId)
formData.set("eventType", eventType)
if (detail) formData.set("eventDetail", detail)
await recordProctoringEventAction(null, formData)
} catch (error) {
console.error("Failed to report proctoring event:", error)
}
},
[examId, submissionId],
)
const showWarning = useCallback(
(eventType: ProctoringEventType, message?: string) => {
const text = message ?? PROCTORING_EVENT_LABELS[eventType]
setWarning({ visible: true, message: text, eventType })
reportEvent(eventType, message)
},
[reportEvent],
)
const resetIdleTimer = useCallback(() => {
if (idleTimerRef.current) clearTimeout(idleTimerRef.current)
idleTimerRef.current = setTimeout(() => {
showWarning("idle_timeout", "空闲超时5 分钟无操作)")
}, IDLE_TIMEOUT_MS)
}, [showWarning])
// 进入/退出全屏
const enterFullscreen = useCallback(() => {
const elem = document.documentElement
if (elem.requestFullscreen) {
elem.requestFullscreen().catch(() => {
// 全屏请求失败(如用户未交互),忽略
})
}
}, [])
const exitFullscreen = useCallback(() => {
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {})
}
}, [])
// 主副作用:注册事件监听
useEffect(() => {
if (!enabled) return
const handleVisibilityChange = () => {
if (document.hidden) {
showWarning("tab_switch", "切换到其他标签页或最小化窗口")
}
}
const handleBlur = () => {
showWarning("window_blur", "窗口失去焦点")
}
const handleCopy = (e: ClipboardEvent) => {
e.preventDefault()
showWarning("copy_attempt", "尝试复制内容")
}
const handlePaste = (e: ClipboardEvent) => {
e.preventDefault()
showWarning("paste_attempt", "尝试粘贴内容")
}
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault()
showWarning("right_click", "尝试右键点击")
}
const handleKeyDown = (e: KeyboardEvent) => {
// F12 / Ctrl+Shift+I / Ctrl+Shift+J / Ctrl+U
const isDevtools =
e.key === "F12" ||
(e.ctrlKey && e.shiftKey && (e.key === "I" || e.key === "i" || e.key === "J" || e.key === "j")) ||
(e.ctrlKey && (e.key === "U" || e.key === "u"))
if (isDevtools) {
e.preventDefault()
showWarning("devtools_open", "尝试打开开发者工具")
}
}
const handleFullscreenChange = () => {
const fs = !!document.fullscreenElement
setIsFullscreen(fs)
if (!fs && forceFullscreen) {
showWarning("fullscreen_exit", "退出了全屏模式")
}
}
const handleUserActivity = () => {
resetIdleTimer()
}
document.addEventListener("visibilitychange", handleVisibilityChange)
window.addEventListener("blur", handleBlur)
document.addEventListener("copy", handleCopy)
document.addEventListener("paste", handlePaste)
document.addEventListener("contextmenu", handleContextMenu)
document.addEventListener("keydown", handleKeyDown)
document.addEventListener("fullscreenchange", handleFullscreenChange)
document.addEventListener("mousemove", handleUserActivity)
document.addEventListener("keydown", handleUserActivity)
document.addEventListener("click", handleUserActivity)
resetIdleTimer()
if (forceFullscreen) {
enterFullscreen()
}
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange)
window.removeEventListener("blur", handleBlur)
document.removeEventListener("copy", handleCopy)
document.removeEventListener("paste", handlePaste)
document.removeEventListener("contextmenu", handleContextMenu)
document.removeEventListener("keydown", handleKeyDown)
document.removeEventListener("fullscreenchange", handleFullscreenChange)
document.removeEventListener("mousemove", handleUserActivity)
document.removeEventListener("keydown", handleUserActivity)
document.removeEventListener("click", handleUserActivity)
if (idleTimerRef.current) clearTimeout(idleTimerRef.current)
if (forceFullscreen) exitFullscreen()
}
}, [enabled, forceFullscreen, showWarning, resetIdleTimer, enterFullscreen, exitFullscreen])
if (!enabled) return null
return (
<>
{forceFullscreen && !isFullscreen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/95 p-6">
<div className="max-w-md space-y-4 text-center">
<AlertTriangle className="mx-auto h-12 w-12 text-destructive" />
<h2 className="text-xl font-semibold"></h2>
<p className="text-sm text-muted-foreground">
</p>
<Button onClick={enterFullscreen}></Button>
</div>
</div>
)}
{warning.visible && (
<div className="fixed bottom-4 right-4 z-50 w-80 rounded-lg border border-destructive/50 bg-destructive/10 p-4 shadow-lg">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-5 w-5 flex-shrink-0 text-destructive" />
<div className="flex-1">
<div className="font-medium text-destructive"></div>
<div className="text-sm text-foreground">{warning.message}</div>
<div className="mt-1 text-xs text-muted-foreground">
</div>
</div>
<button
type="button"
onClick={() => setWarning((s) => ({ ...s, visible: false }))}
className="text-muted-foreground hover:text-foreground"
aria-label="关闭警告"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
)}
</>
)
}

View File

@@ -0,0 +1,230 @@
"use client"
import { type Control, type FieldPath, useWatch } from "react-hook-form"
import {
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
FormDescription,
} from "@/shared/components/ui/form"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Switch } from "@/shared/components/ui/switch"
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import type { ExamMode } from "../types"
import { EXAM_MODE_LABELS } from "../types"
const EXAM_MODES: ExamMode[] = ["homework", "timed", "proctored"]
/**
* 考试模式配置表单值约束
* 集成到考试创建/编辑表单时,表单值需满足此接口
*/
export interface ExamModeConfigFieldValues {
examMode: ExamMode
durationMinutes: number | null
shuffleQuestions: boolean
allowLateStart: boolean
lateStartGraceMinutes: number
antiCheatEnabled: boolean
[key: string]: unknown
}
type ExamModeConfigProps<T extends ExamModeConfigFieldValues> = {
control: Control<T>
}
export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
control,
}: ExamModeConfigProps<T>) {
const examMode = useWatch({ control, name: "examMode" as FieldPath<T> }) as ExamMode
const showDuration = examMode === "timed" || examMode === "proctored"
const showProctorOptions = examMode === "proctored"
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<FormField
control={control}
name={"examMode" as FieldPath<T>}
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<RadioGroup
value={field.value as ExamMode}
onValueChange={field.onChange}
className="grid gap-3 md:grid-cols-3"
>
{EXAM_MODES.map((mode) => (
<Label
key={mode}
htmlFor={`exam-mode-${mode}`}
className="flex cursor-pointer items-start gap-3 rounded-md border p-3 has-[:checked]:border-primary has-[:checked]:bg-accent"
>
<RadioGroupItem
id={`exam-mode-${mode}`}
value={mode}
className="mt-0.5"
/>
<div className="space-y-0.5">
<div className="text-sm font-medium">
{EXAM_MODE_LABELS[mode]}
</div>
<div className="text-xs text-muted-foreground">
{mode === "homework" && "学生可在任意时间作答,无时间限制"}
{mode === "timed" && "限时作答,到时自动提交"}
{mode === "proctored" && "限时作答 + 防作弊监控 + 强制全屏"}
</div>
</div>
</Label>
))}
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{showDuration && (
<FormField
control={control}
name={"durationMinutes" as FieldPath<T>}
render={({ field }) => (
<FormItem className="space-y-2">
<div className="space-y-0.5">
<FormLabel></FormLabel>
<FormDescription>
{examMode === "proctored"
? "监考模式下必须设置考试时长"
: "学生开始作答后,到时自动提交"}
</FormDescription>
</div>
<FormControl>
<Input
type="number"
min={1}
value={(field.value as number | null) ?? ""}
onChange={(e) =>
field.onChange(
e.target.value === "" ? null : Number(e.target.value),
)
}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{showProctorOptions && (
<>
<FormField
control={control}
name={"shuffleQuestions" as FieldPath<T>}
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-0.5">
<FormLabel></FormLabel>
<FormDescription></FormDescription>
</div>
<FormControl>
<Switch
checked={field.value as boolean}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name={"antiCheatEnabled" as FieldPath<T>}
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-0.5">
<FormLabel></FormLabel>
<FormDescription>
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value as boolean}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name={"allowLateStart" as FieldPath<T>}
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-0.5">
<FormLabel></FormLabel>
<FormDescription></FormDescription>
</div>
<FormControl>
<Switch
checked={field.value as boolean}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name={"lateStartGraceMinutes" as FieldPath<T>}
render={({ field }) => (
<FormItem className="space-y-2">
<div className="space-y-0.5">
<FormLabel></FormLabel>
<FormDescription></FormDescription>
</div>
<FormControl>
<Input
type="number"
min={0}
value={(field.value as number) ?? 0}
onChange={(e) => field.onChange(Number(e.target.value))}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,286 @@
"use client"
import { useEffect, useState, useCallback } from "react"
import {
AlertTriangle,
ClipboardList,
Clock,
RefreshCw,
Users,
} from "lucide-react"
import { cn } from "@/shared/lib/utils"
import { usePermission } from "@/shared/hooks/use-permission"
import { Permissions } from "@/shared/types/permissions"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { getProctoringDashboardAction } from "../actions"
import type {
ProctoringDashboardData,
ProctoringEventType,
} from "../types"
import { PROCTORING_EVENT_LABELS, EXAM_MODE_LABELS } from "../types"
const REFRESH_INTERVAL_MS = 10_000
const formatTime = (iso: string | null): string => {
if (!iso) return "—"
return new Date(iso).toLocaleString("zh-CN", {
hour: "2-digit", minute: "2-digit", second: "2-digit",
month: "2-digit", day: "2-digit",
})
}
const statusBadge = (status: string | null) => {
if (!status) return <Badge variant="outline"></Badge>
if (status === "started") return <Badge variant="secondary"></Badge>
if (status === "submitted") return <Badge></Badge>
if (status === "graded") return <Badge></Badge>
return <Badge variant="outline">{status}</Badge>
}
type StatCardProps = {
icon: React.ReactNode
label: string
value: number | string
hint?: string
highlight?: boolean
}
function StatCard({ icon, label, value, hint, highlight }: StatCardProps) {
return (
<Card className={cn(highlight && "border-destructive/50")}>
<CardContent className="flex items-center gap-3 p-4">
<div
className={cn(
"flex h-9 w-9 items-center justify-center rounded-md bg-muted",
highlight && "bg-destructive/10 text-destructive",
)}
>
{icon}
</div>
<div>
<div className="text-sm text-muted-foreground">{label}</div>
<div className="text-xl font-semibold">{value}</div>
{hint && <div className="text-xs text-muted-foreground">{hint}</div>}
</div>
</CardContent>
</Card>
)
}
type ProctoringDashboardProps = {
examId: string
initialData: ProctoringDashboardData | null
}
export function ProctoringDashboard({
examId,
initialData,
}: ProctoringDashboardProps) {
const { hasPermission } = usePermission()
const canProctor = hasPermission(Permissions.EXAM_PROCTOR)
const [data, setData] = useState<ProctoringDashboardData | null>(initialData)
const [loading, setLoading] = useState(false)
const [lastUpdated, setLastUpdated] = useState<Date | null>(
initialData ? new Date() : null,
)
const refresh = useCallback(async () => {
if (!canProctor) return
setLoading(true)
try {
const result = await getProctoringDashboardAction(examId)
if (result.success && result.data) {
setData(result.data)
setLastUpdated(new Date())
}
} catch (error) {
console.error("Failed to refresh proctoring dashboard:", error)
} finally {
setLoading(false)
}
}, [examId, canProctor])
useEffect(() => {
if (!canProctor) return
const timer = setInterval(refresh, REFRESH_INTERVAL_MS)
return () => clearInterval(timer)
}, [refresh, canProctor])
if (!canProctor) {
return (
<Card>
<CardContent className="py-10 text-center text-muted-foreground">
exam:proctor
</CardContent>
</Card>
)
}
if (!data) {
return (
<Card>
<CardContent className="py-10 text-center text-muted-foreground">
</CardContent>
</Card>
)
}
const { summary, students, recentEvents } = data
const notStarted = summary.totalStudents - summary.startedStudents - summary.submittedStudents
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">{summary.examTitle}</h2>
<p className="text-sm text-muted-foreground">
{EXAM_MODE_LABELS[summary.examMode]} ·
{lastUpdated ? formatTime(lastUpdated.toISOString()) : "—"}
</p>
</div>
<Button variant="outline" size="sm" onClick={refresh} disabled={loading}>
<RefreshCw className={cn("mr-2 h-4 w-4", loading && "animate-spin")} />
</Button>
</div>
<div className="grid gap-4 md:grid-cols-4">
<StatCard icon={<Users className="h-4 w-4" />} label="学生总数" value={summary.totalStudents} hint={`未开始 ${notStarted}`} />
<StatCard icon={<Clock className="h-4 w-4" />} label="已开始 / 已提交" value={`${summary.startedStudents} / ${summary.submittedStudents}`} />
<StatCard icon={<ClipboardList className="h-4 w-4" />} label="异常事件总数" value={summary.totalEvents} />
<StatCard icon={<AlertTriangle className="h-4 w-4" />} label="异常学生数" value={summary.abnormalStudents} highlight={summary.abnormalStudents > 0} />
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{(Object.keys(summary.eventsByType) as ProctoringEventType[]).map((type) => {
const count = summary.eventsByType[type]
if (count === 0) return null
return (
<Badge key={type} variant="destructive">
{PROCTORING_EVENT_LABELS[type]}{count}
</Badge>
)
})}
{summary.totalEvents === 0 && (
<span className="text-sm text-muted-foreground"></span>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> 3</CardDescription>
</CardHeader>
<CardContent>
{students.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground"></div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{students.map((s) => (
<TableRow key={s.studentId} className={cn(s.isAbnormal && "bg-destructive/5")}>
<TableCell className="font-medium">
{s.studentName}
{s.isAbnormal && <AlertTriangle className="ml-2 inline h-3 w-3 text-destructive" />}
</TableCell>
<TableCell>{statusBadge(s.submissionStatus)}</TableCell>
<TableCell className="text-right">
{s.eventCount > 0 ? (
<span className={cn(s.isAbnormal && "font-semibold text-destructive")}>{s.eventCount}</span>
) : (
<span className="text-muted-foreground">0</span>
)}
</TableCell>
<TableCell className="text-muted-foreground">{formatTime(s.lastEventAt)}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{(Object.keys(s.eventsByType) as ProctoringEventType[])
.filter((t) => s.eventsByType[t] > 0)
.map((t) => (
<Badge key={t} variant="outline" className="text-xs">
{PROCTORING_EVENT_LABELS[t]}×{s.eventsByType[t]}
</Badge>
))}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> 20 </CardDescription>
</CardHeader>
<CardContent>
{recentEvents.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground"></div>
) : (
<ScrollArea className="h-72">
<ul className="space-y-2">
{recentEvents.map((e) => (
<li key={e.id} className="flex items-start justify-between gap-3 rounded-md border p-2 text-sm">
<div>
<div className="font-medium">
{e.studentName}
<Badge variant="destructive" className="ml-2 text-xs">
{PROCTORING_EVENT_LABELS[e.eventType]}
</Badge>
</div>
{e.eventDetail && (
<div className="text-xs text-muted-foreground">{e.eventDetail}</div>
)}
</div>
<span className="text-xs text-muted-foreground">{formatTime(e.occurredAt)}</span>
</li>
))}
</ul>
</ScrollArea>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,388 @@
import "server-only"
import { db } from "@/shared/db"
import {
exams,
examProctoringEvents,
examSubmissions,
users,
} from "@/shared/db/schema"
import { and, desc, eq, gte, lte, sql, inArray } from "drizzle-orm"
import { cache } from "react"
import { createId } from "@paralleldrive/cuid2"
import type {
ProctoringEvent,
ProctoringEventWithDetails,
ExamProctoringSummary,
StudentProctoringStatus,
RecordProctoringEventInput,
GetProctoringEventsFilters,
ExamModeConfig,
ProctoringEventType,
ExamMode,
} from "./types"
import { ABNORMAL_EVENT_THRESHOLD } from "./types"
const ALL_EVENT_TYPES: ProctoringEventType[] = [
"tab_switch",
"window_blur",
"copy_attempt",
"paste_attempt",
"right_click",
"devtools_open",
"fullscreen_exit",
"idle_timeout",
]
const emptyEventsByType = (): Record<ProctoringEventType, number> => ({
tab_switch: 0,
window_blur: 0,
copy_attempt: 0,
paste_attempt: 0,
right_click: 0,
devtools_open: 0,
fullscreen_exit: 0,
idle_timeout: 0,
})
const toExamMode = (value: unknown): ExamMode => {
if (value === "homework" || value === "timed" || value === "proctored") {
return value
}
return "homework"
}
/**
* 记录一条监考事件
*/
export async function recordProctoringEvent(
input: RecordProctoringEventInput,
): Promise<ProctoringEvent> {
const eventId = createId()
const now = new Date()
await db.insert(examProctoringEvents).values({
id: eventId,
submissionId: input.submissionId,
studentId: input.studentId,
examId: input.examId,
eventType: input.eventType,
eventDetail: input.eventDetail ?? null,
occurredAt: now,
})
return {
id: eventId,
submissionId: input.submissionId,
studentId: input.studentId,
examId: input.examId,
eventType: input.eventType,
eventDetail: input.eventDetail ?? null,
occurredAt: now.toISOString(),
createdAt: now.toISOString(),
}
}
/**
* 查询某场考试的监考事件(含学生姓名、考试标题)
*/
export const getProctoringEvents = cache(
async (
examId: string,
filters?: GetProctoringEventsFilters,
): Promise<ProctoringEventWithDetails[]> => {
const conditions = [eq(examProctoringEvents.examId, examId)]
if (filters?.studentId) {
conditions.push(eq(examProctoringEvents.studentId, filters.studentId))
}
if (filters?.eventType) {
conditions.push(eq(examProctoringEvents.eventType, filters.eventType))
}
if (filters?.startedAt) {
conditions.push(gte(examProctoringEvents.occurredAt, new Date(filters.startedAt)))
}
if (filters?.endedAt) {
conditions.push(lte(examProctoringEvents.occurredAt, new Date(filters.endedAt)))
}
const rows = await db
.select({
event: examProctoringEvents,
studentName: users.name,
examTitle: exams.title,
})
.from(examProctoringEvents)
.innerJoin(users, eq(users.id, examProctoringEvents.studentId))
.innerJoin(exams, eq(exams.id, examProctoringEvents.examId))
.where(and(...conditions))
.orderBy(desc(examProctoringEvents.occurredAt))
return rows.map((row) => ({
id: row.event.id,
submissionId: row.event.submissionId,
studentId: row.event.studentId,
examId: row.event.examId,
eventType: row.event.eventType as ProctoringEventType,
eventDetail: row.event.eventDetail,
occurredAt: row.event.occurredAt.toISOString(),
createdAt: row.event.createdAt.toISOString(),
studentName: row.studentName ?? "未知学生",
examTitle: row.examTitle,
}))
},
)
/**
* 查询某次提交的监考事件
*/
export const getProctoringEventsBySubmission = cache(
async (submissionId: string): Promise<ProctoringEvent[]> => {
const rows = await db.query.examProctoringEvents.findMany({
where: eq(examProctoringEvents.submissionId, submissionId),
orderBy: [desc(examProctoringEvents.occurredAt)],
})
return rows.map((row) => ({
id: row.id,
submissionId: row.submissionId,
studentId: row.studentId,
examId: row.examId,
eventType: row.eventType as ProctoringEventType,
eventDetail: row.eventDetail,
occurredAt: row.occurredAt.toISOString(),
createdAt: row.createdAt.toISOString(),
}))
},
)
/**
* 获取考试监考摘要
*/
export const getExamProctoringSummary = cache(
async (examId: string): Promise<ExamProctoringSummary> => {
const exam = await db.query.exams.findFirst({
where: eq(exams.id, examId),
columns: {
id: true,
title: true,
examMode: true,
},
})
const examTitle = exam?.title ?? "未知考试"
const examMode = toExamMode(exam?.examMode)
// 统计提交记录
const submissions = await db.query.examSubmissions.findMany({
where: eq(examSubmissions.examId, examId),
columns: {
id: true,
studentId: true,
status: true,
},
})
const totalStudents = submissions.length
const startedStudents = submissions.filter(
(s) => s.status === "started",
).length
const submittedStudents = submissions.filter(
(s) => s.status === "submitted" || s.status === "graded",
).length
// 按事件类型分组统计
const eventStats = await db
.select({
eventType: examProctoringEvents.eventType,
count: sql<number>`count(*)::int`,
})
.from(examProctoringEvents)
.where(eq(examProctoringEvents.examId, examId))
.groupBy(examProctoringEvents.eventType)
const eventsByType = emptyEventsByType()
let totalEvents = 0
for (const stat of eventStats) {
const type = stat.eventType as ProctoringEventType
if (eventsByType[type] !== undefined) {
eventsByType[type] = stat.count
totalEvents += stat.count
}
}
// 统计异常学生数(事件数 >= 阈值)
const studentEventCounts = await db
.select({
studentId: examProctoringEvents.studentId,
count: sql<number>`count(*)::int`,
})
.from(examProctoringEvents)
.where(eq(examProctoringEvents.examId, examId))
.groupBy(examProctoringEvents.studentId)
const abnormalStudents = studentEventCounts.filter(
(s) => s.count >= ABNORMAL_EVENT_THRESHOLD,
).length
return {
examId,
examTitle,
examMode,
totalStudents,
startedStudents,
submittedStudents,
totalEvents,
abnormalStudents,
eventsByType,
}
},
)
/**
* 获取所有学生监考状态
*/
export const getStudentProctoringStatuses = cache(
async (examId: string): Promise<StudentProctoringStatus[]> => {
// 1. 拉取所有提交记录及学生姓名
const submissions = await db
.select({
submission: examSubmissions,
studentName: users.name,
})
.from(examSubmissions)
.innerJoin(users, eq(users.id, examSubmissions.studentId))
.where(eq(examSubmissions.examId, examId))
if (submissions.length === 0) return []
const studentIds = submissions.map((s) => s.submission.studentId)
// 2. 拉取这些提交的事件,按学生聚合
const eventRows = await db
.select({
studentId: examProctoringEvents.studentId,
eventType: examProctoringEvents.eventType,
occurredAt: examProctoringEvents.occurredAt,
})
.from(examProctoringEvents)
.where(
and(
eq(examProctoringEvents.examId, examId),
inArray(examProctoringEvents.studentId, studentIds),
),
)
.orderBy(desc(examProctoringEvents.occurredAt))
// 3. 按学生聚合
const statsByStudent = new Map<
string,
{
count: number
lastEventAt: Date | null
byType: Record<ProctoringEventType, number>
}
>()
for (const row of eventRows) {
const sid = row.studentId
const type = row.eventType as ProctoringEventType
const existing = statsByStudent.get(sid) ?? {
count: 0,
lastEventAt: null,
byType: emptyEventsByType(),
}
existing.count += 1
if (existing.byType[type] !== undefined) {
existing.byType[type] += 1
}
if (!existing.lastEventAt || row.occurredAt > existing.lastEventAt) {
existing.lastEventAt = row.occurredAt
}
statsByStudent.set(sid, existing)
}
return submissions.map((row) => {
const studentId = row.submission.studentId
const stats = statsByStudent.get(studentId)
return {
studentId,
studentName: row.studentName ?? "未知学生",
submissionId: row.submission.id,
submissionStatus: (row.submission.status ?? null) as StudentProctoringStatus["submissionStatus"],
eventCount: stats?.count ?? 0,
lastEventAt: stats?.lastEventAt ? stats.lastEventAt.toISOString() : null,
isAbnormal: (stats?.count ?? 0) >= ABNORMAL_EVENT_THRESHOLD,
eventsByType: stats?.byType ?? emptyEventsByType(),
}
})
},
)
/**
* 获取考试信息(含监考模式相关字段)
*/
export const getExamForProctoring = cache(
async (examId: string): Promise<{
id: string
title: string
examMode: ExamMode
config: ExamModeConfig
} | null> => {
const exam = await db.query.exams.findFirst({
where: eq(exams.id, examId),
})
if (!exam) return null
return {
id: exam.id,
title: exam.title,
examMode: toExamMode(exam.examMode),
config: {
examMode: toExamMode(exam.examMode),
durationMinutes: exam.durationMinutes ?? null,
shuffleQuestions: exam.shuffleQuestions ?? false,
allowLateStart: exam.allowLateStart ?? false,
lateStartGraceMinutes: exam.lateStartGraceMinutes ?? 0,
antiCheatEnabled: exam.antiCheatEnabled ?? false,
},
}
},
)
/**
* 获取最近 N 条监考事件(用于面板实时展示)
*/
export const getRecentProctoringEvents = cache(
async (examId: string, limit = 20): Promise<ProctoringEventWithDetails[]> => {
const rows = await db
.select({
event: examProctoringEvents,
studentName: users.name,
examTitle: exams.title,
})
.from(examProctoringEvents)
.innerJoin(users, eq(users.id, examProctoringEvents.studentId))
.innerJoin(exams, eq(exams.id, examProctoringEvents.examId))
.where(eq(examProctoringEvents.examId, examId))
.orderBy(desc(examProctoringEvents.occurredAt))
.limit(limit)
return rows.map((row) => ({
id: row.event.id,
submissionId: row.event.submissionId,
studentId: row.event.studentId,
examId: row.event.examId,
eventType: row.event.eventType as ProctoringEventType,
eventDetail: row.event.eventDetail,
occurredAt: row.event.occurredAt.toISOString(),
createdAt: row.event.createdAt.toISOString(),
studentName: row.studentName ?? "未知学生",
examTitle: row.examTitle,
}))
},
)
export { ALL_EVENT_TYPES }

View File

@@ -0,0 +1,136 @@
// 监考模块类型定义
export type ProctoringEventType =
| "tab_switch"
| "window_blur"
| "copy_attempt"
| "paste_attempt"
| "right_click"
| "devtools_open"
| "fullscreen_exit"
| "idle_timeout"
export type ExamMode = "homework" | "timed" | "proctored"
export type SubmissionStatus = "started" | "submitted" | "graded"
/**
* 监考事件(原始记录)
*/
export interface ProctoringEvent {
id: string
submissionId: string
studentId: string
examId: string
eventType: ProctoringEventType
eventDetail?: string | null
occurredAt: string
createdAt: string
}
/**
* 监考事件(含学生姓名、考试标题等关联信息)
*/
export interface ProctoringEventWithDetails extends ProctoringEvent {
studentName: string
examTitle: string
}
/**
* 监考事件输入参数
*/
export interface RecordProctoringEventInput {
submissionId: string
studentId: string
examId: string
eventType: ProctoringEventType
eventDetail?: string
}
/**
* 监考事件查询过滤条件
*/
export interface GetProctoringEventsFilters {
studentId?: string
eventType?: ProctoringEventType
startedAt?: string
endedAt?: string
}
/**
* 考试监考摘要
*/
export interface ExamProctoringSummary {
examId: string
examTitle: string
examMode: ExamMode
totalStudents: number
startedStudents: number
submittedStudents: number
totalEvents: number
abnormalStudents: number
eventsByType: Record<ProctoringEventType, number>
}
/**
* 学生监考状态
*/
export interface StudentProctoringStatus {
studentId: string
studentName: string
submissionId: string | null
submissionStatus: SubmissionStatus | null
eventCount: number
lastEventAt: string | null
isAbnormal: boolean
eventsByType: Record<ProctoringEventType, number>
}
/**
* 监考面板数据(合并摘要与学生状态)
*/
export interface ProctoringDashboardData {
summary: ExamProctoringSummary
students: StudentProctoringStatus[]
recentEvents: ProctoringEventWithDetails[]
}
/**
* 考试模式配置(用于考试创建/编辑表单)
*/
export interface ExamModeConfig {
examMode: ExamMode
durationMinutes: number | null
shuffleQuestions: boolean
allowLateStart: boolean
lateStartGraceMinutes: number
antiCheatEnabled: boolean
}
/**
* 监考事件类型显示标签
*/
export const PROCTORING_EVENT_LABELS: Record<ProctoringEventType, string> = {
tab_switch: "切换标签页",
window_blur: "窗口失焦",
copy_attempt: "复制操作",
paste_attempt: "粘贴操作",
right_click: "右键点击",
devtools_open: "开发者工具",
fullscreen_exit: "退出全屏",
idle_timeout: "空闲超时",
}
/**
* 考试模式显示标签
*/
export const EXAM_MODE_LABELS: Record<ExamMode, string> = {
homework: "作业模式",
timed: "限时模式",
proctored: "监考模式",
}
/**
* 异常事件阈值:超过该值视为异常学生
*/
export const ABNORMAL_EVENT_THRESHOLD = 3

View File

@@ -418,6 +418,14 @@ export const classSchedule = mysqlTable("class_schedule", {
}).onDelete("cascade"),
}));
// --- P2: Exam Proctoring (考试监考) ---
export const examModeEnum = mysqlEnum("exam_mode", ["homework", "timed", "proctored"]);
export const proctoringEventTypeEnum = mysqlEnum("event_type", [
"tab_switch", "window_blur", "copy_attempt", "paste_attempt",
"right_click", "devtools_open", "fullscreen_exit", "idle_timeout"
]);
export const exams = mysqlTable("exams", {
id: id("id").primaryKey(),
title: varchar("title", { length: 255 }).notNull(),
@@ -444,7 +452,15 @@ export const exams = mysqlTable("exams", {
startTime: timestamp("start_time"),
endTime: timestamp("end_time"),
// P2: Online exam mode + proctoring settings
examMode: examModeEnum.default("homework"),
durationMinutes: int("duration_minutes"),
shuffleQuestions: boolean("shuffle_questions").default(false),
allowLateStart: boolean("allow_late_start").default(false),
lateStartGraceMinutes: int("late_start_grace_minutes").default(0),
antiCheatEnabled: boolean("anti_cheat_enabled").default(false),
// Status: draft, published, ongoing, finished
status: varchar("status", { length: 50 }).default("draft"),
@@ -1083,3 +1099,115 @@ export const scheduleChanges = mysqlTable("schedule_changes", {
requestedByIdx: index("schedule_changes_requested_by_idx").on(table.requestedBy),
originalScheduleIdx: index("schedule_changes_original_schedule_idx").on(table.originalScheduleId),
}));
// --- P2: Elective Course Management (选课管理) ---
export const electiveCourseStatusEnum = mysqlEnum("status", ["draft", "open", "closed", "cancelled"]);
export const electiveSelectionModeEnum = mysqlEnum("selection_mode", ["fcfs", "lottery"]);
export const courseSelectionStatusEnum = mysqlEnum("selection_status", ["selected", "enrolled", "waitlist", "dropped", "rejected"]);
export const electiveCourses = mysqlTable("elective_courses", {
id: id("id").primaryKey(),
name: varchar("name", { length: 255 }).notNull(),
subjectId: varchar("subject_id", { length: 128 }).references(() => subjects.id, { onDelete: "set null" }),
teacherId: varchar("teacher_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
gradeId: varchar("grade_id", { length: 128 }).references(() => grades.id, { onDelete: "set null" }),
description: text("description"),
capacity: int("capacity").default(30).notNull(),
enrolledCount: int("enrolled_count").default(0).notNull(),
classroom: varchar("classroom", { length: 100 }),
schedule: varchar("schedule", { length: 255 }),
startDate: date("start_date"),
endDate: date("end_date"),
selectionStartAt: datetime("selection_start_at"),
selectionEndAt: datetime("selection_end_at"),
status: electiveCourseStatusEnum.default("draft").notNull(),
selectionMode: electiveSelectionModeEnum.default("fcfs").notNull(),
credit: decimal("credit", { precision: 3, scale: 1 }).default("1.0"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
teacherIdx: index("elective_courses_teacher_idx").on(table.teacherId),
subjectIdx: index("elective_courses_subject_idx").on(table.subjectId),
gradeIdx: index("elective_courses_grade_idx").on(table.gradeId),
statusIdx: index("elective_courses_status_idx").on(table.status),
}));
export const courseSelections = mysqlTable("course_selections", {
id: id("id").primaryKey(),
courseId: varchar("course_id", { length: 128 }).notNull().references(() => electiveCourses.id, { onDelete: "cascade" }),
studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
status: courseSelectionStatusEnum.default("selected").notNull(),
priority: int("priority").default(1),
selectedAt: timestamp("selected_at").defaultNow().notNull(),
enrolledAt: timestamp("enrolled_at"),
droppedAt: timestamp("dropped_at"),
lotteryRank: int("lottery_rank"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
courseStudentPk: primaryKey({ columns: [table.courseId, table.studentId] }),
courseIdx: index("course_selections_course_idx").on(table.courseId),
studentIdx: index("course_selections_student_idx").on(table.studentId),
statusIdx: index("course_selections_status_idx").on(table.status),
}));
// --- P2: Exam Proctoring (考试监考) ---
export const examProctoringEvents = mysqlTable("exam_proctoring_events", {
id: id("id").primaryKey(),
submissionId: varchar("submission_id", { length: 128 }).notNull().references(() => examSubmissions.id, { onDelete: "cascade" }),
studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
examId: varchar("exam_id", { length: 128 }).notNull().references(() => exams.id, { onDelete: "cascade" }),
eventType: proctoringEventTypeEnum.notNull(),
eventDetail: text("event_detail"),
occurredAt: timestamp("occurred_at").defaultNow().notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
}, (table) => ({
submissionIdx: index("proctoring_submission_idx").on(table.submissionId),
studentIdx: index("proctoring_student_idx").on(table.studentId),
examIdx: index("proctoring_exam_idx").on(table.examId),
eventTypeIdx: index("proctoring_event_type_idx").on(table.eventType),
}));
// --- P2: Learning Diagnostic (学情诊断报告) ---
export const knowledgePointMastery = mysqlTable("knowledge_point_mastery", {
id: id("id").primaryKey(),
studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
knowledgePointId: varchar("knowledge_point_id", { length: 128 }).notNull().references(() => knowledgePoints.id, { onDelete: "cascade" }),
masteryLevel: decimal("mastery_level", { precision: 5, scale: 2 }).default("0").notNull(),
totalQuestions: int("total_questions").default(0).notNull(),
correctQuestions: int("correct_questions").default(0).notNull(),
lastAssessedAt: timestamp("last_assessed_at").defaultNow().notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
studentKpPk: primaryKey({ columns: [table.studentId, table.knowledgePointId] }),
studentIdx: index("mastery_student_idx").on(table.studentId),
kpIdx: index("mastery_kp_idx").on(table.knowledgePointId),
}));
export const diagnosticReportStatusEnum = mysqlEnum("report_status", ["draft", "published", "archived"]);
export const diagnosticReportTypeEnum = mysqlEnum("report_type", ["individual", "class", "grade"]);
export const learningDiagnosticReports = mysqlTable("learning_diagnostic_reports", {
id: id("id").primaryKey(),
studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
generatedBy: varchar("generated_by", { length: 128 }).references(() => users.id, { onDelete: "set null" }),
reportType: diagnosticReportTypeEnum.default("individual").notNull(),
period: varchar("period", { length: 50 }),
summary: text("summary"),
strengths: json("strengths"),
weaknesses: json("weaknesses"),
recommendations: json("recommendations"),
overallScore: decimal("overall_score", { precision: 5, scale: 2 }),
status: diagnosticReportStatusEnum.default("draft").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
studentIdx: index("diagnostic_student_idx").on(table.studentId),
generatedByIdx: index("diagnostic_generated_by_idx").on(table.generatedBy),
statusIdx: index("diagnostic_status_idx").on(table.status),
reportTypeIdx: index("diagnostic_report_type_idx").on(table.reportType),
}));

View File

@@ -47,6 +47,12 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
Permissions.MESSAGE_DELETE,
Permissions.SCHEDULE_AUTO,
Permissions.SCHEDULE_ADJUST,
Permissions.ELECTIVE_MANAGE,
Permissions.ELECTIVE_READ,
Permissions.EXAM_PROCTOR,
Permissions.EXAM_PROCTOR_READ,
Permissions.DIAGNOSTIC_MANAGE,
Permissions.DIAGNOSTIC_READ,
],
teacher: [
Permissions.EXAM_CREATE,
@@ -78,6 +84,12 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
Permissions.MESSAGE_SEND,
Permissions.MESSAGE_READ,
Permissions.MESSAGE_DELETE,
Permissions.ELECTIVE_MANAGE,
Permissions.ELECTIVE_READ,
Permissions.EXAM_PROCTOR,
Permissions.EXAM_PROCTOR_READ,
Permissions.DIAGNOSTIC_MANAGE,
Permissions.DIAGNOSTIC_READ,
],
student: [
Permissions.EXAM_READ,
@@ -92,6 +104,9 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
Permissions.ATTENDANCE_READ,
Permissions.MESSAGE_READ,
Permissions.MESSAGE_DELETE,
Permissions.ELECTIVE_SELECT,
Permissions.ELECTIVE_READ,
Permissions.DIAGNOSTIC_READ,
],
parent: [
Permissions.EXAM_READ,
@@ -135,6 +150,10 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
Permissions.MESSAGE_SEND,
Permissions.MESSAGE_READ,
Permissions.MESSAGE_DELETE,
Permissions.ELECTIVE_READ,
Permissions.EXAM_PROCTOR_READ,
Permissions.DIAGNOSTIC_MANAGE,
Permissions.DIAGNOSTIC_READ,
],
teaching_head: [
Permissions.EXAM_CREATE,
@@ -163,6 +182,9 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
Permissions.MESSAGE_SEND,
Permissions.MESSAGE_READ,
Permissions.MESSAGE_DELETE,
Permissions.ELECTIVE_READ,
Permissions.EXAM_PROCTOR_READ,
Permissions.DIAGNOSTIC_READ,
],
}

View File

@@ -80,6 +80,19 @@ export const Permissions = {
// Scheduling (排课与调课)
SCHEDULE_AUTO: "schedule:auto",
SCHEDULE_ADJUST: "schedule:adjust",
// P2: Elective Course (选课管理)
ELECTIVE_MANAGE: "elective:manage",
ELECTIVE_READ: "elective:read",
ELECTIVE_SELECT: "elective:select",
// P2: Exam Proctoring (考试监考)
EXAM_PROCTOR: "exam:proctor",
EXAM_PROCTOR_READ: "exam:proctor_read",
// P2: Learning Diagnostic (学情诊断)
DIAGNOSTIC_MANAGE: "diagnostic:manage",
DIAGNOSTIC_READ: "diagnostic:read",
} as const
export type Permission = (typeof Permissions)[keyof typeof Permissions]