Files
NextEdu/src/modules/homework/components/homework-take-view.tsx
SpecialX dfffb61e94 feat(homework,classes,course-plans): add scans, student data, take confirm, error boundaries, dialogs, hooks, calendar
homework:

- Add data-access-scans, data-access-student, data-access-utils, data-access-exam-cross

- Add excellent-submissions, homework-take-confirm-dialog, homework-take-sidebar components

classes:

- Add class-delete-dialog, class-error-boundary, class-form-dialog, class-form-utils

- Add class-list-table, class-list-toolbar, class-skeleton

- Add schedule-create-dialog, schedule-delete-dialog, schedule-edit-dialog, schedule-utils

- Add data-access-teacher and hooks directory

course-plans:

- Add course-plan-calendar, sortable-week-row, template-picker-dialog components

- Add lib directory
2026-07-03 10:25:35 +08:00

429 lines
17 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 { 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<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 [scanImages, setScanImages] = useState<ScanImage[]>([])
// 加载已有答题扫描图(拍照上传)
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<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
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 (
<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 ? (
<div className="flex items-center gap-3">
{isTimedExam && examModeConfig && (
<div className="flex items-center gap-1.5 rounded-md border border-orange-200 bg-orange-50 px-3 py-1.5 text-xs text-orange-700 dark:border-orange-900 dark:bg-orange-950 dark:text-orange-300">
<Timer className="h-3.5 w-3.5" />
<span className="font-medium">
{t("homework.take.timedExam", { minutes: examModeConfig.durationMinutes ?? 0 })}
</span>
</div>
)}
<Button onClick={handleStart} disabled={isBusy} size="sm">
{isBusy ? t("homework.take.starting") : t("homework.take.startAssignment")}
</Button>
</div>
) : (
<div className="flex items-center gap-3">
{countdown && (
<div
className={cn(
"flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-semibold tabular-nums",
countdown.isExpired
? "border-destructive bg-destructive/10 text-destructive"
: countdown.isUrgent
? "border-destructive bg-destructive/5 text-destructive animate-pulse"
: "border-muted-foreground/20 bg-muted/50 text-foreground"
)}
role="timer"
aria-live="polite"
aria-label={t("homework.take.timeRemaining")}
>
<Timer className="h-4 w-4" />
<span>{formatCountdown(countdown)}</span>
</div>
)}
<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>
)}
</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>
)
})}
{showQuestions && submissionId && (
<Card className="border-l-4 border-l-blue-500 shadow-sm">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<Camera className="h-4 w-4 text-blue-500" />
<h3 className="font-semibold text-sm">{t("homework.take.scanTitle")}</h3>
</div>
<p className="text-xs text-muted-foreground mt-1">
{t("homework.take.scanDescription")}
</p>
<div className="mt-3">
<ScanUploader
images={scanImages}
onChange={setScanImages}
onDeleteScan={handleDeleteScan}
submissionId={submissionId}
disabled={!canEdit}
/>
</div>
</CardHeader>
</Card>
)}
</div>
</ScrollArea>
</div>
<HomeworkTakeSidebar
assignment={initialData.assignment}
questions={initialData.questions}
answersByQuestionId={answersByQuestionId}
submissionStatus={submissionStatus}
canEdit={canEdit}
isBusy={isBusy}
showQuestions={showQuestions}
autoSaveStatus={autoSave.status}
dueAt={dueAt}
isOverdue={isOverdue}
isUrgent={isUrgent}
hoursUntilDue={hoursUntilDue}
maxAttempts={maxAttempts}
attemptsUsed={attemptsUsed}
attemptsRemaining={attemptsRemaining}
onSubmitClick={() => setShowSubmitConfirm(true)}
onQuestionJump={handleQuestionJump}
/>
<HomeworkTakeConfirmDialog
open={showSubmitConfirm}
onOpenChange={setShowSubmitConfirm}
unansweredCount={unansweredCount}
isBusy={isBusy}
onConfirm={handleSubmit}
/>
</div>
)
}