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

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

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

451 lines
19 KiB
TypeScript

"use client"
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardHeader } from "@/shared/components/ui/card"
import { Label } from "@/shared/components/ui/label"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { Clock, CheckCircle2, Save, FileText, ChevronLeft, TriangleAlert, CloudUpload, CloudOff, Check, Loader2 } from "lucide-react"
import { formatDate, cn } from "@/shared/lib/utils"
import type { StudentHomeworkTakeData } from "../types"
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction } from "../actions"
import { QuestionRenderer } from "./question-renderer"
import { parseSavedAnswer } from "../lib/question-content-utils"
import { useDebouncedAutoSave, loadOfflineCache, clearOfflineCache } from "../hooks/use-debounced-auto-save"
type HomeworkTakeViewProps = {
assignmentId: string
initialData: StudentHomeworkTakeData
}
export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeViewProps) {
const router = useRouter()
const t = useTranslations("examHomework")
const [submissionId, setSubmissionId] = useState<string | null>(initialData.submission?.id ?? null)
const [submissionStatus, setSubmissionStatus] = useState<string>(initialData.submission?.status ?? "not_started")
const [isBusy, setIsBusy] = useState(false)
const [showSubmitConfirm, setShowSubmitConfirm] = useState(false)
const initialAnswersByQuestionId = useMemo(() => {
const map = new Map<string, { answer: unknown }>()
for (const q of initialData.questions) {
map.set(q.questionId, parseSavedAnswer(q.savedAnswer, q.questionType))
}
return map
}, [initialData.questions])
const [answersByQuestionId, setAnswersByQuestionId] = useState(() => {
const obj: Record<string, { answer: unknown }> = {}
for (const [k, v] of initialAnswersByQuestionId.entries()) obj[k] = v
return obj
})
const isStarted = submissionStatus === "started"
const canEdit = isStarted && Boolean(submissionId)
const showQuestions = submissionStatus !== "not_started"
// P2-9: 自动保存 + 离线缓存
const offlineStorageKey = `homework-draft-${assignmentId}`
const autoSave = useDebouncedAutoSave({
submissionId,
answers: answersByQuestionId,
enabled: canEdit,
storageKey: offlineStorageKey,
})
// 挂载时尝试从 localStorage 恢复未提交的答案
useEffect(() => {
if (!canEdit) return
const cached = loadOfflineCache(offlineStorageKey)
if (!cached) return
setAnswersByQuestionId((prev) => {
const merged: Record<string, { answer: unknown }> = { ...prev }
let changed = false
for (const questionId of Object.keys(cached)) {
const cachedEntry = cached[questionId]
if (!cachedEntry) continue
const prevEntry = prev[questionId]
const cachedJson = JSON.stringify(cachedEntry.answer)
const prevJson = prevEntry ? JSON.stringify(prevEntry.answer) : ""
if (cachedJson !== prevJson) {
merged[questionId] = { answer: cachedEntry.answer }
changed = true
}
}
if (changed) {
toast.success(t("homework.take.autoSaveRestored"))
}
return merged
})
// 仅恢复一次,恢复后清除缓存(避免重复提示)
clearOfflineCache(offlineStorageKey)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [canEdit])
// 离开警告:作答中未提交时关闭/刷新页面会丢失答案
useEffect(() => {
if (!canEdit) return
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault()
e.returnValue = ""
}
window.addEventListener("beforeunload", handler)
return () => window.removeEventListener("beforeunload", handler)
}, [canEdit])
// 截止时间与紧急度
const dueAt = initialData.assignment.dueAt
const now = new Date()
const dueDate = dueAt ? new Date(dueAt) : null
const isOverdue = dueDate ? dueDate < now : false
const hoursUntilDue = dueDate ? Math.floor((dueDate.getTime() - now.getTime()) / (1000 * 60 * 60)) : null
const isUrgent = hoursUntilDue !== null && hoursUntilDue >= 0 && hoursUntilDue < 24
// 尝试次数
const maxAttempts = initialData.assignment.maxAttempts
const attemptsUsed = initialData.submission?.attemptNo ?? 0
const attemptsRemaining = Math.max(0, maxAttempts - attemptsUsed)
const handleStart = async () => {
setIsBusy(true)
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)
}
}
const handleSaveQuestion = async (questionId: string) => {
if (!submissionId) return
// setIsBusy(true) // Don't block UI for individual saves
const payload = answersByQuestionId[questionId]?.answer ?? null
const fd = new FormData()
fd.set("submissionId", submissionId)
fd.set("questionId", questionId)
fd.set("answerJson", JSON.stringify({ answer: payload }))
const res = await saveHomeworkAnswerAction(null, fd)
if (res.success) toast.success(t("homework.take.saved"))
else toast.error(res.message || t("homework.take.saveFailed"))
// setIsBusy(false)
}
const handleSubmit = async () => {
if (!submissionId) return
setIsBusy(true)
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")
// 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)
}
}
// 统计未作答题目数
const unansweredCount = initialData.questions.filter((q) => {
const v = answersByQuestionId[q.questionId]?.answer
if (v === undefined || v === null) return true
if (typeof v === "string" && v.trim() === "") return true
if (Array.isArray(v) && v.length === 0) return true
return false
}).length
return (
<div className="grid h-[calc(100vh-10rem)] grid-cols-1 gap-6 lg:grid-cols-12">
<div className="lg:col-span-9 flex flex-col h-full overflow-hidden rounded-md border bg-card">
<div className="border-b p-4 flex items-center justify-between bg-muted/30">
<div className="flex items-center gap-3">
<Button asChild variant="ghost" size="sm" className="mr-1">
<Link href="/student/learning/assignments">
<ChevronLeft className="mr-1 h-4 w-4" />
{t("homework.take.back")}
</Link>
</Button>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
<FileText className="h-4 w-4 text-primary" />
</div>
<div>
<h3 className="font-semibold leading-none">{t("homework.take.questions")}</h3>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant={submissionStatus === "started" ? "default" : "secondary"} className="h-5 px-1.5 text-[10px] capitalize">
{submissionStatus === "not_started" ? t("homework.take.notStarted") : submissionStatus}
</Badge>
<span></span>
<span>{initialData.questions.length} {t("homework.take.questions")}</span>
</div>
</div>
</div>
{!canEdit ? (
<Button onClick={handleStart} disabled={isBusy} size="sm">
{isBusy ? t("homework.take.starting") : t("homework.take.startAssignment")}
</Button>
) : (
<Button onClick={() => setShowSubmitConfirm(true)} disabled={isBusy} size="sm">
<CheckCircle2 className="mr-2 h-4 w-4" />
{isBusy ? t("homework.take.submitting") : t("homework.take.submitAssignment")}
</Button>
)}
</div>
<ScrollArea className="flex-1 bg-muted/10">
<div className="space-y-6 p-6 max-w-4xl mx-auto">
{!isStarted && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center mb-4">
<Clock className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium">{t("homework.take.readyToStart")}</h3>
<p className="text-muted-foreground max-w-sm mt-2 mb-6">
{t("homework.take.readyDescription")}
</p>
<Button onClick={handleStart} disabled={isBusy}>
{t("homework.take.startNow")}
</Button>
</div>
)}
{showQuestions && initialData.questions.map((q, idx) => {
const value = answersByQuestionId[q.questionId]?.answer
return (
<Card key={q.questionId} id={`question-${q.questionId}`} className="border-l-4 border-l-primary shadow-sm scroll-mt-4">
<CardHeader className="pb-2">
<QuestionRenderer
questionId={q.questionId}
questionType={q.questionType}
questionContent={q.questionContent}
maxScore={q.maxScore}
index={idx}
mode={submissionStatus === "graded" ? "review" : "take"}
value={value}
disabled={!canEdit}
onChange={(answer) =>
setAnswersByQuestionId((prev) => ({
...prev,
[q.questionId]: { answer },
}))
}
showCorrectAnswer={submissionStatus === "graded"}
feedback={submissionStatus === "graded" ? q.feedback : null}
footerExtra={
canEdit ? (
<div className="flex justify-end pt-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleSaveQuestion(q.questionId)}
disabled={isBusy}
className="text-muted-foreground hover:text-foreground"
>
<Save className="mr-2 h-3 w-3" />
{t("homework.take.saveAnswer")}
</Button>
</div>
) : null
}
/>
</CardHeader>
</Card>
)
})}
</div>
</ScrollArea>
</div>
<div className="lg:col-span-3 flex flex-col h-full overflow-hidden rounded-md border bg-card">
<div className="border-b p-4 bg-muted/30">
<h3 className="font-semibold">{t("homework.take.assignmentInfo")}</h3>
{canEdit && (
<div className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground" role="status" aria-live="polite">
{autoSave.status === "saving" && <Loader2 className="h-3 w-3 animate-spin" />}
{autoSave.status === "saved" && <Check className="h-3 w-3 text-green-500" />}
{autoSave.status === "error" && <CloudOff className="h-3 w-3 text-destructive" />}
{autoSave.status === "idle" && <CloudUpload className="h-3 w-3" />}
<span className={
autoSave.status === "saved" ? "text-green-600" :
autoSave.status === "error" ? "text-destructive" :
"text-muted-foreground"
}>
{t(`homework.take.autoSave${autoSave.status.charAt(0).toUpperCase()}${autoSave.status.slice(1)}`)}
</span>
</div>
)}
</div>
<div className="flex-1 p-4 overflow-y-auto">
<div className="space-y-6">
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.status")}</Label>
<div className="mt-1 flex items-center gap-2">
<Badge variant={submissionStatus === "started" ? "default" : "outline"} className="capitalize">
{submissionStatus === "not_started" ? t("homework.take.notStarted") : submissionStatus}
</Badge>
</div>
</div>
{dueAt && (
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.dueDate")}</Label>
<div className={cn(
"mt-1 flex items-center gap-2 text-sm font-medium",
isOverdue ? "text-destructive" : isUrgent ? "text-orange-500" : "text-foreground"
)}>
{(isOverdue || isUrgent) && <TriangleAlert className="h-4 w-4" />}
<span>{formatDate(dueAt)}</span>
</div>
{isOverdue && (
<p className="mt-1 text-xs text-destructive">{t("homework.take.overdue")}</p>
)}
{isUrgent && !isOverdue && hoursUntilDue !== null && (
<p className="mt-1 text-xs text-orange-500">
{hoursUntilDue === 0
? t("homework.take.lessThanOneHour")
: t("homework.take.hoursLeft", { hours: hoursUntilDue })}
</p>
)}
</div>
)}
{maxAttempts > 0 && (
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.attempts")}</Label>
<div className="mt-1 text-sm">
<span className="font-medium">{attemptsUsed}</span>
<span className="text-muted-foreground"> {t("homework.take.attemptsUsed", { used: attemptsUsed, max: maxAttempts })}</span>
{attemptsRemaining > 0 && (
<span className="text-muted-foreground"> {t("homework.take.attemptsRemaining", { remaining: attemptsRemaining })}</span>
)}
</div>
</div>
)}
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.description")}</Label>
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
{initialData.assignment.description || t("homework.take.noDescription")}
</p>
</div>
{showQuestions && (
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.progress")}</Label>
<div className="mt-2 grid grid-cols-5 gap-2">
{initialData.questions.map((q, i) => {
const answer = answersByQuestionId[q.questionId]?.answer
const hasAnswer = answer !== undefined &&
answer !== "" &&
(Array.isArray(answer) ? answer.length > 0 : true)
return (
<button
key={q.questionId}
type="button"
onClick={() => {
const el = document.getElementById(`question-${q.questionId}`)
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" })
}}
className={cn(
"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 })}
aria-pressed={hasAnswer}
title={hasAnswer ? t("homework.take.answered") : t("homework.take.unanswered")}
>
{i + 1}
</button>
)
})}
</div>
</div>
)}
</div>
</div>
{canEdit && (
<div className="border-t p-4 bg-muted/20">
<Button className="w-full" onClick={() => setShowSubmitConfirm(true)} disabled={isBusy}>
{isBusy ? t("homework.take.submitting") : t("homework.take.submitAll")}
</Button>
<p className="mt-2 text-xs text-center text-muted-foreground">
{t("homework.take.makeSureAnswered")}
</p>
</div>
)}
</div>
{/* 提交二次确认对话框 */}
<AlertDialog open={showSubmitConfirm} onOpenChange={setShowSubmitConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("homework.take.confirmSubmit")}</AlertDialogTitle>
<AlertDialogDescription>
{unansweredCount > 0
? t("homework.take.unansweredWarning", { count: unansweredCount })
: t("homework.take.confirmSubmitDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isBusy}>{t("homework.take.cancel")}</AlertDialogCancel>
<AlertDialogAction
disabled={isBusy}
onClick={(e) => {
e.preventDefault()
setShowSubmitConfirm(false)
void handleSubmit()
}}
>
{isBusy ? t("homework.take.submitting") : t("homework.take.confirmSubmitAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}