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:
41
src/app/(dashboard)/admin/elective/[id]/edit/page.tsx
Normal file
41
src/app/(dashboard)/admin/elective/[id]/edit/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
src/app/(dashboard)/admin/elective/create/page.tsx
Normal file
29
src/app/(dashboard)/admin/elective/create/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
src/app/(dashboard)/admin/elective/page.tsx
Normal file
44
src/app/(dashboard)/admin/elective/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
31
src/app/(dashboard)/student/diagnostic/page.tsx
Normal file
31
src/app/(dashboard)/student/diagnostic/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
src/app/(dashboard)/student/elective/page.tsx
Normal file
49
src/app/(dashboard)/student/elective/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
48
src/app/(dashboard)/teacher/diagnostic/page.tsx
Normal file
48
src/app/(dashboard)/teacher/diagnostic/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
50
src/app/(dashboard)/teacher/elective/page.tsx
Normal file
50
src/app/(dashboard)/teacher/elective/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
55
src/app/(dashboard)/teacher/exams/[id]/proctoring/page.tsx
Normal file
55
src/app/(dashboard)/teacher/exams/[id]/proctoring/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
91
src/app/api/proctoring/event/route.ts
Normal file
91
src/app/api/proctoring/event/route.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
148
src/modules/diagnostic/actions.ts
Normal file
148
src/modules/diagnostic/actions.ts
Normal 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" }
|
||||
}
|
||||
}
|
||||
267
src/modules/diagnostic/components/class-diagnostic-view.tsx
Normal file
267
src/modules/diagnostic/components/class-diagnostic-view.tsx
Normal 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 <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 (<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 <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>
|
||||
)
|
||||
}
|
||||
114
src/modules/diagnostic/components/mastery-radar-chart.tsx
Normal file
114
src/modules/diagnostic/components/mastery-radar-chart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
262
src/modules/diagnostic/components/report-list.tsx
Normal file
262
src/modules/diagnostic/components/report-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
229
src/modules/diagnostic/components/student-diagnostic-view.tsx
Normal file
229
src/modules/diagnostic/components/student-diagnostic-view.tsx
Normal 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 (<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>
|
||||
)
|
||||
}
|
||||
202
src/modules/diagnostic/data-access-reports.ts
Normal file
202
src/modules/diagnostic/data-access-reports.ts
Normal 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 存生成者 ID(schema 要求 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
|
||||
254
src/modules/diagnostic/data-access.ts
Normal file
254
src/modules/diagnostic/data-access.ts
Normal 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,
|
||||
}))
|
||||
}
|
||||
97
src/modules/diagnostic/types.ts
Normal file
97
src/modules/diagnostic/types.ts
Normal 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
|
||||
}
|
||||
304
src/modules/elective/actions.ts
Normal file
304
src/modules/elective/actions.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
293
src/modules/elective/components/elective-course-form.tsx
Normal file
293
src/modules/elective/components/elective-course-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
233
src/modules/elective/components/elective-course-list.tsx
Normal file
233
src/modules/elective/components/elective-course-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
215
src/modules/elective/components/student-selection-view.tsx
Normal file
215
src/modules/elective/components/student-selection-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
217
src/modules/elective/data-access-operations.ts
Normal file
217
src/modules/elective/data-access-operations.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
189
src/modules/elective/data-access-selections.ts
Normal file
189
src/modules/elective/data-access-selections.ts
Normal 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)
|
||||
}
|
||||
242
src/modules/elective/data-access.ts
Normal file
242
src/modules/elective/data-access.ts
Normal 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 }
|
||||
133
src/modules/elective/schema.ts
Normal file
133
src/modules/elective/schema.ts
Normal 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>
|
||||
108
src/modules/elective/types.ts
Normal file
108
src/modules/elective/types.ts
Normal 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",
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
144
src/modules/proctoring/actions.ts
Normal file
144
src/modules/proctoring/actions.ts
Normal 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")
|
||||
}
|
||||
}
|
||||
225
src/modules/proctoring/components/anti-cheat-monitor.tsx
Normal file
225
src/modules/proctoring/components/anti-cheat-monitor.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
230
src/modules/proctoring/components/exam-mode-config.tsx
Normal file
230
src/modules/proctoring/components/exam-mode-config.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
286
src/modules/proctoring/components/proctoring-dashboard.tsx
Normal file
286
src/modules/proctoring/components/proctoring-dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
388
src/modules/proctoring/data-access.ts
Normal file
388
src/modules/proctoring/data-access.ts
Normal 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 }
|
||||
136
src/modules/proctoring/types.ts
Normal file
136
src/modules/proctoring/types.ts
Normal 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
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user