- Update classes data-access (invitations, main) for invitation management - Update course-plans actions, data-access, and types - Update diagnostic data-access for report queries - Update questions data-access for question bank queries - Update settings actions, ai-provider-settings-card, data-access, and types - Update student course-filters, student-courses-view, student-schedule-filters, student-schedule-view - Update layout app-sidebar, site-header, and navigation config
478 lines
16 KiB
TypeScript
478 lines
16 KiB
TypeScript
import "server-only"
|
||
|
||
import { cache } from "react"
|
||
import { and, 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, getExamWithQuestionsForHomework } from "@/modules/exams/data-access"
|
||
import { getHomeworkSubmissionWithAnswersForMastery } from "@/modules/homework/data-access-error-collection"
|
||
import { getKnowledgePointsForQuestions } from "@/modules/questions/data-access"
|
||
import { getUserIdsByGradeId, getUserNamesByIds } from "@/modules/users/data-access"
|
||
|
||
import {
|
||
aggregateClassMastery,
|
||
buildClassMasterySummary,
|
||
buildStudentMasterySummary,
|
||
computeKpStats,
|
||
computeMasteryLevel,
|
||
serializeMasteryWithKp,
|
||
type RawClassMasteryRow,
|
||
type RawMasteryWithKpRow,
|
||
} from "./stats-service"
|
||
import type {
|
||
ClassMasterySummary,
|
||
KnowledgePointStat,
|
||
MasteryWithKnowledgePoint,
|
||
StudentMasterySummary,
|
||
} from "./types"
|
||
|
||
/** 获取学生在所有知识点的掌握度(含知识点名称) */
|
||
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) =>
|
||
serializeMasteryWithKp({
|
||
mastery: r.mastery,
|
||
kpName: r.kpName,
|
||
kpDescription: r.kpDescription,
|
||
} satisfies RawMasteryWithKpRow),
|
||
)
|
||
})
|
||
|
||
/** 获取学生掌握度摘要(含强项/弱项分析) */
|
||
export const getStudentMasterySummary = cache(async (studentId: string): Promise<StudentMasterySummary | null> => {
|
||
// P3-18 修复:用户名查询与掌握度查询相互独立,并行执行
|
||
const [userMap, allMastery] = await Promise.all([
|
||
getUserNamesByIds([studentId]),
|
||
getStudentMastery(studentId),
|
||
])
|
||
const student = userMap.get(studentId)
|
||
if (!student) return null
|
||
|
||
return buildStudentMasterySummary(studentId, student.name ?? "Unknown", 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 existingRows = await db
|
||
.select()
|
||
.from(knowledgePointMastery)
|
||
.where(
|
||
and(
|
||
eq(knowledgePointMastery.studentId, submission.studentId),
|
||
inArray(knowledgePointMastery.knowledgePointId, Array.from(kpStats.keys())),
|
||
),
|
||
)
|
||
|
||
const existingByKp = new Map<string, { total: number; correct: number }>()
|
||
for (const row of existingRows) {
|
||
existingByKp.set(row.knowledgePointId, {
|
||
total: row.totalQuestions,
|
||
correct: row.correctQuestions,
|
||
})
|
||
}
|
||
|
||
const now = new Date()
|
||
// 使用事务保证多个 upsert 的原子性
|
||
await db.transaction(async (tx) => {
|
||
await Promise.all(
|
||
Array.from(kpStats.entries()).map(async ([kpId, stat]) => {
|
||
const existing = existingByKp.get(kpId)
|
||
const totalQuestions = (existing?.total ?? 0) + stat.total
|
||
const correctQuestions = (existing?.correct ?? 0) + stat.correct
|
||
const masteryLevel = computeMasteryLevel(correctQuestions, totalQuestions)
|
||
await tx
|
||
.insert(knowledgePointMastery)
|
||
.values({
|
||
studentId: submission.studentId,
|
||
knowledgePointId: kpId,
|
||
masteryLevel: String(masteryLevel),
|
||
totalQuestions,
|
||
correctQuestions,
|
||
lastAssessedAt: now,
|
||
})
|
||
.onDuplicateKeyUpdate({
|
||
set: {
|
||
masteryLevel: String(masteryLevel),
|
||
totalQuestions,
|
||
correctQuestions,
|
||
lastAssessedAt: now,
|
||
updatedAt: now,
|
||
},
|
||
})
|
||
}),
|
||
)
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 从作业提交更新掌握度(累积模式)。
|
||
*
|
||
* 与 updateMasteryFromSubmission 类似,但数据来源是作业提交而非考试提交。
|
||
* 通过 homework 模块的跨模块接口获取作业提交的答案数据,避免直查 homeworkSubmissions 表。
|
||
*
|
||
* @param submissionId 作业提交 ID
|
||
*/
|
||
export async function updateMasteryFromHomeworkSubmission(submissionId: string): Promise<void> {
|
||
const submission = await getHomeworkSubmissionWithAnswersForMastery(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 existingRows = await db
|
||
.select()
|
||
.from(knowledgePointMastery)
|
||
.where(
|
||
and(
|
||
eq(knowledgePointMastery.studentId, submission.studentId),
|
||
inArray(knowledgePointMastery.knowledgePointId, Array.from(kpStats.keys())),
|
||
),
|
||
)
|
||
|
||
const existingByKp = new Map<string, { total: number; correct: number }>()
|
||
for (const row of existingRows) {
|
||
existingByKp.set(row.knowledgePointId, {
|
||
total: row.totalQuestions,
|
||
correct: row.correctQuestions,
|
||
})
|
||
}
|
||
|
||
const now = new Date()
|
||
// 使用事务保证多个 upsert 的原子性
|
||
await db.transaction(async (tx) => {
|
||
await Promise.all(
|
||
Array.from(kpStats.entries()).map(async ([kpId, stat]) => {
|
||
const existing = existingByKp.get(kpId)
|
||
const totalQuestions = (existing?.total ?? 0) + stat.total
|
||
const correctQuestions = (existing?.correct ?? 0) + stat.correct
|
||
const masteryLevel = computeMasteryLevel(correctQuestions, totalQuestions)
|
||
await tx
|
||
.insert(knowledgePointMastery)
|
||
.values({
|
||
studentId: submission.studentId,
|
||
knowledgePointId: kpId,
|
||
masteryLevel: String(masteryLevel),
|
||
totalQuestions,
|
||
correctQuestions,
|
||
lastAssessedAt: now,
|
||
})
|
||
.onDuplicateKeyUpdate({
|
||
set: {
|
||
masteryLevel: String(masteryLevel),
|
||
totalQuestions,
|
||
correctQuestions,
|
||
lastAssessedAt: now,
|
||
updatedAt: now,
|
||
},
|
||
})
|
||
}),
|
||
)
|
||
})
|
||
}
|
||
|
||
/**
|
||
* v3-P1-5:从手动录入的成绩更新掌握度。
|
||
*
|
||
* 当教师手动录入关联了 examId 的成绩时,根据得分率(score/fullScore)
|
||
* 更新该考试涉及的所有知识点的掌握度。采用累积模式,与 updateMasteryFromSubmission 一致。
|
||
*
|
||
* 注意:此函数假设学生在所有知识点上的掌握度等于整体得分率,
|
||
* 这是一种近似(无法区分知识点级别的强弱),适用于无题目级别答案的场景。
|
||
*/
|
||
export async function updateMasteryFromExamScore(
|
||
studentId: string,
|
||
examId: string,
|
||
score: number,
|
||
fullScore: number,
|
||
): Promise<void> {
|
||
if (fullScore <= 0) return
|
||
|
||
// 获取考试的所有题目
|
||
const examWithQuestions = await getExamWithQuestionsForHomework(examId)
|
||
if (!examWithQuestions || examWithQuestions.questions.length === 0) return
|
||
|
||
// 获取题目关联的知识点
|
||
const questionIds = examWithQuestions.questions.map((q) => q.questionId)
|
||
const kpMap = await getKnowledgePointsForQuestions(questionIds)
|
||
|
||
// 收集所有涉及的知识点 ID
|
||
const kpIds = new Set<string>()
|
||
for (const links of kpMap.values()) {
|
||
for (const link of links) {
|
||
kpIds.add(link.knowledgePointId)
|
||
}
|
||
}
|
||
|
||
if (kpIds.size === 0) return
|
||
|
||
// 计算得分率作为掌握度
|
||
const masteryLevel = computeMasteryLevel(score, fullScore)
|
||
|
||
// 读取已有掌握度记录,累积计算
|
||
const existingRows = await db
|
||
.select()
|
||
.from(knowledgePointMastery)
|
||
.where(
|
||
and(
|
||
eq(knowledgePointMastery.studentId, studentId),
|
||
inArray(knowledgePointMastery.knowledgePointId, Array.from(kpIds)),
|
||
),
|
||
)
|
||
|
||
const existingByKp = new Map<string, { total: number; correct: number }>()
|
||
for (const row of existingRows) {
|
||
existingByKp.set(row.knowledgePointId, {
|
||
total: row.totalQuestions,
|
||
correct: row.correctQuestions,
|
||
})
|
||
}
|
||
|
||
const now = new Date()
|
||
// 使用事务保证多个 upsert 的原子性
|
||
await db.transaction(async (tx) => {
|
||
await Promise.all(
|
||
Array.from(kpIds).map(async (kpId) => {
|
||
const existing = existingByKp.get(kpId)
|
||
// 累积:将本次成绩视为 1 道题,得分率作为掌握度
|
||
const totalQuestions = (existing?.total ?? 0) + 1
|
||
const correctQuestions = (existing?.correct ?? 0) + Math.round(masteryLevel / 100)
|
||
const newMasteryLevel = computeMasteryLevel(correctQuestions, totalQuestions)
|
||
await tx
|
||
.insert(knowledgePointMastery)
|
||
.values({
|
||
studentId,
|
||
knowledgePointId: kpId,
|
||
masteryLevel: String(newMasteryLevel),
|
||
totalQuestions,
|
||
correctQuestions,
|
||
lastAssessedAt: now,
|
||
})
|
||
.onDuplicateKeyUpdate({
|
||
set: {
|
||
masteryLevel: String(newMasteryLevel),
|
||
totalQuestions,
|
||
correctQuestions,
|
||
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 rawRows: RawClassMasteryRow[] = masteryRows.map((r) => ({
|
||
mastery: {
|
||
studentId: r.mastery.studentId,
|
||
knowledgePointId: r.mastery.knowledgePointId,
|
||
masteryLevel: r.mastery.masteryLevel,
|
||
},
|
||
kpName: r.kpName,
|
||
}))
|
||
|
||
return buildClassMasterySummary(classId, className, students, rawRows)
|
||
})
|
||
|
||
/** 获取知识点统计(按班级或年级聚合) */
|
||
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 rawRows: RawClassMasteryRow[] = masteryRows.map((r) => ({
|
||
mastery: {
|
||
studentId: r.mastery.studentId,
|
||
knowledgePointId: r.mastery.knowledgePointId,
|
||
masteryLevel: r.mastery.masteryLevel,
|
||
},
|
||
kpName: r.kpName,
|
||
}))
|
||
|
||
const { byKp } = aggregateClassMastery(rawRows, studentIds)
|
||
return computeKpStats(byKp)
|
||
})
|
||
|
||
/**
|
||
* v3-P2-5: 获取班级学生在指定知识点上的掌握度列表。
|
||
*
|
||
* 用于"按知识点筛选学生"功能:教师选择某个知识点后,列出班级所有学生
|
||
* 在该知识点上的掌握度,便于针对性辅导。掌握度低于阈值(默认 60)的学生
|
||
* 排在前面并标记为"需关注"。
|
||
*/
|
||
export const getClassStudentsByKnowledgePoint = cache(
|
||
async (
|
||
classId: string,
|
||
knowledgePointId: string,
|
||
options?: { threshold?: number }
|
||
): Promise<
|
||
Array<{
|
||
studentId: string
|
||
studentName: string
|
||
masteryLevel: number
|
||
totalQuestions: number
|
||
correctQuestions: number
|
||
lastAssessedAt: string | null
|
||
needsAttention: boolean
|
||
}>
|
||
> => {
|
||
const threshold = options?.threshold ?? 60
|
||
const studentIds = await getActiveStudentIdsByClassId(classId)
|
||
if (studentIds.length === 0) return []
|
||
|
||
const [userMap, masteryRows] = await Promise.all([
|
||
getUserNamesByIds(studentIds),
|
||
db
|
||
.select({
|
||
mastery: knowledgePointMastery,
|
||
})
|
||
.from(knowledgePointMastery)
|
||
.where(
|
||
and(
|
||
eq(knowledgePointMastery.knowledgePointId, knowledgePointId),
|
||
inArray(knowledgePointMastery.studentId, studentIds),
|
||
),
|
||
),
|
||
])
|
||
|
||
const masteryByStudent = new Map<string, {
|
||
masteryLevel: number
|
||
totalQuestions: number
|
||
correctQuestions: number
|
||
lastAssessedAt: Date | null
|
||
}>()
|
||
for (const row of masteryRows) {
|
||
masteryByStudent.set(row.mastery.studentId, {
|
||
masteryLevel: Number(row.mastery.masteryLevel) || 0,
|
||
totalQuestions: row.mastery.totalQuestions,
|
||
correctQuestions: row.mastery.correctQuestions,
|
||
lastAssessedAt: row.mastery.lastAssessedAt,
|
||
})
|
||
}
|
||
|
||
const result = studentIds.map((id) => {
|
||
const info = userMap.get(id)
|
||
const mastery = masteryByStudent.get(id)
|
||
const masteryLevel = mastery?.masteryLevel ?? 0
|
||
return {
|
||
studentId: id,
|
||
studentName: info?.name ?? "Unknown",
|
||
masteryLevel,
|
||
totalQuestions: mastery?.totalQuestions ?? 0,
|
||
correctQuestions: mastery?.correctQuestions ?? 0,
|
||
lastAssessedAt: mastery?.lastAssessedAt
|
||
? mastery.lastAssessedAt.toISOString()
|
||
: null,
|
||
needsAttention: masteryLevel < threshold,
|
||
}
|
||
})
|
||
|
||
// 需关注的学生排前面,相同关注状态按掌握度升序
|
||
result.sort((a, b) => {
|
||
if (a.needsAttention !== b.needsAttention) {
|
||
return a.needsAttention ? -1 : 1
|
||
}
|
||
return a.masteryLevel - b.masteryLevel
|
||
})
|
||
|
||
return result
|
||
}
|
||
)
|