- Update attendance components and data-access for record management - Update audit log views, filters, and data-access - Update auth login and register forms - Update classes actions, components, and data-access (admin, schedule, stats) - Update course-plans actions, form, list, progress, and schema - Update exams actions, AI pipeline, preview components, and hooks - Update files components (icon, list, preview, upload) and data-access - Update homework assignment form, review view, auto-save hook, and stats-service - Update layout sidebar, header, and navigation config - Update proctoring actions, anti-cheat monitor, and data-access - Update questions actions, components (dialog, actions, columns, filters), and data-access - Update scheduling actions, auto-scheduler, components, and schema - Update textbooks constants and text-selection hook - Update users class-registration, import-dialog, data-access, and user-service
241 lines
9.3 KiB
TypeScript
241 lines
9.3 KiB
TypeScript
"use client"
|
|
|
|
import { useMemo, useState } from "react"
|
|
import { useFormStatus } from "react-dom"
|
|
import { toast } from "sonner"
|
|
import { useRouter } from "next/navigation"
|
|
import { useSearchParams } from "next/navigation"
|
|
import { useTranslations } from "next-intl"
|
|
import { FileText, FileQuestion } from "lucide-react"
|
|
|
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
|
import { Button } from "@/shared/components/ui/button"
|
|
import { Input } from "@/shared/components/ui/input"
|
|
import { Label } from "@/shared/components/ui/label"
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
|
import { Textarea } from "@/shared/components/ui/textarea"
|
|
import { cn } from "@/shared/lib/utils"
|
|
|
|
import { createHomeworkAssignmentAction } from "../actions"
|
|
import type { TeacherClass } from "@/modules/classes/types"
|
|
|
|
type ExamOption = { id: string; title: string }
|
|
|
|
function SubmitButton() {
|
|
const { pending } = useFormStatus()
|
|
const t = useTranslations("examHomework")
|
|
return (
|
|
<Button type="submit" disabled={pending}>
|
|
{pending ? t("homework.form.submitting") : t("homework.form.submit")}
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
export function HomeworkAssignmentForm({ exams, classes }: { exams: ExamOption[]; classes: TeacherClass[] }) {
|
|
const router = useRouter()
|
|
const searchParams = useSearchParams()
|
|
const t = useTranslations("examHomework")
|
|
|
|
const [mode, setMode] = useState<"exam" | "quick">(exams.length > 0 ? "exam" : "quick")
|
|
const initialExamId = useMemo(() => exams[0]?.id ?? "", [exams])
|
|
const [examId, setExamId] = useState<string>(initialExamId)
|
|
const initialClassId = useMemo(() => {
|
|
const fromQuery = searchParams.get("classId") || ""
|
|
if (fromQuery && classes.some((c) => c.id === fromQuery)) return fromQuery
|
|
return classes[0]?.id ?? ""
|
|
}, [classes, searchParams])
|
|
const [classId, setClassId] = useState<string>(initialClassId)
|
|
const [allowLate, setAllowLate] = useState<boolean>(false)
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
|
|
const handleSubmit = async (formData: FormData) => {
|
|
if (mode === "exam" && !examId) {
|
|
toast.error(t("homework.form.selectExamRequired"))
|
|
return
|
|
}
|
|
if (mode === "quick" && !formData.get("title")) {
|
|
toast.error(t("homework.form.titleRequired"))
|
|
return
|
|
}
|
|
if (!classId) {
|
|
toast.error(t("homework.form.selectClassRequired"))
|
|
return
|
|
}
|
|
|
|
if (mode === "exam") {
|
|
formData.set("sourceExamId", examId)
|
|
} else {
|
|
formData.delete("sourceExamId")
|
|
}
|
|
formData.set("classId", classId)
|
|
formData.set("allowLate", allowLate ? "true" : "false")
|
|
formData.set("publish", "true")
|
|
|
|
setIsSubmitting(true)
|
|
try {
|
|
const result = await createHomeworkAssignmentAction(null, formData)
|
|
if (result.success) {
|
|
toast.success(result.message)
|
|
router.push("/teacher/homework/assignments")
|
|
} else {
|
|
toast.error(result.message || t("homework.form.createFailed"))
|
|
}
|
|
} catch {
|
|
toast.error(t("homework.form.createFailed"))
|
|
} finally {
|
|
setIsSubmitting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Card className="relative">
|
|
{isSubmitting && (
|
|
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-background/60 backdrop-blur-sm">
|
|
<div className="flex items-center gap-2 text-sm font-medium">
|
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
|
{t("homework.form.creating")}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<CardHeader>
|
|
<CardTitle>{t("homework.form.createTitle")}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form action={handleSubmit} className="space-y-6">
|
|
{/* 模式切换 */}
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setMode("quick")}
|
|
className={cn(
|
|
"flex items-center gap-2 rounded-md border p-3 text-left transition-colors",
|
|
mode === "quick"
|
|
? "border-primary bg-primary/5 text-primary"
|
|
: "border-border hover:bg-muted/50"
|
|
)}
|
|
>
|
|
<FileText className="h-4 w-4 shrink-0" aria-hidden="true" />
|
|
<div>
|
|
<div className="text-sm font-medium">{t("homework.form.quickMode")}</div>
|
|
<div className="text-xs text-muted-foreground">{t("homework.form.quickModeDescription")}</div>
|
|
</div>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setMode("exam")}
|
|
disabled={exams.length === 0}
|
|
className={cn(
|
|
"flex items-center gap-2 rounded-md border p-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
|
mode === "exam"
|
|
? "border-primary bg-primary/5 text-primary"
|
|
: "border-border hover:bg-muted/50"
|
|
)}
|
|
>
|
|
<FileQuestion className="h-4 w-4 shrink-0" aria-hidden="true" />
|
|
<div>
|
|
<div className="text-sm font-medium">{t("homework.form.examMode")}</div>
|
|
<div className="text-xs text-muted-foreground">{t("homework.form.examModeDescription")}</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<div className="grid gap-2 md:col-span-2">
|
|
<Label>{t("homework.form.class")}</Label>
|
|
<Select value={classId} onValueChange={setClassId}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder={t("homework.form.selectClass")} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{classes.map((c) => (
|
|
<SelectItem key={c.id} value={c.id}>
|
|
{c.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<input type="hidden" name="classId" value={classId} />
|
|
</div>
|
|
|
|
{mode === "exam" && (
|
|
<div className="grid gap-2 md:col-span-2">
|
|
<Label>{t("homework.form.sourceExam")}</Label>
|
|
<Select value={examId} onValueChange={setExamId}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder={t("homework.form.selectExam")} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{exams.map((e) => (
|
|
<SelectItem key={e.id} value={e.id}>
|
|
{e.title}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<input type="hidden" name="sourceExamId" value={examId} />
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid gap-2 md:col-span-2">
|
|
<Label htmlFor="title">
|
|
{t("homework.form.assignmentTitle")} {mode === "quick" && <span className="text-destructive">*</span>}
|
|
</Label>
|
|
<Input
|
|
id="title"
|
|
name="title"
|
|
placeholder={mode === "exam" ? t("homework.form.titlePlaceholderExam") : t("homework.form.titlePlaceholderQuick")}
|
|
required={mode === "quick"}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-2 md:col-span-2">
|
|
<Label htmlFor="description">{t("homework.form.description")}</Label>
|
|
<Textarea
|
|
id="description"
|
|
name="description"
|
|
className="min-h-[80px]"
|
|
placeholder={mode === "quick" ? t("homework.form.descriptionPlaceholderQuick") : undefined}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="availableAt">{t("homework.form.availableAt")}</Label>
|
|
<Input id="availableAt" name="availableAt" type="datetime-local" />
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="dueAt">{t("homework.form.dueAt")}</Label>
|
|
<Input id="dueAt" name="dueAt" type="datetime-local" />
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 md:col-span-2">
|
|
<input
|
|
id="allowLate"
|
|
type="checkbox"
|
|
checked={allowLate}
|
|
onChange={(e) => setAllowLate(e.target.checked)}
|
|
/>
|
|
<Label htmlFor="allowLate">{t("homework.form.allowLate")}</Label>
|
|
<input type="hidden" name="allowLate" value={allowLate ? "true" : "false"} />
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="lateDueAt">{t("homework.form.lateDueAt")}</Label>
|
|
<Input id="lateDueAt" name="lateDueAt" type="datetime-local" />
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="maxAttempts">{t("homework.form.maxAttempts")}</Label>
|
|
<Input id="maxAttempts" name="maxAttempts" type="number" min={1} max={20} defaultValue={1} />
|
|
</div>
|
|
</div>
|
|
|
|
<CardFooter className="justify-end">
|
|
<SubmitButton />
|
|
</CardFooter>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|