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 架构文档
This commit is contained in:
@@ -3,9 +3,11 @@
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { handleActionError, safeJsonParse, safeParseDate } from "@/shared/lib/action-utils"
|
||||
import { trackExamEvent } from "@/shared/lib/track-event"
|
||||
|
||||
import { CreateHomeworkAssignmentSchema, GradeHomeworkSchema } from "./schema"
|
||||
import {
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
markHomeworkSubmitted,
|
||||
saveHomeworkAnswer,
|
||||
startHomeworkSubmission,
|
||||
batchAutoGradeSubmissions,
|
||||
} from "./data-access-write"
|
||||
|
||||
const parseStudentIds = (raw: string): string[] => {
|
||||
@@ -77,7 +80,11 @@ export async function createHomeworkAssignmentAction(
|
||||
|
||||
let exam: Awaited<ReturnType<typeof getExamWithQuestionsForHomework>> = null
|
||||
if (!isQuickAssignment) {
|
||||
const examData = await getExamWithQuestionsForHomework(input.sourceExamId!)
|
||||
const sourceExamId = input.sourceExamId
|
||||
if (!sourceExamId) {
|
||||
return { success: false, message: "sourceExamId is required for exam mode" }
|
||||
}
|
||||
const examData = await getExamWithQuestionsForHomework(sourceExamId)
|
||||
if (!examData) return { success: false, message: "Exam not found" }
|
||||
exam = examData
|
||||
}
|
||||
@@ -116,9 +123,9 @@ export async function createHomeworkAssignmentAction(
|
||||
}
|
||||
|
||||
const assignmentId = createId()
|
||||
const availableAt = input.availableAt ? new Date(input.availableAt) : null
|
||||
const dueAt = input.dueAt ? new Date(input.dueAt) : null
|
||||
const lateDueAt = input.lateDueAt ? new Date(input.lateDueAt) : null
|
||||
const availableAt = input.availableAt ? safeParseDate(input.availableAt, "可用时间") : null
|
||||
const dueAt = input.dueAt ? safeParseDate(input.dueAt, "截止时间") : null
|
||||
const lateDueAt = input.lateDueAt ? safeParseDate(input.lateDueAt, "迟交截止时间") : null
|
||||
|
||||
await createHomeworkAssignment({
|
||||
assignmentId,
|
||||
@@ -141,13 +148,21 @@ export async function createHomeworkAssignmentAction(
|
||||
revalidatePath("/teacher/homework/assignments")
|
||||
revalidatePath("/teacher/homework/submissions")
|
||||
|
||||
// V3-4: 埋点监控
|
||||
await trackExamEvent("homework.created", {
|
||||
userId: ctx.userId,
|
||||
targetId: assignmentId,
|
||||
properties: {
|
||||
sourceExamId: input.sourceExamId ?? null,
|
||||
classId: input.classId,
|
||||
targetStudentCount: targetStudentIds.length,
|
||||
published: publish,
|
||||
},
|
||||
})
|
||||
|
||||
return { success: true, message: "Assignment created", data: assignmentId }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,11 +184,7 @@ export async function startHomeworkSubmissionAction(
|
||||
|
||||
return { success: true, message: "Started", data: result.submissionId }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,17 +205,16 @@ export async function saveHomeworkAnswerAction(
|
||||
if (submission.studentId !== ctx.userId) return { success: false, message: "Unauthorized" }
|
||||
if (submission.status !== "started") return { success: false, message: "Submission is locked" }
|
||||
|
||||
const payload = typeof answerJson === "string" && answerJson.length > 0 ? JSON.parse(answerJson) : null
|
||||
const payload =
|
||||
typeof answerJson === "string" && answerJson.length > 0
|
||||
? safeJsonParse<unknown>(answerJson, "答案数据格式无效")
|
||||
: null
|
||||
|
||||
await saveHomeworkAnswer(submissionId, questionId, payload)
|
||||
|
||||
return { success: true, message: "Saved", data: submissionId }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,18 +242,30 @@ export async function submitHomeworkAction(
|
||||
|
||||
const isLate = Boolean(dueAt && now > dueAt)
|
||||
|
||||
await markHomeworkSubmitted(submissionId, isLate)
|
||||
// V3-2: 即时自动批改回写
|
||||
const { isFullyAutoGraded, totalScore } = await markHomeworkSubmitted(submissionId, isLate)
|
||||
|
||||
revalidatePath("/teacher/homework/submissions")
|
||||
revalidatePath("/student/learning/assignments")
|
||||
|
||||
return { success: true, message: "Submitted", data: submissionId }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
// V3-4: 埋点监控
|
||||
await trackExamEvent("homework.submitted", {
|
||||
userId: ctx.userId,
|
||||
targetId: submissionId,
|
||||
properties: {
|
||||
isLate,
|
||||
isFullyAutoGraded,
|
||||
totalScore,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: isFullyAutoGraded ? "Submitted and auto-graded" : "Submitted",
|
||||
data: submissionId,
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,7 +280,7 @@ export async function gradeHomeworkSubmissionAction(
|
||||
const rawAnswers = typeof rawAnswersValue === "string" ? rawAnswersValue : null
|
||||
const parsed = GradeHomeworkSchema.safeParse({
|
||||
submissionId: formData.get("submissionId"),
|
||||
answers: rawAnswers ? JSON.parse(rawAnswers) : [],
|
||||
answers: rawAnswers ? safeJsonParse<unknown[]>(rawAnswers, "批改数据格式无效") : [],
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
@@ -294,12 +316,82 @@ export async function gradeHomeworkSubmissionAction(
|
||||
|
||||
revalidatePath("/teacher/homework/submissions")
|
||||
|
||||
// V3-4: 埋点监控
|
||||
await trackExamEvent("homework.graded", {
|
||||
userId: ctx.userId,
|
||||
targetId: submissionId,
|
||||
properties: {
|
||||
answerCount: answers.length,
|
||||
},
|
||||
})
|
||||
|
||||
return { success: true, message: "Grading saved" }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V3-7: 批量自动批改提交
|
||||
*
|
||||
* 教师在提交列表页勾选多份提交后,一键自动批改所有客观题。
|
||||
* 仅批改选择题/判断题,主观题保持原分数。
|
||||
*/
|
||||
export async function batchAutoGradeSubmissionsAction(
|
||||
prevState: ActionState<{ successCount: number; failedCount: number; fullyGradedCount: number }> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<{ successCount: number; failedCount: number; fullyGradedCount: number }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.HOMEWORK_GRADE)
|
||||
|
||||
const rawSubmissionIds = formData.get("submissionIds")
|
||||
const submissionIdsJson = typeof rawSubmissionIds === "string" ? rawSubmissionIds : "[]"
|
||||
const submissionIds = safeJsonParse<string[]>(submissionIdsJson, "提交 ID 列表格式无效")
|
||||
|
||||
if (submissionIds.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: "请至少选择一份提交",
|
||||
}
|
||||
}
|
||||
|
||||
// 权限校验:非管理员仅可批改自己创建的作业提交
|
||||
if (ctx.dataScope.type !== "all") {
|
||||
for (const submissionId of submissionIds) {
|
||||
const submissionForGrading = await getHomeworkSubmissionForGrading(submissionId)
|
||||
if (!submissionForGrading) {
|
||||
return { success: false, message: `提交不存在: ${submissionId}` }
|
||||
}
|
||||
if (submissionForGrading.creatorId !== ctx.userId) {
|
||||
return { success: false, message: "只能批改自己创建的作业提交" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const results = await batchAutoGradeSubmissions(submissionIds)
|
||||
const successCount = results.filter((r) => r.success).length
|
||||
const failedCount = results.filter((r) => !r.success).length
|
||||
const fullyGradedCount = results.filter((r) => r.success && r.isFullyAutoGraded).length
|
||||
|
||||
revalidatePath("/teacher/homework/submissions")
|
||||
revalidatePath("/teacher/homework/assignments")
|
||||
|
||||
await trackExamEvent("homework.graded", {
|
||||
userId: ctx.userId,
|
||||
targetId: submissionIds[0] ?? "",
|
||||
properties: {
|
||||
batchCount: submissionIds.length,
|
||||
successCount,
|
||||
fullyGradedCount,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `批量批改完成:成功 ${successCount} 份,失败 ${failedCount} 份,其中 ${fullyGradedCount} 份已全自动批改`,
|
||||
data: { successCount, failedCount, fullyGradedCount },
|
||||
}
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
168
src/modules/homework/components/homework-batch-grading-view.tsx
Normal file
168
src/modules/homework/components/homework-batch-grading-view.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useTransition } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Zap } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { batchAutoGradeSubmissionsAction } from "../actions"
|
||||
import type { HomeworkSubmissionListItem } from "../types"
|
||||
|
||||
interface HomeworkBatchGradingViewProps {
|
||||
submissions: HomeworkSubmissionListItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* V3-7: 批量批改视图
|
||||
*
|
||||
* 教师在提交列表页可勾选多份提交,一键自动批改所有客观题。
|
||||
* 对标智学网的批量批改功能。
|
||||
*/
|
||||
export function HomeworkBatchGradingView({ submissions }: HomeworkBatchGradingViewProps) {
|
||||
const t = useTranslations("examHomework")
|
||||
const router = useRouter()
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const selectableSubmissions = submissions.filter(
|
||||
(s) => s.status === "submitted"
|
||||
)
|
||||
|
||||
const allSelectableSelected =
|
||||
selectableSubmissions.length > 0 &&
|
||||
selectableSubmissions.every((s) => selectedIds.has(s.id))
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (allSelectableSelected) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(selectableSubmissions.map((s) => s.id)))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleBatchAutoGrade = () => {
|
||||
if (selectedIds.size === 0) {
|
||||
toast.error(t("homework.grade.batchSelectAtLeastOne"))
|
||||
return
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const formData = new FormData()
|
||||
formData.set("submissionIds", JSON.stringify(Array.from(selectedIds)))
|
||||
const result = await batchAutoGradeSubmissionsAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
setSelectedIds(new Set())
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || t("homework.grade.batchFailed"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="flex items-center justify-between rounded-lg border bg-muted/50 px-4 py-3">
|
||||
<span className="text-sm font-medium">
|
||||
{t("homework.grade.batchSelected", { count: selectedIds.size })}
|
||||
</span>
|
||||
<Button
|
||||
onClick={handleBatchAutoGrade}
|
||||
disabled={isPending}
|
||||
size="sm"
|
||||
>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
{t("homework.grade.batchAutoGrade")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-md border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px]">
|
||||
<Checkbox
|
||||
checked={allSelectableSelected}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
aria-label={t("homework.grade.selectAll")}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>{t("homework.grade.student")}</TableHead>
|
||||
<TableHead>{t("homework.grade.status")}</TableHead>
|
||||
<TableHead>{t("homework.grade.submitted")}</TableHead>
|
||||
<TableHead>{t("homework.grade.score")}</TableHead>
|
||||
<TableHead>{t("homework.grade.action")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{submissions.map((s) => {
|
||||
const isSelectable = s.status === "submitted"
|
||||
const isSelected = selectedIds.has(s.id)
|
||||
return (
|
||||
<TableRow key={s.id} data-selected={isSelected}>
|
||||
<TableCell>
|
||||
{isSelectable ? (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleSelect(s.id)}
|
||||
aria-label={t("homework.grade.selectRow")}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground" aria-hidden="true">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium truncate max-w-[160px]">{s.studentName}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{s.status}
|
||||
</Badge>
|
||||
{s.isLate ? <span className="ml-2 text-xs text-destructive">{t("homework.grade.late")}</span> : null}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground tabular-nums">{s.submittedAt ? formatDate(s.submittedAt) : "-"}</TableCell>
|
||||
<TableCell className="tabular-nums">{typeof s.score === "number" ? s.score : "-"}</TableCell>
|
||||
<TableCell>
|
||||
<a
|
||||
href={`/teacher/homework/submissions/${s.id}`}
|
||||
className="text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("homework.grade.title")}
|
||||
</a>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
234
src/modules/homework/components/homework-submission-result.tsx
Normal file
234
src/modules/homework/components/homework-submission-result.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import type { JSX } from "react"
|
||||
import Link from "next/link"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { CheckCircle2, XCircle, AlertCircle, Award, ArrowLeft, BookOpen } from "lucide-react"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Progress } from "@/shared/components/ui/progress"
|
||||
import {
|
||||
formatStudentAnswer,
|
||||
getCorrectnessState,
|
||||
getQuestionText,
|
||||
getTextCorrectAnswers,
|
||||
getChoiceCorrectIds,
|
||||
getJudgmentCorrectAnswer,
|
||||
} from "../lib/question-content-utils"
|
||||
import type { HomeworkSubmissionDetails } from "../types"
|
||||
|
||||
interface HomeworkSubmissionResultProps {
|
||||
submission: HomeworkSubmissionDetails
|
||||
}
|
||||
|
||||
/**
|
||||
* V3-9: 提交后即时反馈页
|
||||
*
|
||||
* 对标智学网/猿题库,学生提交后立即看到:
|
||||
* - 分数汇总(总分/满分、得分率)
|
||||
* - 对错分布(正确/错误/部分正确/待批改)
|
||||
* - 错题预览(题目文本、学生答案、正确答案)
|
||||
*/
|
||||
export function HomeworkSubmissionResult({ submission }: HomeworkSubmissionResultProps): JSX.Element {
|
||||
const t = useTranslations("examHomework")
|
||||
|
||||
const maxScore = submission.answers.reduce((sum, a) => sum + a.maxScore, 0)
|
||||
const totalScore = submission.totalScore ?? 0
|
||||
const scorePercentage = maxScore > 0 ? (totalScore / maxScore) * 100 : 0
|
||||
|
||||
const stats = submission.answers.reduce(
|
||||
(acc, a) => {
|
||||
const state = getCorrectnessState({ score: a.score, maxScore: a.maxScore })
|
||||
if (state === "correct") acc.correct += 1
|
||||
else if (state === "incorrect") acc.incorrect += 1
|
||||
else if (state === "partial") acc.partial += 1
|
||||
else acc.ungraded += 1
|
||||
return acc
|
||||
},
|
||||
{ correct: 0, incorrect: 0, partial: 0, ungraded: 0 }
|
||||
)
|
||||
|
||||
const wrongAnswers = submission.answers.filter((a) => {
|
||||
const state = getCorrectnessState({ score: a.score, maxScore: a.maxScore })
|
||||
return state === "incorrect" || state === "partial"
|
||||
})
|
||||
|
||||
const formatCorrectAnswer = (questionType: string, content: unknown): string => {
|
||||
if (questionType === "single_choice" || questionType === "multiple_choice") {
|
||||
const ids = getChoiceCorrectIds(content)
|
||||
return ids.length > 0 ? ids.join(", ") : "—"
|
||||
}
|
||||
if (questionType === "judgment") {
|
||||
const ans = getJudgmentCorrectAnswer(content)
|
||||
if (ans === null) return "—"
|
||||
return ans ? t("homework.review.correctAnswerTrue") : t("homework.review.correctAnswerFalse")
|
||||
}
|
||||
if (questionType === "text") {
|
||||
const answers = getTextCorrectAnswers(content)
|
||||
return answers.length > 0 ? answers.join(" / ") : "—"
|
||||
}
|
||||
return "—"
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Score Summary */}
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<Award className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-3xl">
|
||||
{totalScore}
|
||||
<span className="text-lg font-normal text-muted-foreground">
|
||||
{" / "}{maxScore}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t("homework.result.scoreRate")}: {scorePercentage.toFixed(1)}%
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Progress value={scorePercentage} className="h-3" />
|
||||
{submission.status === "graded" ? (
|
||||
<p className="mt-3 text-center text-sm text-muted-foreground">
|
||||
{t("homework.result.fullyGraded")}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-3 text-center text-sm text-muted-foreground">
|
||||
{t("homework.result.partiallyGraded")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 pt-6">
|
||||
<CheckCircle2 className="h-8 w-8 text-green-500" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{stats.correct}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("homework.result.correctCount")}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 pt-6">
|
||||
<XCircle className="h-8 w-8 text-red-500" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{stats.incorrect}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("homework.result.incorrectCount")}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 pt-6">
|
||||
<AlertCircle className="h-8 w-8 text-yellow-500" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{stats.partial}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("homework.result.partialCount")}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 pt-6">
|
||||
<BookOpen className="h-8 w-8 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{stats.ungraded}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("homework.result.pendingCount")}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Wrong Answers Preview */}
|
||||
{wrongAnswers.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("homework.result.wrongAnswersTitle")}</CardTitle>
|
||||
<CardDescription>{t("homework.result.wrongAnswersDesc")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{wrongAnswers.map((a, index) => {
|
||||
const state = getCorrectnessState({ score: a.score, maxScore: a.maxScore })
|
||||
return (
|
||||
<div key={a.id} className="rounded-lg border p-4">
|
||||
<div className="mb-2 flex items-start gap-2">
|
||||
<span className="font-medium tabular-nums">#{index + 1}</span>
|
||||
<Badge
|
||||
variant={state === "incorrect" ? "destructive" : "secondary"}
|
||||
className="text-xs"
|
||||
>
|
||||
{state === "incorrect"
|
||||
? t("homework.grade.incorrect")
|
||||
: t("homework.grade.partial")}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{a.questionType}
|
||||
</Badge>
|
||||
<span className="ml-auto text-sm tabular-nums text-muted-foreground">
|
||||
{a.score ?? 0} / {a.maxScore}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mb-3 text-sm">
|
||||
{getQuestionText(a.questionContent) || t("homework.grade.noQuestionText")}
|
||||
</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="rounded bg-red-50 dark:bg-red-950/20 p-2">
|
||||
<p className="mb-1 text-xs font-medium text-red-600 dark:text-red-400">
|
||||
{t("homework.review.yourAnswer")}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{formatStudentAnswer(a.studentAnswer) || "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded bg-green-50 dark:bg-green-950/20 p-2">
|
||||
<p className="mb-1 text-xs font-medium text-green-600 dark:text-green-400">
|
||||
{t("homework.review.correctAnswer")}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{formatCorrectAnswer(a.questionType, a.questionContent)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{a.feedback && (
|
||||
<div className="mt-2 rounded bg-blue-50 dark:bg-blue-950/20 p-2">
|
||||
<p className="mb-1 text-xs font-medium text-blue-600 dark:text-blue-400">
|
||||
{t("homework.review.teacherFeedback")}
|
||||
</p>
|
||||
<p className="text-sm">{a.feedback}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-center gap-3">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/student/learning/assignments">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{t("homework.result.backToList")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/student/error-book">
|
||||
<BookOpen className="mr-2 h-4 w-4" />
|
||||
{t("homework.result.viewErrorBook")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -125,18 +125,23 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
|
||||
const handleStart = async () => {
|
||||
setIsBusy(true)
|
||||
const fd = new FormData()
|
||||
fd.set("assignmentId", assignmentId)
|
||||
const res = await startHomeworkSubmissionAction(null, fd)
|
||||
if (res.success && res.data) {
|
||||
setSubmissionId(res.data)
|
||||
setSubmissionStatus("started")
|
||||
toast.success(t("homework.take.startSuccess"))
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("homework.take.startFailed"))
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.set("assignmentId", assignmentId)
|
||||
const res = await startHomeworkSubmissionAction(null, fd)
|
||||
if (res.success && res.data) {
|
||||
setSubmissionId(res.data)
|
||||
setSubmissionStatus("started")
|
||||
toast.success(t("homework.take.startSuccess"))
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("homework.take.startFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("homework.take.startFailed"))
|
||||
} finally {
|
||||
setIsBusy(false)
|
||||
}
|
||||
setIsBusy(false)
|
||||
}
|
||||
|
||||
const handleSaveQuestion = async (questionId: string) => {
|
||||
@@ -156,22 +161,29 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
const handleSubmit = async () => {
|
||||
if (!submissionId) return
|
||||
setIsBusy(true)
|
||||
// P2-9: 提交前 flush 自动保存队列,确保所有答案已落库
|
||||
await autoSave.flush()
|
||||
try {
|
||||
// P2-9: 提交前 flush 自动保存队列,确保所有答案已落库
|
||||
// flush 失败应中止提交,避免丢失未保存的答案
|
||||
await autoSave.flush()
|
||||
|
||||
const submitFd = new FormData()
|
||||
submitFd.set("submissionId", submissionId)
|
||||
const submitRes = await submitHomeworkAction(null, submitFd)
|
||||
if (submitRes.success) {
|
||||
// 提交成功后清除离线缓存
|
||||
clearOfflineCache(offlineStorageKey)
|
||||
toast.success(t("homework.take.submitSuccess"))
|
||||
setSubmissionStatus("submitted")
|
||||
router.push("/student/learning/assignments")
|
||||
} else {
|
||||
toast.error(submitRes.message || t("homework.take.submitFailed"))
|
||||
const submitFd = new FormData()
|
||||
submitFd.set("submissionId", submissionId)
|
||||
const submitRes = await submitHomeworkAction(null, submitFd)
|
||||
if (submitRes.success) {
|
||||
// 提交成功后清除离线缓存
|
||||
clearOfflineCache(offlineStorageKey)
|
||||
toast.success(t("homework.take.submitSuccess"))
|
||||
setSubmissionStatus("submitted")
|
||||
// V3-9: 提交后跳转到结果页,展示即时反馈
|
||||
router.push(`/student/learning/assignments/${assignmentId}/result`)
|
||||
} else {
|
||||
toast.error(submitRes.message || t("homework.take.submitFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("homework.take.submitFailed"))
|
||||
} finally {
|
||||
setIsBusy(false)
|
||||
}
|
||||
setIsBusy(false)
|
||||
}
|
||||
|
||||
// 统计未作答题目数
|
||||
@@ -378,7 +390,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||
}}
|
||||
className={cn(
|
||||
"h-8 w-8 rounded flex items-center justify-center text-xs font-medium border transition-colors hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
||||
"h-11 w-11 sm:h-8 sm:w-8 rounded flex items-center justify-center text-xs font-medium border transition-colors hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
||||
hasAnswer ? "bg-primary text-primary-foreground border-primary" : "bg-background text-muted-foreground border-input"
|
||||
)}
|
||||
aria-label={t("homework.take.jumpToQuestion", { index: i + 1 })}
|
||||
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
type ExamWithQuestionsForHomework,
|
||||
} from "@/modules/exams/data-access"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
import {
|
||||
autoGradeSubmission,
|
||||
type AutoGradableAnswer,
|
||||
} from "./lib/question-content-utils"
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
@@ -267,12 +271,84 @@ export const saveHomeworkAnswer = async (
|
||||
export const markHomeworkSubmitted = async (
|
||||
submissionId: string,
|
||||
isLate: boolean
|
||||
): Promise<void> => {
|
||||
): Promise<{ isFullyAutoGraded: boolean; totalScore: number }> => {
|
||||
const now = new Date()
|
||||
await db
|
||||
.update(homeworkSubmissions)
|
||||
.set({ status: "submitted", submittedAt: now, isLate, updatedAt: now })
|
||||
.where(eq(homeworkSubmissions.id, submissionId))
|
||||
|
||||
// V3-2: 即时自动批改回写
|
||||
// 1. 获取提交的所有答案 + 题目元数据
|
||||
// 2. 调用 autoGradeSubmission 计算分数
|
||||
// 3. 回写答案分数 + 提交状态 + 总分
|
||||
const submission = await db.query.homeworkSubmissions.findFirst({
|
||||
where: eq(homeworkSubmissions.id, submissionId),
|
||||
columns: { id: true, assignmentId: true },
|
||||
})
|
||||
|
||||
if (!submission) {
|
||||
throw new Error(`Submission not found: ${submissionId}`)
|
||||
}
|
||||
|
||||
const [answers, assignmentQuestions] = await Promise.all([
|
||||
db.query.homeworkAnswers.findMany({
|
||||
where: eq(homeworkAnswers.submissionId, submissionId),
|
||||
columns: { id: true, questionId: true, answerContent: true, score: true, feedback: true },
|
||||
}),
|
||||
db.query.homeworkAssignmentQuestions.findMany({
|
||||
where: eq(homeworkAssignmentQuestions.assignmentId, submission.assignmentId),
|
||||
with: { question: { columns: { type: true, content: true } } },
|
||||
columns: { questionId: true, score: true, order: true },
|
||||
}),
|
||||
])
|
||||
|
||||
// 构建 AutoGradableAnswer 数组
|
||||
const questionMetaMap = new Map(assignmentQuestions.map((aq) => [aq.questionId, aq]))
|
||||
const autoGradableInputs: AutoGradableAnswer[] = answers.map((ans) => {
|
||||
const meta = questionMetaMap.get(ans.questionId)
|
||||
return {
|
||||
id: ans.id,
|
||||
questionId: ans.questionId,
|
||||
questionType: meta?.question.type ?? "text",
|
||||
questionContent: meta?.question.content ?? null,
|
||||
maxScore: meta?.score ?? 0,
|
||||
studentAnswer: ans.answerContent,
|
||||
score: ans.score,
|
||||
feedback: ans.feedback,
|
||||
order: meta?.order ?? 0,
|
||||
}
|
||||
})
|
||||
|
||||
// 执行自动批改
|
||||
const { answers: gradedAnswers, isFullyAutoGraded } = autoGradeSubmission(autoGradableInputs)
|
||||
const totalScore = gradedAnswers.reduce((sum, a) => sum + a.score, 0)
|
||||
|
||||
// 回写 DB:答案分数 + 提交状态
|
||||
await db.transaction(async (tx) => {
|
||||
// 批量更新答案分数
|
||||
for (const ans of gradedAnswers) {
|
||||
await tx
|
||||
.update(homeworkAnswers)
|
||||
.set({ score: ans.score, updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(homeworkAnswers.id, ans.id),
|
||||
eq(homeworkAnswers.submissionId, submissionId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 更新提交状态:全部可自动判分 → graded;含主观题 → submitted
|
||||
await tx
|
||||
.update(homeworkSubmissions)
|
||||
.set({
|
||||
status: isFullyAutoGraded ? "graded" : "submitted",
|
||||
submittedAt: now,
|
||||
isLate,
|
||||
score: totalScore,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(homeworkSubmissions.id, submissionId))
|
||||
})
|
||||
|
||||
return { isFullyAutoGraded, totalScore }
|
||||
}
|
||||
|
||||
export const gradeHomeworkAnswers = async (
|
||||
@@ -282,10 +358,17 @@ export const gradeHomeworkAnswers = async (
|
||||
await db.transaction(async (tx) => {
|
||||
let totalScore = 0
|
||||
for (const ans of answers) {
|
||||
// 关键安全约束:WHERE 子句同时匹配 answer.id 和 submissionId,
|
||||
// 防止恶意客户端篡改 answer ID 批改其他 submission 的答案
|
||||
await tx
|
||||
.update(homeworkAnswers)
|
||||
.set({ score: ans.score, feedback: ans.feedback, updatedAt: new Date() })
|
||||
.where(eq(homeworkAnswers.id, ans.id))
|
||||
.where(
|
||||
and(
|
||||
eq(homeworkAnswers.id, ans.id),
|
||||
eq(homeworkAnswers.submissionId, submissionId)
|
||||
)
|
||||
)
|
||||
totalScore += ans.score
|
||||
}
|
||||
|
||||
@@ -295,3 +378,122 @@ export const gradeHomeworkAnswers = async (
|
||||
.where(eq(homeworkSubmissions.id, submissionId))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* V3-7: 批量自动批改提交
|
||||
*
|
||||
* 对多个提交执行自动批改(复用 autoGradeSubmission 逻辑)。
|
||||
* 仅批改客观题(选择题/判断题),主观题保持原分数。
|
||||
*
|
||||
* @param submissionIds 要批量批改的提交 ID 列表
|
||||
* @returns 每个提交的批改结果摘要
|
||||
*/
|
||||
export const batchAutoGradeSubmissions = async (
|
||||
submissionIds: string[]
|
||||
): Promise<Array<{
|
||||
submissionId: string
|
||||
success: boolean
|
||||
isFullyAutoGraded: boolean
|
||||
totalScore: number
|
||||
message?: string
|
||||
}>> => {
|
||||
const now = new Date()
|
||||
const results: Array<{
|
||||
submissionId: string
|
||||
success: boolean
|
||||
isFullyAutoGraded: boolean
|
||||
totalScore: number
|
||||
message?: string
|
||||
}> = []
|
||||
|
||||
for (const submissionId of submissionIds) {
|
||||
try {
|
||||
const submission = await db.query.homeworkSubmissions.findFirst({
|
||||
where: eq(homeworkSubmissions.id, submissionId),
|
||||
columns: { id: true, assignmentId: true },
|
||||
})
|
||||
|
||||
if (!submission) {
|
||||
results.push({
|
||||
submissionId,
|
||||
success: false,
|
||||
isFullyAutoGraded: false,
|
||||
totalScore: 0,
|
||||
message: "Submission not found",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const [answers, assignmentQuestions] = await Promise.all([
|
||||
db.query.homeworkAnswers.findMany({
|
||||
where: eq(homeworkAnswers.submissionId, submissionId),
|
||||
columns: { id: true, questionId: true, answerContent: true, score: true, feedback: true },
|
||||
}),
|
||||
db.query.homeworkAssignmentQuestions.findMany({
|
||||
where: eq(homeworkAssignmentQuestions.assignmentId, submission.assignmentId),
|
||||
with: { question: { columns: { type: true, content: true } } },
|
||||
columns: { questionId: true, score: true, order: true },
|
||||
}),
|
||||
])
|
||||
|
||||
const questionMetaMap = new Map(assignmentQuestions.map((aq) => [aq.questionId, aq]))
|
||||
const autoGradableInputs: AutoGradableAnswer[] = answers.map((ans) => {
|
||||
const meta = questionMetaMap.get(ans.questionId)
|
||||
return {
|
||||
id: ans.id,
|
||||
questionId: ans.questionId,
|
||||
questionType: meta?.question.type ?? "text",
|
||||
questionContent: meta?.question.content ?? null,
|
||||
maxScore: meta?.score ?? 0,
|
||||
studentAnswer: ans.answerContent,
|
||||
score: ans.score,
|
||||
feedback: ans.feedback,
|
||||
order: meta?.order ?? 0,
|
||||
}
|
||||
})
|
||||
|
||||
const { answers: gradedAnswers, isFullyAutoGraded } = autoGradeSubmission(autoGradableInputs)
|
||||
const totalScore = gradedAnswers.reduce((sum, a) => sum + a.score, 0)
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const ans of gradedAnswers) {
|
||||
await tx
|
||||
.update(homeworkAnswers)
|
||||
.set({ score: ans.score, updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(homeworkAnswers.id, ans.id),
|
||||
eq(homeworkAnswers.submissionId, submissionId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(homeworkSubmissions)
|
||||
.set({
|
||||
status: isFullyAutoGraded ? "graded" : "submitted",
|
||||
score: totalScore,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(homeworkSubmissions.id, submissionId))
|
||||
})
|
||||
|
||||
results.push({
|
||||
submissionId,
|
||||
success: true,
|
||||
isFullyAutoGraded,
|
||||
totalScore,
|
||||
})
|
||||
} catch (error) {
|
||||
results.push({
|
||||
submissionId,
|
||||
success: false,
|
||||
isFullyAutoGraded: false,
|
||||
totalScore: 0,
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
@@ -282,44 +282,44 @@ export const getHomeworkAssignmentReviewList = cache(async (params: { creatorId:
|
||||
|
||||
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 [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 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))
|
||||
|
||||
@@ -452,27 +452,26 @@ export const getHomeworkAssignmentById = cache(async (id: string, scope?: DataSc
|
||||
}
|
||||
}
|
||||
|
||||
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")))
|
||||
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,
|
||||
@@ -487,15 +486,137 @@ export const getHomeworkAssignmentById = cache(async (id: string, scope?: DataSc
|
||||
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,
|
||||
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),
|
||||
@@ -507,17 +628,18 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
|
||||
|
||||
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 [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) => {
|
||||
@@ -579,6 +701,89 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 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"
|
||||
|
||||
Reference in New Issue
Block a user