"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(initialData.submission?.id ?? null) const [submissionStatus, setSubmissionStatus] = useState(initialData.submission?.status ?? "not_started") const [isBusy, setIsBusy] = useState(false) const [showSubmitConfirm, setShowSubmitConfirm] = useState(false) const initialAnswersByQuestionId = useMemo(() => { const map = new Map() 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 = {} 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 = { ...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 (

{t("homework.take.questions")}

{submissionStatus === "not_started" ? t("homework.take.notStarted") : submissionStatus} {initialData.questions.length} {t("homework.take.questions")}
{!canEdit ? ( ) : ( )}
{!isStarted && (

{t("homework.take.readyToStart")}

{t("homework.take.readyDescription")}

)} {showQuestions && initialData.questions.map((q, idx) => { const value = answersByQuestionId[q.questionId]?.answer return ( setAnswersByQuestionId((prev) => ({ ...prev, [q.questionId]: { answer }, })) } showCorrectAnswer={submissionStatus === "graded"} feedback={submissionStatus === "graded" ? q.feedback : null} footerExtra={ canEdit ? (
) : null } />
) })}

{t("homework.take.assignmentInfo")}

{canEdit && (
{autoSave.status === "saving" && } {autoSave.status === "saved" && } {autoSave.status === "error" && } {autoSave.status === "idle" && } {t(`homework.take.autoSave${autoSave.status.charAt(0).toUpperCase()}${autoSave.status.slice(1)}`)}
)}
{submissionStatus === "not_started" ? t("homework.take.notStarted") : submissionStatus}
{dueAt && (
{(isOverdue || isUrgent) && } {formatDate(dueAt)}
{isOverdue && (

{t("homework.take.overdue")}

)} {isUrgent && !isOverdue && hoursUntilDue !== null && (

{hoursUntilDue === 0 ? t("homework.take.lessThanOneHour") : t("homework.take.hoursLeft", { hours: hoursUntilDue })}

)}
)} {maxAttempts > 0 && (
{attemptsUsed} {t("homework.take.attemptsUsed", { used: attemptsUsed, max: maxAttempts })} {attemptsRemaining > 0 && ( {t("homework.take.attemptsRemaining", { remaining: attemptsRemaining })} )}
)}

{initialData.assignment.description || t("homework.take.noDescription")}

{showQuestions && (
{initialData.questions.map((q, i) => { const answer = answersByQuestionId[q.questionId]?.answer const hasAnswer = answer !== undefined && answer !== "" && (Array.isArray(answer) ? answer.length > 0 : true) return ( ) })}
)}
{canEdit && (

{t("homework.take.makeSureAnswered")}

)}
{/* 提交二次确认对话框 */} {t("homework.take.confirmSubmit")} {unansweredCount > 0 ? t("homework.take.unansweredWarning", { count: unansweredCount }) : t("homework.take.confirmSubmitDescription")} {t("homework.take.cancel")} { e.preventDefault() setShowSubmitConfirm(false) void handleSubmit() }} > {isBusy ? t("homework.take.submitting") : t("homework.take.confirmSubmitAction")}
) }