Files
NextEdu/src/modules/homework/data-access.ts
SpecialX 036a2f2839 feat(exams,homework,proctoring): 长期问题修复与竞品差距补齐
P1-1 跨模块直查消除:
- homework/data-access-classes.ts 移除对 exams/subjects 表的 JOIN 直查
- 改为调用 exams/data-access.getExamSubjectIdMap + school/data-access.getSubjectNameMapByIds
- school/data-access.ts 新增 getSubjectNameMapByIds 批量科目名称映射函数

P1-2 as 断言消除(exam-mode-config.tsx):
- 移除全部 10 处 as 类型断言
- 改用 useFormContext 替代 Control prop,避免 Control<T> 不变型问题
- exam-form.tsx 调用方简化为 <ExamModeConfig />(已集成到考试表单)

P1-3 as 断言消除(proctoring-dashboard.tsx):
- 用类型守卫函数 isProctoringEventType + toProctoringEventTypes
  替代 Object.keys(...) as ProctoringEventType[] 断言

P0-竞品倒计时(对标智学网/猿题库):
- 新增 hooks/use-exam-countdown.ts 考试倒计时 Hook
- homework-take-view.tsx 集成限时/监考模式倒计时显示与到时自动提交
- data-access.ts 的 getStudentHomeworkTakeData 新增 examModeConfig + startedAt 字段
- types.ts 扩展 StudentHomeworkTakeData 类型
- i18n 补充 timedExam/timeRemaining/timeUpAutoSubmit 翻译键

架构文档同步:
- 004/005 更新 homework/proctoring/school/exams 模块导出与依赖关系
- 005 新增 homework.hooks.useExamCountdown 与 school.dataAccess.getSubjectNameMapByIds
- 005 依赖矩阵 homework→school 补充 getSubjectNameMapByIds

验证:tsc --noEmit 零错误,eslint 零错误(3 个预存 warning 无关)
2026-06-23 09:34:24 +08:00

1009 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import "server-only"
import { cache } from "react"
import { and, asc, count, desc, eq, gt, inArray, isNull, lt, lte, or, sql } from "drizzle-orm"
import { db } from "@/shared/db"
import {
homeworkAnswers,
homeworkAssignmentQuestions,
homeworkAssignmentTargets,
homeworkAssignments,
homeworkSubmissions,
} from "@/shared/db/schema"
import { getStudentIdsByClassId, getStudentIdsByClassIds } from "@/modules/classes/data-access"
import { getExamIdsByGradeIds, getExamSubjectIdMap, getExamForProctoringCrossModule } from "@/modules/exams/data-access"
import { getSubjectOptions } from "@/modules/school/data-access"
import type {
HomeworkAssignmentListItem,
HomeworkAssignmentReviewListItem,
HomeworkQuestionContent,
HomeworkAssignmentStatus,
HomeworkSubmissionDetails,
HomeworkSubmissionListItem,
HomeworkSubmissionStatus,
StudentHomeworkAssignmentListItem,
StudentHomeworkProgressStatus,
StudentHomeworkTakeData,
} from "./types"
import type { DataScope } from "@/shared/types/permissions"
export const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
const isHomeworkAssignmentStatus = (v: unknown): v is HomeworkAssignmentStatus =>
v === "draft" || v === "published" || v === "archived"
const toHomeworkAssignmentStatus = (v: string | null | undefined): HomeworkAssignmentStatus =>
isHomeworkAssignmentStatus(v) ? v : "draft"
const isHomeworkSubmissionStatus = (v: unknown): v is HomeworkSubmissionStatus =>
v === "started" || v === "submitted" || v === "graded"
const toHomeworkSubmissionStatus = (v: string | null | undefined): HomeworkSubmissionStatus =>
isHomeworkSubmissionStatus(v) ? v : "started"
const isHomeworkQuestionContent = (v: unknown): v is HomeworkQuestionContent =>
isRecord(v)
export const toQuestionContent = (v: unknown): HomeworkQuestionContent | null => {
if (!isHomeworkQuestionContent(v)) return null
return v
}
export const getAssignmentMaxScoreById = async (assignmentIds: string[]): Promise<Map<string, number>> => {
const ids = assignmentIds.filter((v) => v.trim().length > 0)
if (ids.length === 0) return new Map()
const rows = await db
.select({
assignmentId: homeworkAssignmentQuestions.assignmentId,
maxScore: sql<number>`COALESCE(SUM(${homeworkAssignmentQuestions.score}), 0)`,
})
.from(homeworkAssignmentQuestions)
.where(inArray(homeworkAssignmentQuestions.assignmentId, ids))
.groupBy(homeworkAssignmentQuestions.assignmentId)
const map = new Map<string, number>()
for (const r of rows) map.set(r.assignmentId, Number(r.maxScore ?? 0))
return map
}
export const getHomeworkAssignments = cache(async (params?: { creatorId?: string; ids?: string[]; classId?: string; scope?: DataScope }) => {
const conditions = []
if (params?.creatorId) conditions.push(eq(homeworkAssignments.creatorId, params.creatorId))
if (params?.ids && params.ids.length > 0) conditions.push(inArray(homeworkAssignments.id, params.ids))
if (params?.classId) {
const classStudentIds = await getStudentIdsByClassId(params.classId)
const targetAssignmentIds = await db
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, classStudentIds))
conditions.push(inArray(homeworkAssignments.id, targetAssignmentIds.map((t) => t.assignmentId)))
}
// Data scope filtering
if (params?.scope) {
if (params.scope.type === "owned") {
conditions.push(eq(homeworkAssignments.creatorId, params.scope.userId))
}
if (params.scope.type === "class_taught" && params.scope.classIds.length > 0) {
// Filter homework by assignments targeting students in teacher's classes
const classStudentIds = await getStudentIdsByClassIds(params.scope.classIds)
const targetAssignmentIds = await db
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, classStudentIds))
conditions.push(inArray(homeworkAssignments.id, targetAssignmentIds.map((t) => t.assignmentId)))
}
if (params.scope.type === "grade_managed" && params.scope.gradeIds.length > 0) {
// Homework links to exam via sourceExamId, exam has gradeId
const gradeExamIds = await getExamIdsByGradeIds(params.scope.gradeIds)
conditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
}
// "all" type: no filtering
}
const data = await db.query.homeworkAssignments.findMany({
where: conditions.length ? and(...conditions) : undefined,
orderBy: [desc(homeworkAssignments.createdAt)],
with: {
sourceExam: true,
},
})
if (data.length === 0) return []
const assignmentIds = data.map((a) => a.id)
const now = new Date()
// 并行查询:目标学生数 / 已提交数 / 已批改数 / 已批改平均分 / 逾期未提交学生集合
const [targetCountRows, submittedCountRows, gradedCountRows, avgScoreRows, submittedStudentRows] = await Promise.all([
db
.select({
assignmentId: homeworkAssignmentTargets.assignmentId,
targetCount: sql<number>`COUNT(*)`,
})
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.assignmentId, assignmentIds))
.groupBy(homeworkAssignmentTargets.assignmentId),
db
.select({
assignmentId: homeworkSubmissions.assignmentId,
submittedCount: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})`,
})
.from(homeworkSubmissions)
.where(
and(
inArray(homeworkSubmissions.assignmentId, assignmentIds),
inArray(homeworkSubmissions.status, ["submitted", "graded"])
)
)
.groupBy(homeworkSubmissions.assignmentId),
db
.select({
assignmentId: homeworkSubmissions.assignmentId,
gradedCount: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})`,
})
.from(homeworkSubmissions)
.where(and(inArray(homeworkSubmissions.assignmentId, assignmentIds), eq(homeworkSubmissions.status, "graded")))
.groupBy(homeworkSubmissions.assignmentId),
db
.select({
assignmentId: homeworkSubmissions.assignmentId,
avgScore: sql<number>`AVG(${homeworkSubmissions.score})`,
})
.from(homeworkSubmissions)
.where(
and(
inArray(homeworkSubmissions.assignmentId, assignmentIds),
eq(homeworkSubmissions.status, "graded")
)
)
.groupBy(homeworkSubmissions.assignmentId),
db
.select({
assignmentId: homeworkSubmissions.assignmentId,
studentId: homeworkSubmissions.studentId,
})
.from(homeworkSubmissions)
.where(
and(
inArray(homeworkSubmissions.assignmentId, assignmentIds),
inArray(homeworkSubmissions.status, ["submitted", "graded"])
)
),
])
const targetCountByAssignmentId = new Map<string, number>()
for (const r of targetCountRows) targetCountByAssignmentId.set(r.assignmentId, Number(r.targetCount ?? 0))
const submittedCountByAssignmentId = new Map<string, number>()
for (const r of submittedCountRows) submittedCountByAssignmentId.set(r.assignmentId, Number(r.submittedCount ?? 0))
const gradedCountByAssignmentId = new Map<string, number>()
for (const r of gradedCountRows) gradedCountByAssignmentId.set(r.assignmentId, Number(r.gradedCount ?? 0))
const avgScoreByAssignmentId = new Map<string, number | null>()
for (const r of avgScoreRows) {
const v = r.avgScore
avgScoreByAssignmentId.set(r.assignmentId, v === null ? null : Number(v))
}
// 已提交学生集合(按 assignmentId 分组),用于计算逾期未提交人数
const submittedStudentIdsByAssignmentId = new Map<string, Set<string>>()
for (const r of submittedStudentRows) {
let set = submittedStudentIdsByAssignmentId.get(r.assignmentId)
if (!set) {
set = new Set<string>()
submittedStudentIdsByAssignmentId.set(r.assignmentId, set)
}
set.add(r.studentId)
}
// 逾期未提交人数 = 目标学生数 - 已提交学生数(仅当 dueAt 已过时计算)
const computeOverdueCount = (assignmentId: string, dueAt: Date | null): number => {
if (!dueAt || dueAt > now) return 0
const targetCount = targetCountByAssignmentId.get(assignmentId) ?? 0
const submittedCount = submittedStudentIdsByAssignmentId.get(assignmentId)?.size ?? 0
return Math.max(0, targetCount - submittedCount)
}
return data.map((a) => {
const targetCount = targetCountByAssignmentId.get(a.id) ?? 0
const submittedCount = submittedCountByAssignmentId.get(a.id) ?? 0
const gradedCount = gradedCountByAssignmentId.get(a.id) ?? 0
const averageScore = avgScoreByAssignmentId.get(a.id) ?? null
const overdueCount = computeOverdueCount(a.id, a.dueAt)
const item: HomeworkAssignmentListItem = {
id: a.id,
sourceExamId: a.sourceExamId,
sourceExamTitle: a.sourceExam?.title ?? null,
title: a.title,
status: toHomeworkAssignmentStatus(a.status),
availableAt: a.availableAt ? a.availableAt.toISOString() : null,
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
allowLate: a.allowLate,
lateDueAt: a.lateDueAt ? a.lateDueAt.toISOString() : null,
maxAttempts: a.maxAttempts,
createdAt: a.createdAt.toISOString(),
updatedAt: a.updatedAt.toISOString(),
targetCount,
submittedCount,
gradedCount,
averageScore,
overdueCount,
}
return item
})
})
export const getHomeworkAssignmentReviewList = cache(async (params: { creatorId: string; scope?: DataScope }) => {
const creatorId = params.creatorId.trim()
if (!creatorId) return []
const conditions = [eq(homeworkAssignments.creatorId, creatorId)]
// Data scope filtering
if (params.scope) {
if (params.scope.type === "owned") {
// Already filtered by creatorId above
}
if (params.scope.type === "class_taught" && params.scope.classIds.length > 0) {
const classStudentIds = await getStudentIdsByClassIds(params.scope.classIds)
const targetAssignmentIds = await db
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, classStudentIds))
conditions.push(inArray(homeworkAssignments.id, targetAssignmentIds.map((t) => t.assignmentId)))
}
if (params.scope.type === "grade_managed" && params.scope.gradeIds.length > 0) {
const gradeExamIds = await getExamIdsByGradeIds(params.scope.gradeIds)
conditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
}
}
const assignments = await db.query.homeworkAssignments.findMany({
where: and(...conditions),
orderBy: [desc(homeworkAssignments.createdAt)],
with: { sourceExam: true },
})
if (assignments.length === 0) return []
const assignmentIds = assignments.map((a) => a.id)
const [targetCountRows, submittedCountRows, gradedCountRows] = await Promise.all([
db
.select({
assignmentId: homeworkAssignmentTargets.assignmentId,
targetCount: sql<number>`COUNT(*)`,
})
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.assignmentId, assignmentIds))
.groupBy(homeworkAssignmentTargets.assignmentId),
db
.select({
assignmentId: homeworkSubmissions.assignmentId,
submittedCount: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})`,
})
.from(homeworkSubmissions)
.where(
and(
inArray(homeworkSubmissions.assignmentId, assignmentIds),
inArray(homeworkSubmissions.status, ["submitted", "graded"])
)
)
.groupBy(homeworkSubmissions.assignmentId),
db
.select({
assignmentId: homeworkSubmissions.assignmentId,
gradedCount: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})`,
})
.from(homeworkSubmissions)
.where(and(inArray(homeworkSubmissions.assignmentId, assignmentIds), eq(homeworkSubmissions.status, "graded")))
.groupBy(homeworkSubmissions.assignmentId),
])
const targetCountByAssignmentId = new Map<string, number>()
for (const r of targetCountRows) targetCountByAssignmentId.set(r.assignmentId, Number(r.targetCount ?? 0))
const submittedCountByAssignmentId = new Map<string, number>()
for (const r of submittedCountRows) submittedCountByAssignmentId.set(r.assignmentId, Number(r.submittedCount ?? 0))
const gradedCountByAssignmentId = new Map<string, number>()
for (const r of gradedCountRows) gradedCountByAssignmentId.set(r.assignmentId, Number(r.gradedCount ?? 0))
return assignments.map((a) => {
const item: HomeworkAssignmentReviewListItem = {
id: a.id,
title: a.title,
status: toHomeworkAssignmentStatus(a.status),
sourceExamTitle: a.sourceExam?.title ?? null,
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
targetCount: targetCountByAssignmentId.get(a.id) ?? 0,
submittedCount: submittedCountByAssignmentId.get(a.id) ?? 0,
gradedCount: gradedCountByAssignmentId.get(a.id) ?? 0,
createdAt: a.createdAt.toISOString(),
}
return item
})
})
export const getHomeworkSubmissions = cache(async (params?: { assignmentId?: string; classId?: string; creatorId?: string; scope?: DataScope }) => {
const conditions = []
if (params?.assignmentId) conditions.push(eq(homeworkSubmissions.assignmentId, params.assignmentId))
if (params?.classId) {
const classStudentIds = await getStudentIdsByClassId(params.classId)
const targetAssignmentIds = await db
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, classStudentIds))
conditions.push(inArray(homeworkSubmissions.studentId, classStudentIds))
conditions.push(inArray(homeworkSubmissions.assignmentId, targetAssignmentIds.map((t) => t.assignmentId)))
}
if (params?.creatorId) {
const creatorAssignmentIds = db
.select({ assignmentId: homeworkAssignments.id })
.from(homeworkAssignments)
.where(eq(homeworkAssignments.creatorId, params.creatorId))
conditions.push(inArray(homeworkSubmissions.assignmentId, creatorAssignmentIds))
}
// Data scope filtering
if (params?.scope) {
if (params.scope.type === "owned") {
const creatorAssignmentIds = db
.select({ assignmentId: homeworkAssignments.id })
.from(homeworkAssignments)
.where(eq(homeworkAssignments.creatorId, params.scope.userId))
conditions.push(inArray(homeworkSubmissions.assignmentId, creatorAssignmentIds))
}
if (params.scope.type === "class_taught" && params.scope.classIds.length > 0) {
const classStudentIds = await getStudentIdsByClassIds(params.scope.classIds)
conditions.push(inArray(homeworkSubmissions.studentId, classStudentIds))
}
if (params.scope.type === "grade_managed" && params.scope.gradeIds.length > 0) {
const gradeExamIds = await getExamIdsByGradeIds(params.scope.gradeIds)
const gradeAssignmentIds = db
.select({ assignmentId: homeworkAssignments.id })
.from(homeworkAssignments)
.where(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
conditions.push(inArray(homeworkSubmissions.assignmentId, gradeAssignmentIds))
}
}
const data = await db.query.homeworkSubmissions.findMany({
where: conditions.length ? and(...conditions) : undefined,
orderBy: [desc(homeworkSubmissions.updatedAt)],
with: {
assignment: true,
student: true,
},
})
return data.map((s) => {
const item: HomeworkSubmissionListItem = {
id: s.id,
assignmentId: s.assignmentId,
assignmentTitle: s.assignment.title,
studentName: s.student.name || "Unknown",
submittedAt: s.submittedAt ? s.submittedAt.toISOString() : null,
score: s.score ?? null,
status: toHomeworkSubmissionStatus(s.status),
isLate: s.isLate,
}
return item
})
})
export const getHomeworkAssignmentById = cache(async (id: string, scope?: DataScope) => {
const assignment = await db.query.homeworkAssignments.findFirst({
where: eq(homeworkAssignments.id, id),
with: {
sourceExam: true,
},
})
if (!assignment) return null
// Data scope verification for single-item fetch
if (scope && scope.type !== "all") {
if (scope.type === "owned" && assignment.creatorId !== scope.userId) {
return null
}
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
// 快速作业(无 sourceExamId不归年级主任管辖直接拒绝
if (!assignment.sourceExamId) return null
const examIds = await getExamIdsByGradeIds(scope.gradeIds)
if (!examIds.includes(assignment.sourceExamId)) {
return null
}
}
if (scope.type === "class_taught" && scope.classIds.length > 0) {
const studentIds = await getStudentIdsByClassIds(scope.classIds)
if (studentIds.length > 0) {
const target = await db.query.homeworkAssignmentTargets.findFirst({
where: and(
eq(homeworkAssignmentTargets.assignmentId, id),
inArray(homeworkAssignmentTargets.studentId, studentIds)
),
})
if (!target) return null
} else {
return null
}
}
}
const [targetsRows, submissionsRows, submittedRows, gradedRows] = await Promise.all([
db
.select({ c: count() })
.from(homeworkAssignmentTargets)
.where(eq(homeworkAssignmentTargets.assignmentId, id)),
db
.select({ c: count() })
.from(homeworkSubmissions)
.where(eq(homeworkSubmissions.assignmentId, id)),
db
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
.from(homeworkSubmissions)
.where(
and(eq(homeworkSubmissions.assignmentId, id), inArray(homeworkSubmissions.status, ["submitted", "graded"]))
),
db
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
.from(homeworkSubmissions)
.where(and(eq(homeworkSubmissions.assignmentId, id), eq(homeworkSubmissions.status, "graded"))),
])
return {
id: assignment.id,
title: assignment.title,
description: assignment.description,
status: toHomeworkAssignmentStatus(assignment.status),
sourceExamId: assignment.sourceExamId,
sourceExamTitle: assignment.sourceExam?.title ?? null,
structure: assignment.structure,
availableAt: assignment.availableAt ? assignment.availableAt.toISOString() : null,
dueAt: assignment.dueAt ? assignment.dueAt.toISOString() : null,
allowLate: assignment.allowLate,
lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null,
maxAttempts: assignment.maxAttempts,
targetCount: targetsRows[0]?.c ?? 0,
submissionCount: submissionsRows[0]?.c ?? 0,
submittedCount: submittedRows[0]?.c ?? 0,
gradedCount: gradedRows[0]?.c ?? 0,
createdAt: assignment.createdAt.toISOString(),
updatedAt: assignment.updatedAt.toISOString(),
}
})
/**
* V3-8: 获取关联到指定考试的所有作业(跨模块读接口)
*
* 供 exams 模块的考试分析仪表盘调用,获取该考试派生的所有作业及其提交统计。
*/
export const getHomeworkAssignmentsByExamId = cache(async (examId: string): Promise<Array<{
id: string
title: string
status: string | null
targetCount: number
submittedCount: number
gradedCount: number
dueAt: string | null
}>> => {
const assignments = await db.query.homeworkAssignments.findMany({
where: eq(homeworkAssignments.sourceExamId, examId),
columns: { id: true, title: true, status: true, dueAt: true },
})
if (assignments.length === 0) return []
const assignmentIds = assignments.map((a) => a.id)
const [targetsRows, submittedRows, gradedRows] = await Promise.all([
db
.select({ assignmentId: homeworkAssignmentTargets.assignmentId, c: count() })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.assignmentId, assignmentIds))
.groupBy(homeworkAssignmentTargets.assignmentId),
db
.select({ assignmentId: homeworkSubmissions.assignmentId, c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
.from(homeworkSubmissions)
.where(
and(
inArray(homeworkSubmissions.assignmentId, assignmentIds),
inArray(homeworkSubmissions.status, ["submitted", "graded"])
)
)
.groupBy(homeworkSubmissions.assignmentId),
db
.select({ assignmentId: homeworkSubmissions.assignmentId, c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
.from(homeworkSubmissions)
.where(
and(
inArray(homeworkSubmissions.assignmentId, assignmentIds),
eq(homeworkSubmissions.status, "graded")
)
)
.groupBy(homeworkSubmissions.assignmentId),
])
const targetMap = new Map(targetsRows.map((r) => [r.assignmentId, Number(r.c)]))
const submittedMap = new Map(submittedRows.map((r) => [r.assignmentId, Number(r.c)]))
const gradedMap = new Map(gradedRows.map((r) => [r.assignmentId, Number(r.c)]))
return assignments.map((a) => ({
id: a.id,
title: a.title,
status: a.status,
targetCount: targetMap.get(a.id) ?? 0,
submittedCount: submittedMap.get(a.id) ?? 0,
gradedCount: gradedMap.get(a.id) ?? 0,
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
}))
})
/**
* V3-8: 获取指定考试所有作业的已批改提交(跨模块读接口)
*
* 供 exams 模块的考试分析仪表盘调用,获取学生姓名、分数、答案内容用于统计分析。
*/
export const getGradedSubmissionsByExamId = cache(async (examId: string): Promise<Array<{
submissionId: string
assignmentId: string
studentId: string
studentName: string
score: number
answers: Array<{ questionId: string; score: number; answerContent: unknown }>
}>> => {
const assignments = await db.query.homeworkAssignments.findMany({
where: eq(homeworkAssignments.sourceExamId, examId),
columns: { id: true },
})
if (assignments.length === 0) return []
const assignmentIds = assignments.map((a) => a.id)
const submissions = await db.query.homeworkSubmissions.findMany({
where: and(
inArray(homeworkSubmissions.assignmentId, assignmentIds),
eq(homeworkSubmissions.status, "graded")
),
with: {
student: true,
answers: {
columns: { questionId: true, score: true, answerContent: true },
},
},
orderBy: (s, { desc }) => [desc(s.updatedAt)],
})
// Deduplicate: keep only the latest submission per student
const latestByStudent = new Map<string, (typeof submissions)[number]>()
for (const s of submissions) {
if (!latestByStudent.has(s.studentId)) latestByStudent.set(s.studentId, s)
}
return Array.from(latestByStudent.values()).map((s) => ({
submissionId: s.id,
assignmentId: s.assignmentId,
studentId: s.studentId,
studentName: s.student.name || "Unknown",
score: s.score ?? 0,
answers: s.answers.map((a) => ({
questionId: a.questionId,
score: a.score ?? 0,
answerContent: a.answerContent,
})),
}))
})
export const getHomeworkSubmissionDetails = cache(async (submissionId: string): Promise<HomeworkSubmissionDetails | null> => {
const submission = await db.query.homeworkSubmissions.findFirst({
where: eq(homeworkSubmissions.id, submissionId),
with: {
student: true,
assignment: true,
},
})
if (!submission) return null
const [answers, assignmentQ] = await Promise.all([
db.query.homeworkAnswers.findMany({
where: eq(homeworkAnswers.submissionId, submissionId),
with: {
question: true,
},
}),
db.query.homeworkAssignmentQuestions.findMany({
where: eq(homeworkAssignmentQuestions.assignmentId, submission.assignmentId),
orderBy: [desc(homeworkAssignmentQuestions.order)],
}),
])
const answersWithDetails = answers
.map((ans) => {
const aqRel = assignmentQ.find((q) => q.questionId === ans.questionId)
return {
id: ans.id,
questionId: ans.questionId,
questionContent: toQuestionContent(ans.question.content),
questionType: ans.question.type,
maxScore: aqRel?.score || 0,
studentAnswer: ans.answerContent,
score: ans.score,
feedback: ans.feedback,
order: aqRel?.order || 0,
}
})
.sort((a, b) => a.order - b.order)
// P1-8: Optimize adjacent submission navigation using LIMIT 1 queries
// instead of fetching all submission IDs for the assignment.
// Original ordering is desc(updatedAt): "previous" = newer, "next" = older.
const currentUpdatedAt = submission.updatedAt
const [prevSubmission, nextSubmission] = await Promise.all([
// Previous (newer): closest submission with updatedAt > current
db.query.homeworkSubmissions.findFirst({
where: and(
eq(homeworkSubmissions.assignmentId, submission.assignmentId),
gt(homeworkSubmissions.updatedAt, currentUpdatedAt)
),
orderBy: [asc(homeworkSubmissions.updatedAt)],
columns: { id: true },
}),
// Next (older): closest submission with updatedAt < current
db.query.homeworkSubmissions.findFirst({
where: and(
eq(homeworkSubmissions.assignmentId, submission.assignmentId),
lt(homeworkSubmissions.updatedAt, currentUpdatedAt)
),
orderBy: [desc(homeworkSubmissions.updatedAt)],
columns: { id: true },
}),
])
const prevSubmissionId = prevSubmission?.id ?? null
const nextSubmissionId = nextSubmission?.id ?? null
return {
id: submission.id,
assignmentId: submission.assignmentId,
assignmentTitle: submission.assignment.title,
studentName: submission.student.name || "Unknown",
submittedAt: submission.submittedAt ? submission.submittedAt.toISOString() : null,
status: toHomeworkSubmissionStatus(submission.status),
totalScore: submission.score,
answers: answersWithDetails,
prevSubmissionId,
nextSubmissionId,
}
})
/**
* V3-9: 获取学生在指定作业的最新提交结果(用于提交后反馈页)
*
* 查找学生最近一次已提交/已批改的 submission返回完整详情含答案。
*/
export const getStudentSubmissionResult = cache(async (
assignmentId: string,
studentId: string
): Promise<HomeworkSubmissionDetails | null> => {
const latestSubmission = await db.query.homeworkSubmissions.findFirst({
where: and(
eq(homeworkSubmissions.assignmentId, assignmentId),
eq(homeworkSubmissions.studentId, studentId),
inArray(homeworkSubmissions.status, ["submitted", "graded"])
),
orderBy: [desc(homeworkSubmissions.updatedAt)],
columns: { id: true },
})
if (!latestSubmission) return null
return getHomeworkSubmissionDetails(latestSubmission.id)
})
/**
* V3-11: 获取学生的考试结果列表(供家长端展示)
*
* 查找学生所有已批改的、关联到考试的作业提交,
* 返回考试标题、科目、分数、提交时间等。
*/
export const getStudentExamResults = cache(async (studentId: string): Promise<Array<{
submissionId: string
examId: string
examTitle: string
assignmentId: string
assignmentTitle: string
score: number
maxScore: number
submittedAt: string | null
status: string
}>> => {
const submissions = await db.query.homeworkSubmissions.findMany({
where: and(
eq(homeworkSubmissions.studentId, studentId),
eq(homeworkSubmissions.status, "graded")
),
with: {
assignment: {
with: { sourceExam: true },
},
},
orderBy: [desc(homeworkSubmissions.updatedAt)],
limit: 50,
})
// Filter to only exam-linked submissions, deduplicate by examId
const latestByExamId = new Map<string, (typeof submissions)[number]>()
for (const s of submissions) {
const examId = s.assignment.sourceExamId
if (!examId) continue
if (!latestByExamId.has(examId)) latestByExamId.set(examId, s)
}
const examIds = Array.from(latestByExamId.keys())
if (examIds.length === 0) return []
// Get max scores for each assignment
const assignmentIds = Array.from(latestByExamId.values()).map((s) => s.assignmentId)
const maxScoreMap = await getAssignmentMaxScoreById(assignmentIds)
return Array.from(latestByExamId.entries()).map(([examId, s]) => ({
submissionId: s.id,
examId,
examTitle: s.assignment.sourceExam?.title ?? s.assignment.title,
assignmentId: s.assignmentId,
assignmentTitle: s.assignment.title,
score: s.score ?? 0,
maxScore: maxScoreMap.get(s.assignmentId) ?? 0,
submittedAt: s.submittedAt ? s.submittedAt.toISOString() : null,
status: s.status ?? "graded",
}))
})
const toStudentProgressStatus = (v: string | null | undefined): StudentHomeworkProgressStatus => {
if (v === "started") return "in_progress"
if (v === "submitted") return "submitted"
if (v === "graded") return "graded"
return "not_started"
}
export const getStudentHomeworkAssignments = cache(async (studentId: string): Promise<StudentHomeworkAssignmentListItem[]> => {
const now = new Date()
const targetAssignmentIds = db
.select({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(eq(homeworkAssignmentTargets.studentId, studentId))
const assignments = await db
.select({
id: homeworkAssignments.id,
title: homeworkAssignments.title,
sourceExamId: homeworkAssignments.sourceExamId,
dueAt: homeworkAssignments.dueAt,
availableAt: homeworkAssignments.availableAt,
maxAttempts: homeworkAssignments.maxAttempts,
createdAt: homeworkAssignments.createdAt,
})
.from(homeworkAssignments)
.where(
and(
eq(homeworkAssignments.status, "published"),
inArray(homeworkAssignments.id, targetAssignmentIds),
or(isNull(homeworkAssignments.availableAt), lte(homeworkAssignments.availableAt, now))
)
)
.orderBy(desc(homeworkAssignments.dueAt), desc(homeworkAssignments.createdAt))
if (assignments.length === 0) return []
// Fetch subject names via cross-module interfaces
// 快速作业无 sourceExamId过滤 null 后再查询科目映射
const examIds = assignments
.map((a) => a.sourceExamId)
.filter((id): id is string => id !== null)
const [examSubjectIdMap, subjectOptions] = await Promise.all([
getExamSubjectIdMap(examIds),
getSubjectOptions(),
])
const subjectNameById = new Map<string, string>()
for (const s of subjectOptions) subjectNameById.set(s.id, s.name)
const assignmentIds = assignments.map((a) => a.id)
const submissions = await db.query.homeworkSubmissions.findMany({
where: and(eq(homeworkSubmissions.studentId, studentId), inArray(homeworkSubmissions.assignmentId, assignmentIds)),
orderBy: [desc(homeworkSubmissions.updatedAt)],
})
const attemptsByAssignmentId = new Map<string, number>()
const latestByAssignmentId = new Map<string, (typeof submissions)[number]>()
const latestSubmittedByAssignmentId = new Map<string, (typeof submissions)[number]>()
for (const s of submissions) {
attemptsByAssignmentId.set(s.assignmentId, (attemptsByAssignmentId.get(s.assignmentId) ?? 0) + 1)
if (!latestByAssignmentId.has(s.assignmentId)) latestByAssignmentId.set(s.assignmentId, s)
if (s.status === "submitted" || s.status === "graded") {
if (!latestSubmittedByAssignmentId.has(s.assignmentId)) latestSubmittedByAssignmentId.set(s.assignmentId, s)
}
}
return assignments.map((a) => {
const latest = latestSubmittedByAssignmentId.get(a.id) ?? latestByAssignmentId.get(a.id) ?? null
const attemptsUsed = attemptsByAssignmentId.get(a.id) ?? 0
const subjectId = a.sourceExamId ? (examSubjectIdMap.get(a.sourceExamId) ?? null) : null
const subjectName = subjectId ? subjectNameById.get(subjectId) ?? null : null
const item: StudentHomeworkAssignmentListItem = {
id: a.id,
title: a.title,
subjectName: subjectName ?? null,
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
availableAt: a.availableAt ? a.availableAt.toISOString() : null,
maxAttempts: a.maxAttempts,
attemptsUsed,
progressStatus: toStudentProgressStatus(latest?.status),
latestSubmissionId: latest?.id ?? null,
latestSubmittedAt: latest?.submittedAt ? latest.submittedAt.toISOString() : null,
latestScore: latest?.score ?? null,
}
return item
})
})
export const getStudentHomeworkTakeData = cache(async (assignmentId: string, studentId: string): Promise<StudentHomeworkTakeData | null> => {
const target = await db.query.homeworkAssignmentTargets.findFirst({
where: and(eq(homeworkAssignmentTargets.assignmentId, assignmentId), eq(homeworkAssignmentTargets.studentId, studentId)),
})
if (!target) return null
const assignment = await db.query.homeworkAssignments.findFirst({
where: eq(homeworkAssignments.id, assignmentId),
})
if (!assignment) return null
if (assignment.status !== "published") return null
const now = new Date()
if (assignment.availableAt && assignment.availableAt > now) return null
const startedSubmission = await db.query.homeworkSubmissions.findFirst({
where: and(
eq(homeworkSubmissions.assignmentId, assignmentId),
eq(homeworkSubmissions.studentId, studentId),
eq(homeworkSubmissions.status, "started")
),
orderBy: (s, { desc }) => [desc(s.createdAt)],
})
const latestSubmission =
startedSubmission ??
(await db.query.homeworkSubmissions.findFirst({
where: and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.studentId, studentId)),
orderBy: (s, { desc }) => [desc(s.createdAt)],
}))
const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({
where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
with: {
question: {
with: {
knowledgePoints: {
with: {
knowledgePoint: true
}
}
}
}
},
orderBy: (q, { asc }) => [asc(q.order)],
})
const answersByQuestionId = new Map<string, { answer: unknown; score: number | null; feedback: string | null }>()
if (latestSubmission) {
const answers = await db.query.homeworkAnswers.findMany({
where: eq(homeworkAnswers.submissionId, latestSubmission.id),
})
for (const ans of answers) {
answersByQuestionId.set(ans.questionId, {
answer: ans.answerContent,
score: ans.score,
feedback: ans.feedback,
})
}
}
// P0-竞品修复:获取考试模式配置(仅当作业关联考试时)
let examModeConfig: StudentHomeworkTakeData["examModeConfig"] = null
if (assignment.sourceExamId) {
const examConfig = await getExamForProctoringCrossModule(assignment.sourceExamId)
if (examConfig) {
examModeConfig = {
examMode: (examConfig.examMode === "timed" || examConfig.examMode === "proctored" || examConfig.examMode === "homework")
? examConfig.examMode
: "homework",
durationMinutes: examConfig.durationMinutes,
shuffleQuestions: examConfig.shuffleQuestions ?? false,
allowLateStart: examConfig.allowLateStart ?? false,
lateStartGraceMinutes: examConfig.lateStartGraceMinutes ?? 0,
antiCheatEnabled: examConfig.antiCheatEnabled ?? false,
}
}
}
return {
assignment: {
id: assignment.id,
title: assignment.title,
description: assignment.description,
availableAt: assignment.availableAt ? assignment.availableAt.toISOString() : null,
dueAt: assignment.dueAt ? assignment.dueAt.toISOString() : null,
allowLate: assignment.allowLate,
lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null,
maxAttempts: assignment.maxAttempts,
},
examModeConfig,
submission: latestSubmission
? {
id: latestSubmission.id,
status: toHomeworkSubmissionStatus(latestSubmission.status),
attemptNo: latestSubmission.attemptNo,
submittedAt: latestSubmission.submittedAt ? latestSubmission.submittedAt.toISOString() : null,
score: latestSubmission.score ?? null,
startedAt: latestSubmission.createdAt ? latestSubmission.createdAt.toISOString() : null,
}
: null,
questions: assignmentQuestions.map((aq) => {
const saved = answersByQuestionId.get(aq.questionId)
// Use optional chaining or fallback to empty array if knowledgePoints is not loaded/undefined
const kps = aq.question.knowledgePoints ?? []
return {
questionId: aq.questionId,
questionType: aq.question.type,
questionContent: toQuestionContent(aq.question.content),
maxScore: aq.score ?? 0,
order: aq.order ?? 0,
savedAnswer: saved?.answer ?? null,
score: saved?.score ?? null,
feedback: saved?.feedback ?? null,
knowledgePoints: kps.map((kp) => ({
id: kp.knowledgePoint.id,
name: kp.knowledgePoint.name,
})),
}
}),
}
})
// Re-export stats functions for backward compatibility
// New code should import directly from "./stats-service"
export {
getTeacherGradeTrends,
getHomeworkAssignmentAnalytics,
getStudentDashboardGrades,
getHomeworkDashboardStats,
} from "./stats-service"
export type { HomeworkDashboardStats } from "./stats-service"