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
This commit is contained in:
@@ -9,20 +9,9 @@ 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 { 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"
|
||||
@@ -31,6 +20,8 @@ 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
|
||||
@@ -168,7 +159,6 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
|
||||
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)
|
||||
@@ -177,7 +167,6 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
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 () => {
|
||||
@@ -185,14 +174,12 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
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")
|
||||
@@ -232,7 +219,6 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
startedAt: initialData.submission?.startedAt ?? null,
|
||||
enabled: isTimedExam,
|
||||
onExpire: () => {
|
||||
// 到时自动提交(仅触发一次)
|
||||
if (submissionStatus === "started" && submissionId) {
|
||||
toast.warning(t("homework.take.timeUpAutoSubmit"))
|
||||
void handleSubmit()
|
||||
@@ -249,6 +235,11 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
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">
|
||||
@@ -405,153 +396,33 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-3 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||
<div className="border-b p-4 bg-muted/30">
|
||||
<h3 className="font-semibold">{t("homework.take.assignmentInfo")}</h3>
|
||||
{canEdit && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground" role="status" aria-live="polite">
|
||||
{autoSave.status === "saving" && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
{autoSave.status === "saved" && <Check className="h-3 w-3 text-green-500" />}
|
||||
{autoSave.status === "error" && <CloudOff className="h-3 w-3 text-destructive" />}
|
||||
{autoSave.status === "idle" && <CloudUpload className="h-3 w-3" />}
|
||||
<span className={
|
||||
autoSave.status === "saved" ? "text-green-600" :
|
||||
autoSave.status === "error" ? "text-destructive" :
|
||||
"text-muted-foreground"
|
||||
}>
|
||||
{t(`homework.take.autoSave${autoSave.status.charAt(0).toUpperCase()}${autoSave.status.slice(1)}`)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 p-4 overflow-y-auto">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.status")}</Label>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Badge variant={submissionStatus === "started" ? "default" : "outline"} className="capitalize">
|
||||
{submissionStatus === "not_started" ? t("homework.take.notStarted") : submissionStatus}
|
||||
</Badge>
|
||||
</div>
|
||||
</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}
|
||||
/>
|
||||
|
||||
{dueAt && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.dueDate")}</Label>
|
||||
<div className={cn(
|
||||
"mt-1 flex items-center gap-2 text-sm font-medium",
|
||||
isOverdue ? "text-destructive" : isUrgent ? "text-orange-500" : "text-foreground"
|
||||
)}>
|
||||
{(isOverdue || isUrgent) && <TriangleAlert className="h-4 w-4" />}
|
||||
<span>{formatDate(dueAt)}</span>
|
||||
</div>
|
||||
{isOverdue && (
|
||||
<p className="mt-1 text-xs text-destructive">{t("homework.take.overdue")}</p>
|
||||
)}
|
||||
{isUrgent && !isOverdue && hoursUntilDue !== null && (
|
||||
<p className="mt-1 text-xs text-orange-500">
|
||||
{hoursUntilDue === 0
|
||||
? t("homework.take.lessThanOneHour")
|
||||
: t("homework.take.hoursLeft", { hours: hoursUntilDue })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{maxAttempts > 0 && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.attempts")}</Label>
|
||||
<div className="mt-1 text-sm">
|
||||
<span className="font-medium">{attemptsUsed}</span>
|
||||
<span className="text-muted-foreground"> {t("homework.take.attemptsUsed", { used: attemptsUsed, max: maxAttempts })}</span>
|
||||
{attemptsRemaining > 0 && (
|
||||
<span className="text-muted-foreground"> {t("homework.take.attemptsRemaining", { remaining: attemptsRemaining })}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.description")}</Label>
|
||||
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
|
||||
{initialData.assignment.description || t("homework.take.noDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showQuestions && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.progress")}</Label>
|
||||
<div className="mt-2 grid grid-cols-5 gap-2">
|
||||
{initialData.questions.map((q, i) => {
|
||||
const answer = answersByQuestionId[q.questionId]?.answer
|
||||
const hasAnswer = answer !== undefined &&
|
||||
answer !== "" &&
|
||||
(Array.isArray(answer) ? answer.length > 0 : true)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={q.questionId}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const el = document.getElementById(`question-${q.questionId}`)
|
||||
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||
}}
|
||||
className={cn(
|
||||
"h-11 w-11 sm:h-8 sm:w-8 rounded flex items-center justify-center text-xs font-medium border transition-colors hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
||||
hasAnswer ? "bg-primary text-primary-foreground border-primary" : "bg-background text-muted-foreground border-input"
|
||||
)}
|
||||
aria-label={t("homework.take.jumpToQuestion", { index: i + 1 })}
|
||||
aria-pressed={hasAnswer}
|
||||
title={hasAnswer ? t("homework.take.answered") : t("homework.take.unanswered")}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<div className="border-t p-4 bg-muted/20">
|
||||
<Button className="w-full" onClick={() => setShowSubmitConfirm(true)} disabled={isBusy}>
|
||||
{isBusy ? t("homework.take.submitting") : t("homework.take.submitAll")}
|
||||
</Button>
|
||||
<p className="mt-2 text-xs text-center text-muted-foreground">
|
||||
{t("homework.take.makeSureAnswered")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 提交二次确认对话框 */}
|
||||
<AlertDialog open={showSubmitConfirm} onOpenChange={setShowSubmitConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("homework.take.confirmSubmit")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{unansweredCount > 0
|
||||
? t("homework.take.unansweredWarning", { count: unansweredCount })
|
||||
: t("homework.take.confirmSubmitDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isBusy}>{t("homework.take.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={isBusy}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setShowSubmitConfirm(false)
|
||||
void handleSubmit()
|
||||
}}
|
||||
>
|
||||
{isBusy ? t("homework.take.submitting") : t("homework.take.confirmSubmitAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<HomeworkTakeConfirmDialog
|
||||
open={showSubmitConfirm}
|
||||
onOpenChange={setShowSubmitConfirm}
|
||||
unansweredCount={unansweredCount}
|
||||
isBusy={isBusy}
|
||||
onConfirm={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user