feat(exam-homework): add audit report, i18n, error boundaries, and permission hardening

- Add comprehensive audit report for exam and homework module

- Create exam-homework i18n message files (zh-CN + en) and register namespace

- Add permission check to gradeHomeworkSubmissionAction to prevent horizontal privilege escalation

- Add Error Boundary + loading.tsx for 5 key pages (exam build/proctoring, homework assignment/submissions, student assignment)

- Refactor exam-columns to createExamColumns(t) factory for i18n support

- Refactor exam-data-table to manage columns internally via useTranslations

- Replace hardcoded strings with i18n keys in all exam/homework components and pages

- Add getHomeworkSubmissionForGrading data-access for secure grading flow
This commit is contained in:
SpecialX
2026-06-22 16:08:39 +08:00
parent fde711ce46
commit 21c7e65fee
26 changed files with 2059 additions and 463 deletions

View File

@@ -5,6 +5,8 @@ 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"
@@ -12,6 +14,7 @@ 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"
@@ -20,9 +23,10 @@ type ExamOption = { id: string; title: string }
function SubmitButton() {
const { pending } = useFormStatus()
const t = useTranslations("examHomework")
return (
<Button type="submit" disabled={pending}>
{pending ? "Creating..." : "Create Assignment"}
{pending ? t("homework.form.submitting") : t("homework.form.submit")}
</Button>
)
}
@@ -30,7 +34,9 @@ function SubmitButton() {
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(() => {
@@ -40,43 +46,100 @@ export function HomeworkAssignmentForm({ exams, classes }: { exams: ExamOption[]
}, [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 (!examId) {
toast.error("Please select an exam")
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("Please select a class")
toast.error(t("homework.form.selectClassRequired"))
return
}
formData.set("sourceExamId", examId)
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)
const result = await createHomeworkAssignmentAction(null, formData)
setIsSubmitting(false)
if (result.success) {
toast.success(result.message)
router.push("/teacher/homework/assignments")
} else {
toast.error(result.message || "Failed to create")
toast.error(result.message || t("homework.form.createFailed"))
}
}
return (
<Card>
<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>Create Assignment</CardTitle>
<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>Class</Label>
<Label>{t("homework.form.class")}</Label>
<Select value={classId} onValueChange={setClassId}>
<SelectTrigger>
<SelectValue placeholder="Select a class" />
<SelectValue placeholder={t("homework.form.selectClass")} />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
@@ -89,40 +152,54 @@ export function HomeworkAssignmentForm({ exams, classes }: { exams: ExamOption[]
<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>Source Exam</Label>
<Select value={examId} onValueChange={setExamId}>
<SelectTrigger>
<SelectValue placeholder="Select an exam" />
</SelectTrigger>
<SelectContent>
{exams.map((e) => (
<SelectItem key={e.id} value={e.id}>
{e.title}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="sourceExamId" value={examId} />
<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="title">Assignment Title (optional)</Label>
<Input id="title" name="title" placeholder="Defaults to exam title" />
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="description">Description (optional)</Label>
<Textarea id="description" name="description" className="min-h-[80px]" />
<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">Available At (optional)</Label>
<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">Due At (optional)</Label>
<Label htmlFor="dueAt">{t("homework.form.dueAt")}</Label>
<Input id="dueAt" name="dueAt" type="datetime-local" />
</div>
@@ -133,29 +210,19 @@ export function HomeworkAssignmentForm({ exams, classes }: { exams: ExamOption[]
checked={allowLate}
onChange={(e) => setAllowLate(e.target.checked)}
/>
<Label htmlFor="allowLate">Allow late submissions</Label>
<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">Late Due At (optional)</Label>
<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">Max Attempts</Label>
<Label htmlFor="maxAttempts">{t("homework.form.maxAttempts")}</Label>
<Input id="maxAttempts" name="maxAttempts" type="number" min={1} max={20} defaultValue={1} />
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="targetStudentIdsText">Target student IDs (optional)</Label>
<Textarea
id="targetStudentIdsText"
name="targetStudentIdsText"
placeholder="Optional. If provided, targets will be limited to students in the selected class."
className="min-h-[90px]"
/>
</div>
</div>
<CardFooter className="justify-end">