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
429 lines
17 KiB
TypeScript
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>
|
|
)
|
|
}
|