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
主要变更: - 新增 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)
258 lines
9.6 KiB
TypeScript
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,
|
|
}))
|
|
})
|