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:
SpecialX
2026-07-03 10:25:35 +08:00
parent 20023e13fd
commit dfffb61e94
82 changed files with 6100 additions and 3321 deletions

View File

@@ -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>
)
}