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

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

View File

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

View File

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