Files
NextEdu/src/modules/diagnostic/data-access.ts
SpecialX 978d9a8309
Some checks failed
Security / deep-security-scan (push) Failing after 20m5s
DR Drill / dr-drill (push) Failing after 1m31s
CI / scheduled-backup (push) Failing after 1m31s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
feat: 新增备课模块并修复全模块 P0/P1/P2 缺陷
主要变更:

- 新增 lesson-preparation 模块: 备课编辑器、节点编辑、AI 建议、知识点选择、版本历史、作业发布

- 新增 shared 通用组件: charts/question-bank-filters/schedule-list/ui (chip-nav/filter-bar/page-header/stat-card/stat-item)

- 新增 student/admin 端 loading.tsx 与 error.tsx, 优化加载与错误态体验

- 新增 teacher/lesson-plans 页面 (列表/新建/编辑)

- 新增 drizzle 迁移 0002_tiny_lionheart 及 snapshot

- 新增 textbooks/schema.ts 与 exams/utils/normalize-structure.ts

- 修复 Tiptap v3 SSR hydration 崩溃 (rich-text-block immediatelyRender: false)

- 重构多模块 data-access/actions/组件, 修复权限校验与类型规范

- 同步架构文档 004/005 反映新增模块、导出、依赖关系

- 归档 bugs/* 测试报告与 e2e 测试脚本 (admin/parent/student/teacher web_test)
2026-06-22 01:06:16 +08:00

258 lines
9.6 KiB
TypeScript

import "server-only"
import { cache } from "react"
import { desc, eq, inArray } from "drizzle-orm"
import { db } from "@/shared/db"
import { knowledgePointMastery, knowledgePoints } from "@/shared/db/schema"
import { getClassNameById, getActiveStudentIdsByClassId, getClassExists } from "@/modules/classes/data-access"
import { getExamSubmissionWithAnswers } from "@/modules/exams/data-access"
import { getKnowledgePointsForQuestions } from "@/modules/questions/data-access"
import { getUserIdsByGradeId, getUserNamesByIds } from "@/modules/users/data-access"
import type {
ClassMasterySummary,
KnowledgePointMastery,
KnowledgePointStat,
MasteryWithKnowledgePoint,
StudentMasterySummary,
} 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 serializeMastery = (r: typeof knowledgePointMastery.$inferSelect): KnowledgePointMastery => ({
id: r.id,
studentId: r.studentId,
knowledgePointId: r.knowledgePointId,
masteryLevel: toNumber(r.masteryLevel),
totalQuestions: r.totalQuestions,
correctQuestions: r.correctQuestions,
lastAssessedAt: r.lastAssessedAt.toISOString(),
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
})
/** 获取学生在所有知识点的掌握度(含知识点名称) */
export const getStudentMastery = cache(async (studentId: string): Promise<MasteryWithKnowledgePoint[]> => {
const rows = await db
.select({
mastery: knowledgePointMastery,
kpName: knowledgePoints.name,
kpDescription: knowledgePoints.description,
})
.from(knowledgePointMastery)
.leftJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId))
.where(eq(knowledgePointMastery.studentId, studentId))
.orderBy(desc(knowledgePointMastery.masteryLevel))
return rows.map((r) => ({
...serializeMastery(r.mastery),
knowledgePointName: r.kpName ?? "Unknown",
knowledgePointDescription: r.kpDescription,
}))
})
/** 获取学生掌握度摘要(含强项/弱项分析) */
export const getStudentMasterySummary = cache(async (studentId: string): Promise<StudentMasterySummary | null> => {
const userMap = await getUserNamesByIds([studentId])
const student = userMap.get(studentId)
if (!student) return null
const allMastery = await getStudentMastery(studentId)
const averageMastery =
allMastery.length > 0
? round2(allMastery.reduce((acc, m) => acc + m.masteryLevel, 0) / allMastery.length)
: 0
// Single-pass classification: strengths (>=80) and weaknesses (<60)
const strengths: MasteryWithKnowledgePoint[] = []
const weaknesses: MasteryWithKnowledgePoint[] = []
for (const m of allMastery) {
if (m.masteryLevel >= 80) strengths.push(m)
if (m.masteryLevel < 60) weaknesses.push(m)
}
return {
studentId,
studentName: student.name ?? "Unknown",
averageMastery,
totalKnowledgePoints: allMastery.length,
strengths,
weaknesses,
allMastery,
}
})
/** 从提交答案更新掌握度(正确率作为掌握度) */
export async function updateMasteryFromSubmission(submissionId: string): Promise<void> {
const submission = await getExamSubmissionWithAnswers(submissionId)
if (!submission) return
const answers = submission.answers
if (answers.length === 0) return
const questionIds = Array.from(new Set(answers.map((a) => a.questionId)))
const kpMap = await getKnowledgePointsForQuestions(questionIds)
// Build a Map for O(1) answer lookup instead of find() in loop
const answerByQuestionId = new Map(answers.map((a) => [a.questionId, a]))
const kpStats = new Map<string, { total: number; correct: number }>()
for (const [questionId, kpLinks] of kpMap.entries()) {
const answer = answerByQuestionId.get(questionId)
if (!answer) continue
for (const link of kpLinks) {
const stat = kpStats.get(link.knowledgePointId) ?? { total: 0, correct: 0 }
stat.total += 1
if ((answer.score ?? 0) > 0) stat.correct += 1
kpStats.set(link.knowledgePointId, stat)
}
}
const now = new Date()
await Promise.all(
Array.from(kpStats.entries()).map(async ([kpId, stat]) => {
const masteryLevel = stat.total > 0 ? round2((stat.correct / stat.total) * 100) : 0
await db
.insert(knowledgePointMastery)
.values({
studentId: submission.studentId,
knowledgePointId: kpId,
masteryLevel: String(masteryLevel),
totalQuestions: stat.total,
correctQuestions: stat.correct,
lastAssessedAt: now,
})
.onDuplicateKeyUpdate({
set: {
masteryLevel: String(masteryLevel),
totalQuestions: stat.total,
correctQuestions: stat.correct,
lastAssessedAt: now,
updatedAt: now,
},
})
}),
)
}
/** 获取班级掌握度摘要 */
export const getClassMasterySummary = cache(async (classId: string): Promise<ClassMasterySummary | null> => {
const classExists = await getClassExists(classId)
if (!classExists) return null
// 班级名称 与 学生列表 相互独立,并行拉取
const [classNameResult, studentIds] = await Promise.all([
getClassNameById(classId),
getActiveStudentIdsByClassId(classId),
])
const className = classNameResult ?? "Unknown"
if (studentIds.length === 0) {
return { classId, className, studentCount: 0, averageMastery: 0, knowledgePointStats: [], studentsNeedingAttention: [] }
}
// 学生姓名 与 掌握度记录 相互独立,并行拉取
const [userMap, masteryRows] = await Promise.all([
getUserNamesByIds(studentIds),
db
.select({ mastery: knowledgePointMastery, kpName: knowledgePoints.name })
.from(knowledgePointMastery)
.leftJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId))
.where(inArray(knowledgePointMastery.studentId, studentIds)),
])
const students = studentIds
.map((id) => ({ id, name: userMap.get(id)?.name ?? null }))
.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""))
const byKp = new Map<string, { name: string; levels: number[]; mastered: number; notMastered: number }>()
const byStudent = new Map<string, { levels: number[]; weakCount: number }>()
for (const s of students) byStudent.set(s.id, { levels: [], weakCount: 0 })
for (const r of masteryRows) {
const level = toNumber(r.mastery.masteryLevel)
const kpId = r.mastery.knowledgePointId
const kpEntry = byKp.get(kpId) ?? { name: r.kpName ?? "Unknown", levels: [], mastered: 0, notMastered: 0 }
kpEntry.levels.push(level)
if (level >= 80) kpEntry.mastered += 1
if (level < 60) kpEntry.notMastered += 1
byKp.set(kpId, kpEntry)
const stuEntry = byStudent.get(r.mastery.studentId)
if (stuEntry) {
stuEntry.levels.push(level)
if (level < 60) stuEntry.weakCount += 1
}
}
const knowledgePointStats: KnowledgePointStat[] = Array.from(byKp.entries()).map(([kpId, e]) => ({
knowledgePointId: kpId,
knowledgePointName: e.name,
averageMastery: e.levels.length > 0 ? round2(e.levels.reduce((a, b) => a + b, 0) / e.levels.length) : 0,
masteredCount: e.mastered,
notMasteredCount: e.notMastered,
totalStudents: students.length,
}))
const allLevels = masteryRows.map((r) => toNumber(r.mastery.masteryLevel))
const averageMastery = allLevels.length > 0 ? round2(allLevels.reduce((a, b) => a + b, 0) / allLevels.length) : 0
const studentsNeedingAttention = students
.map((s) => {
const e = byStudent.get(s.id)
if (!e) return null
const avg = e.levels.length > 0 ? round2(e.levels.reduce((a, b) => a + b, 0) / e.levels.length) : 0
return { studentId: s.id, studentName: s.name ?? "Unknown", averageMastery: avg, weakCount: e.weakCount }
})
.filter((s): s is { studentId: string; studentName: string; averageMastery: number; weakCount: number } => s !== null)
.filter((s) => s.averageMastery < 60)
.sort((a, b) => a.averageMastery - b.averageMastery)
return { classId, className, studentCount: students.length, averageMastery, knowledgePointStats, studentsNeedingAttention }
})
/** 获取知识点统计(按班级或年级聚合) */
export const getKnowledgePointStats = cache(async (classId?: string, gradeId?: string): Promise<KnowledgePointStat[]> => {
let studentIds: string[] = []
if (classId) {
studentIds = await getActiveStudentIdsByClassId(classId)
} else if (gradeId) {
studentIds = await getUserIdsByGradeId(gradeId)
}
if (studentIds.length === 0) return []
const masteryRows = await db
.select({ mastery: knowledgePointMastery, kpName: knowledgePoints.name })
.from(knowledgePointMastery)
.leftJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId))
.where(inArray(knowledgePointMastery.studentId, studentIds))
const byKp = new Map<string, { name: string; levels: number[]; mastered: number; notMastered: number }>()
for (const r of masteryRows) {
const level = toNumber(r.mastery.masteryLevel)
const kpId = r.mastery.knowledgePointId
const e = byKp.get(kpId) ?? { name: r.kpName ?? "Unknown", levels: [], mastered: 0, notMastered: 0 }
e.levels.push(level)
if (level >= 80) e.mastered += 1
if (level < 60) e.notMastered += 1
byKp.set(kpId, e)
}
return Array.from(byKp.entries()).map(([kpId, e]) => ({
knowledgePointId: kpId,
knowledgePointName: e.name,
averageMastery: e.levels.length > 0 ? round2(e.levels.reduce((a, b) => a + b, 0) / e.levels.length) : 0,
masteredCount: e.mastered,
notMasteredCount: e.notMastered,
totalStudents: studentIds.length,
}))
})