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:
SpecialX
2026-06-23 01:06:27 +08:00
parent 21c5eba96c
commit a60105455e
23 changed files with 2407 additions and 263 deletions

View 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>
)
}

View 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>
)
}

View File

@@ -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 })}