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

@@ -20,6 +20,56 @@ import { sql, eq } from "drizzle-orm";
import { hash } from "bcryptjs";
import { ROLE_PERMISSIONS } from "../src/shared/lib/permissions";
// ---- 内部类型定义(避免 any ----
interface SeedOption {
id: string;
text: string;
isCorrect?: boolean;
}
interface SeedQuestionContent {
text?: string;
options?: SeedOption[];
correctAnswer?: string | boolean;
}
interface SeedQuestion {
id: string;
type: "single_choice" | "text" | "judgment";
difficulty: number;
content: SeedQuestionContent;
score: number;
}
interface SeedQuestionBank {
chineseQuestions: SeedQuestion[];
mathQuestions: SeedQuestion[];
engQuestions?: SeedQuestion[];
}
interface SeedGradeRecord {
id: string;
studentId: string;
subjectId: string;
classId: string;
teacherId: string;
score: number;
examType: string;
term: string;
recordedAt: Date;
}
interface SeedAttendanceRecord {
id: string;
studentId: string;
classId: string;
date: Date;
status: "present" | "late" | "absent";
recorderId: string;
remark: string | null;
}
/**
* 小学场景初始化脚本
*
@@ -73,22 +123,22 @@ async function seed() {
await seedEnrollmentsAndParents(classMap, studentMap, parentMap);
// --- 8. 教材 + 章节(每科第一章第一节课)---
const chapterMap = await seedTextbooksAndChapters(subjectMap, gradeMap);
const chapterMap = await seedTextbooksAndChapters();
// --- 9. 知识点 ---
const kpMap = await seedKnowledgePoints(chapterMap);
// --- 10. 课表 ---
await seedClassSchedule(classMap, subjectMap);
await seedClassSchedule(classMap);
// --- 11. 题库 ---
const questionBanks = await seedQuestions(teacherMap, subjectMap, kpMap);
// --- 12. 试卷(语文、数学各 1 套)+ 学生答题与批改 ---
await seedExamsAndSubmissions(teacherMap, classMap, studentMap, subjectMap, questionBanks, gradeMap);
await seedExamsAndSubmissions(teacherMap, classMap, studentMap, questionBanks);
// --- 13. 作业(引用试卷)+ 学生答题与批改 ---
await seedHomework(teacherMap, classMap, studentMap, questionBanks, subjectMap);
await seedHomework(teacherMap, classMap, studentMap, questionBanks);
// --- 14. 成绩记录 ---
await seedGradeRecords(teacherMap, classMap, studentMap, subjectMap, academicYearId);
@@ -451,10 +501,7 @@ async function seedEnrollmentsAndParents(
// ============ 8. 教材 + 章节 ============
async function seedTextbooksAndChapters(
subjectMap: Record<string, string>,
gradeMap: Record<string, string>
) {
async function seedTextbooksAndChapters() {
console.log("📖 创建教材与章节...");
// 每科 1 本教材(一年级语文、一年级数学、一年级英语)
// 只实现第一章第一节课
@@ -558,8 +605,7 @@ async function seedKnowledgePoints(chapterMap: Record<string, string>) {
// ============ 10. 课表 ============
async function seedClassSchedule(
classMap: Record<string, { id: string }>,
subjectMap: Record<string, string>
classMap: Record<string, { id: string }>
) {
console.log("📅 创建课表...");
// 每班每天安排语数外,简化为周一三五各 2 节
@@ -807,13 +853,11 @@ async function seedExamsAndSubmissions(
teacherMap: Record<string, { id: string }>,
classMap: Record<string, { id: string }>,
studentMap: Record<string, { id: string; classKey: string }>,
subjectMap: Record<string, string>,
questionBanks: { chineseQuestions: any[]; mathQuestions: any[] },
gradeMap: Record<string, string>
questionBanks: SeedQuestionBank
) {
console.log("📝 创建试卷与学生答题...");
const makeGroup = (title: string, children: any[]) => ({
const makeGroup = (title: string, children: SeedQuestion[]) => ({
id: createId(),
type: "group" as const,
title,
@@ -926,12 +970,12 @@ async function seedExamsAndSubmissions(
submissionCount++;
// 答案
const buildAnswer = (q: any, idx: number) => {
const buildAnswer = (q: SeedQuestion, idx: number) => {
if (q.type === "single_choice") {
// 前 3 题选正确项,后 2 题选错误项
const correct = q.content.options.find((o: any) => o.isCorrect);
const wrong = q.content.options.find((o: any) => !o.isCorrect);
return { answer: idx < 3 ? correct.id : wrong.id };
const correct = q.content.options?.find((o) => o.isCorrect);
const wrong = q.content.options?.find((o) => !o.isCorrect);
return { answer: idx < 3 ? correct?.id : wrong?.id };
}
if (q.type === "judgment") return { answer: idx < 3 };
return { answer: idx < 3 ? "标准答案" : "学生答案" };
@@ -939,7 +983,7 @@ async function seedExamsAndSubmissions(
await db.insert(submissionAnswers).values(
exam.qIds.map((qid, idx) => {
const q = exam.qs.find((x: any) => x.id === qid)!;
const q = exam.qs.find((x) => x.id === qid)!;
const score = isGraded ? (perScores[idx] ?? 0) : null;
return {
id: createId(),
@@ -963,8 +1007,7 @@ async function seedHomework(
teacherMap: Record<string, { id: string }>,
classMap: Record<string, { id: string }>,
studentMap: Record<string, { id: string; classKey: string }>,
questionBanks: { chineseQuestions: any[]; mathQuestions: any[]; engQuestions: any[] },
subjectMap: Record<string, string>
questionBanks: SeedQuestionBank
) {
console.log("📚 创建作业...");
// 1 个语文作业(引用语文题)+ 1 个数学作业(引用数学题)
@@ -972,7 +1015,7 @@ async function seedHomework(
const dueAt = new Date(now.getTime() + 7 * 86400_000);
const lateDueAt = new Date(now.getTime() + 9 * 86400_000);
const assignments: { id: string; title: string; creatorId: string; qIds: string[]; qs: any[]; targetClassKey: string }[] = [
const assignments: { id: string; title: string; creatorId: string; qIds: string[]; qs: SeedQuestion[]; targetClassKey: string }[] = [
{
id: "hw_chinese_g1",
title: "一年级语文第一课作业",
@@ -1039,7 +1082,6 @@ async function seedHomework(
);
// 前 3 个学生提交作业并批改
const scoreMap = new Map(hw.qs.map(q => [q.id, q.score] as const));
for (let i = 0; i < Math.min(3, targets.length); i++) {
const student = targets[i];
const submissionId = createId();
@@ -1067,11 +1109,11 @@ async function seedHomework(
});
hwSubmissionCount++;
const buildAnswer = (q: any, idx: number) => {
const buildAnswer = (q: SeedQuestion, idx: number) => {
if (q.type === "single_choice") {
const correct = q.content.options.find((o: any) => o.isCorrect);
const wrong = q.content.options.find((o: any) => !o.isCorrect);
return { answer: idx < 3 ? correct.id : wrong.id };
const correct = q.content.options?.find((o) => o.isCorrect);
const wrong = q.content.options?.find((o) => !o.isCorrect);
return { answer: idx < 3 ? correct?.id : wrong?.id };
}
if (q.type === "judgment") return { answer: idx < 3 };
return { answer: idx < 3 ? "标准答案" : "学生答案" };
@@ -1079,7 +1121,7 @@ async function seedHomework(
await db.insert(homeworkAnswers).values(
hw.qIds.map((qid, idx) => {
const q = hw.qs.find((x: any) => x.id === qid)!;
const q = hw.qs.find((x) => x.id === qid)!;
const score = isGraded ? (perScores[idx] ?? 0) : null;
return {
id: createId(),
@@ -1117,7 +1159,7 @@ async function seedGradeRecords(
ENG: teacherMap.T_E1.id,
};
const records: any[] = [];
const records: SeedGradeRecord[] = [];
for (const s of g1c1Students) {
for (const [subCode, sid] of Object.entries(subjectMap)) {
records.push({
@@ -1151,7 +1193,7 @@ async function seedAttendanceRecords(
// 为一年级1班最近 5 天的考勤
const g1c1Students = Object.values(studentMap).filter(s => s.classKey === "G1C1");
const recorder = teacherMap.T_C1.id;
const records: any[] = [];
const records: SeedAttendanceRecord[] = [];
const statuses = ["present", "present", "present", "present", "late", "absent"] as const;
for (let d = 0; d < 5; d++) {