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:
@@ -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