Files
NextEdu/src/modules/homework/data-access.ts
SpecialX 682d385ee2 fix(dashboard): v3 审计修复 — 数据完整性、i18n、类型安全、死代码清理
P0 修复(严重):
- admin ContentRow 标签与值错配(stats.users→textbooks 等 6 处)
- admin/error.tsx 硬编码中文替换为 useTranslations
- UserGrowthChart 空数据时渲染 EmptyState(userGrowth/homeworkTrend 永远为空数组)

P1 修复(高):
- 新增 admin/dashboard 和 student/dashboard 的 loading.tsx + error.tsx
- 抽取 DashboardLoadingSkeleton 和 DashboardErrorFallback 共享组件,消除 5 套重复文件
- formatDate/formatLongDate 传入用户 locale(admin/teacher/student 共 6 个组件)
- 移除死代码:getCachedAdminDashboard、AvatarImage src={undefined}、TeacherStats isLoading prop
- filterTodaySchedule 改为泛型函数,消除 as 类型断言
- 辅助函数 getStatus/getDueUrgency 新增显式返回类型
- UserGrowthChart 新增 labelKey prop 区分用户增长/作业提交趋势标签

P2 修复(中):
- 4 个组件从客户端转为服务端组件(DashboardGreetingHeader、TeacherQuickActions、TeacherDashboardHeader、StudentDashboardHeader)
- Student dashboard 空状态新增 CTA(viewSchedule、viewAll)
- TeacherHomeworkCard 图标按钮新增 aria-label
- TeacherTodoCard 排序逻辑重写为可读的 if/return 模式

同步更新:
- docs/architecture/005_architecture_data.json 新增 DashboardLoadingSkeleton、DashboardErrorFallback 条目
- 新增 docs/architecture/audit/dashboard-audit-report-v3.md 审计报告
- dashboard.json 新增 6 个 i18n 键(textbooks/chapters/questions/exams/totalAssignments/totalSubmissions)
2026-06-22 18:36:46 +08:00

784 lines
29 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 = await db
.select({
assignmentId: homeworkAssignmentTargets.assignmentId,
targetCount: sql<number>`COUNT(*)`,
})
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.assignmentId, assignmentIds))
.groupBy(homeworkAssignmentTargets.assignmentId)
const targetCountByAssignmentId = new Map<string, number>()
for (const r of targetCountRows) targetCountByAssignmentId.set(r.assignmentId, Number(r.targetCount ?? 0))
const submittedCountRows = await 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)
const submittedCountByAssignmentId = new Map<string, number>()
for (const r of submittedCountRows) submittedCountByAssignmentId.set(r.assignmentId, Number(r.submittedCount ?? 0))
const gradedCountRows = await 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 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 [targetsRow] = await db
.select({ c: count() })
.from(homeworkAssignmentTargets)
.where(eq(homeworkAssignmentTargets.assignmentId, id))
const [submissionsRow] = await db
.select({ c: count() })
.from(homeworkSubmissions)
.where(eq(homeworkSubmissions.assignmentId, id))
const [submittedRow] = await db
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
.from(homeworkSubmissions)
.where(
and(eq(homeworkSubmissions.assignmentId, id), inArray(homeworkSubmissions.status, ["submitted", "graded"]))
)
const [gradedRow] = await 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: targetsRow?.c ?? 0,
submissionCount: submissionsRow?.c ?? 0,
submittedCount: submittedRow?.c ?? 0,
gradedCount: gradedRow?.c ?? 0,
createdAt: assignment.createdAt.toISOString(),
updatedAt: assignment.updatedAt.toISOString(),
}
})
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 = await db.query.homeworkAnswers.findMany({
where: eq(homeworkAnswers.submissionId, submissionId),
with: {
question: true,
},
})
const assignmentQ = await 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,
}
})
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"