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

## 新增功能模块

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

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

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

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

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

View File

@@ -0,0 +1,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))
}
}
}
}