Files
NextEdu/src/modules/homework/data-access.ts
SpecialX a60105455e feat(exams,homework,parent): V3 审计深度修复 — 批量批改/考试分析/提交反馈/家长视图/移动端优化
V3-5: exam-actions.tsx 集成 useExamHomeworkFeatures hook,按角色控制菜单项可见性
V3-7: 批量批改 — 新增 batchAutoGradeSubmissions data-access + Server Action + HomeworkBatchGradingView 组件
V3-8: 考试分析仪表盘 — 新增 getExamAnalytics stats-service + ExamAnalyticsDashboard 组件 + /teacher/exams/[id]/analytics 路由
V3-9: 提交后即时反馈页 — 新增 HomeworkSubmissionResult 组件 + /student/learning/assignments/[id]/result 路由
V3-11: 家长考试详情 — 新增 ChildExamDetail 组件 + getStudentExamResults data-access + child-detail-panel exams Tab
V3-12: 移动端触控优化 — 题目导航与考试操作按钮 44px 最小触控目标

修复: instrumentation.ts 适配器补全 questionCount/averageScore/overdueCount 字段
修复: exam-homework-port.ts 类型导入对齐 ExamWithQuestionsForHomework
修复: trend-line-chart.tsx 数据类型允许 undefined(classAverage 可选场景)

同步更新 004/005 架构文档
2026-06-23 01:06:27 +08:00

989 lines
36 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 } 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,
})
}
}
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,
},
submission: latestSubmission
? {
id: latestSubmission.id,
status: toHomeworkSubmissionStatus(latestSubmission.status),
attemptNo: latestSubmission.attemptNo,
submittedAt: latestSubmission.submittedAt ? latestSubmission.submittedAt.toISOString() : null,
score: latestSubmission.score ?? 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"