"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 { ScrollArea } from "@/shared/components/ui/scroll-area" import { Clock, CheckCircle2, Save, FileText, ChevronLeft, Camera, Timer } from "lucide-react" import { 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" import { HomeworkTakeSidebar } from "./homework-take-sidebar" import { HomeworkTakeConfirmDialog } from "./homework-take-confirm-dialog" 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 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")) } const handleSubmit = async () => { if (!submissionId) return setIsBusy(true) try { // P2-9: 提交前 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(" ") } const handleQuestionJump = (questionId: string) => { const el = document.getElementById(`question-${questionId}`) if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }) } 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")}

)}
setShowSubmitConfirm(true)} onQuestionJump={handleQuestionJump} />
) }