"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, Timer, Camera } from "lucide-react" import { formatDate, cn } from "@/shared/lib/utils" import type { StudentHomeworkTakeData } from "../types" import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction, getScansAction, deleteScanAction } from "../actions" import { QuestionRenderer } from "./question-renderer" import { ScanUploader, type ScanImage } from "./scan-uploader" import { parseSavedAnswer } from "../lib/question-content-utils" import { useDebouncedAutoSave, loadOfflineCache, clearOfflineCache } from "../hooks/use-debounced-auto-save" import { useExamCountdown } from "../hooks/use-exam-countdown" 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 [scanImages, setScanImages] = useState([]) // 加载已有答题扫描图(拍照上传) useEffect(() => { if (!submissionId) return void (async () => { const result = await getScansAction(submissionId) if (result.success && result.data) { setScanImages(result.data) } })() }, [submissionId]) const handleDeleteScan = async (fileId: string) => { if (!submissionId) return const result = await deleteScanAction(submissionId, fileId) if (!result.success) { toast.error(result.message || t("homework.take.saveFailed")) } } 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 // P0-竞品修复:限时/监考模式倒计时 const examModeConfig = initialData.examModeConfig const isTimedExam = canEdit && examModeConfig !== null && (examModeConfig.examMode === "timed" || examModeConfig.examMode === "proctored") && examModeConfig.durationMinutes !== null && examModeConfig.durationMinutes > 0 && initialData.submission?.startedAt !== null && initialData.submission?.startedAt !== undefined const countdown = useExamCountdown({ durationMinutes: examModeConfig?.durationMinutes ?? null, startedAt: initialData.submission?.startedAt ?? null, enabled: isTimedExam, onExpire: () => { // 到时自动提交(仅触发一次) if (submissionStatus === "started" && submissionId) { toast.warning(t("homework.take.timeUpAutoSubmit")) void handleSubmit() } }, }) const formatCountdown = (s: { hours: number; minutes: number; seconds: number } | null): string => { if (!s) return "" const parts: string[] = [] if (s.hours > 0) parts.push(`${s.hours}h`) parts.push(`${s.minutes.toString().padStart(2, "0")}m`) parts.push(`${s.seconds.toString().padStart(2, "0")}s`) return parts.join(" ") } return (

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

{submissionStatus === "not_started" ? t("homework.take.notStarted") : submissionStatus} {initialData.questions.length} {t("homework.take.questions")}
{!canEdit ? (
{isTimedExam && examModeConfig && (
{t("homework.take.timedExam", { minutes: examModeConfig.durationMinutes ?? 0 })}
)}
) : (
{countdown && (
{formatCountdown(countdown)}
)}
)}
{!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 } />
) })} {showQuestions && submissionId && (

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

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

)}

{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")}
) }