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:
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
|
||||
Reference in New Issue
Block a user