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:
@@ -13,6 +13,7 @@ import {
|
||||
getActiveClassStudentIdsForHomework,
|
||||
getClassTeacherById,
|
||||
getExamWithQuestionsForHomework,
|
||||
getHomeworkSubmissionForGrading,
|
||||
getHomeworkSubmissionForPermission,
|
||||
getTeacherAssignedSubjectIds,
|
||||
gradeHomeworkAnswers,
|
||||
@@ -71,20 +72,29 @@ export async function createHomeworkAssignmentAction(
|
||||
const classRow = await getClassTeacherById(input.classId)
|
||||
if (!classRow) return { success: false, message: "Class not found" }
|
||||
|
||||
const exam = await getExamWithQuestionsForHomework(input.sourceExamId)
|
||||
if (!exam) return { success: false, message: "Exam not found" }
|
||||
// 快速作业模式:无 sourceExamId 时创建纯文本作业(无题目)
|
||||
const isQuickAssignment = !input.sourceExamId
|
||||
|
||||
let exam: Awaited<ReturnType<typeof getExamWithQuestionsForHomework>> = null
|
||||
if (!isQuickAssignment) {
|
||||
const examData = await getExamWithQuestionsForHomework(input.sourceExamId!)
|
||||
if (!examData) return { success: false, message: "Exam not found" }
|
||||
exam = examData
|
||||
}
|
||||
|
||||
if (ctx.dataScope.type !== "all" && classRow.teacherId !== ctx.userId) {
|
||||
const assignedSubjectIds = await getTeacherAssignedSubjectIds(input.classId, ctx.userId)
|
||||
if (assignedSubjectIds.length === 0) {
|
||||
return { success: false, message: "Not assigned to this class" }
|
||||
}
|
||||
const assignedSubjectSet = new Set(assignedSubjectIds)
|
||||
if (!exam.subjectId) {
|
||||
return { success: false, message: "Exam subject not set" }
|
||||
}
|
||||
if (!assignedSubjectSet.has(exam.subjectId)) {
|
||||
return { success: false, message: "Not assigned to this subject" }
|
||||
if (!isQuickAssignment && exam) {
|
||||
const assignedSubjectSet = new Set(assignedSubjectIds)
|
||||
if (!exam.subjectId) {
|
||||
return { success: false, message: "Exam subject not set" }
|
||||
}
|
||||
if (!assignedSubjectSet.has(exam.subjectId)) {
|
||||
return { success: false, message: "Not assigned to this subject" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,10 +122,10 @@ export async function createHomeworkAssignmentAction(
|
||||
|
||||
await createHomeworkAssignment({
|
||||
assignmentId,
|
||||
sourceExamId: input.sourceExamId,
|
||||
title: input.title?.trim().length ? input.title.trim() : exam.title,
|
||||
sourceExamId: input.sourceExamId ?? null,
|
||||
title: input.title?.trim().length ? input.title.trim() : (exam?.title ?? "Untitled Assignment"),
|
||||
description: input.description ?? null,
|
||||
structure: exam.structure,
|
||||
structure: exam?.structure ?? [],
|
||||
status: publish ? "published" : "draft",
|
||||
creatorId: ctx.userId,
|
||||
availableAt,
|
||||
@@ -124,7 +134,7 @@ export async function createHomeworkAssignmentAction(
|
||||
lateDueAt,
|
||||
maxAttempts: input.maxAttempts ?? 1,
|
||||
publish,
|
||||
questions: exam.questions,
|
||||
questions: exam?.questions ?? [],
|
||||
targetStudentIds,
|
||||
})
|
||||
|
||||
@@ -242,7 +252,7 @@ export async function gradeHomeworkSubmissionAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.HOMEWORK_GRADE)
|
||||
const ctx = await requirePermission(Permissions.HOMEWORK_GRADE)
|
||||
|
||||
const rawAnswersValue = formData.get("answersJson")
|
||||
const rawAnswers = typeof rawAnswersValue === "string" ? rawAnswersValue : null
|
||||
@@ -261,6 +271,18 @@ export async function gradeHomeworkSubmissionAction(
|
||||
|
||||
const { submissionId, answers } = parsed.data
|
||||
|
||||
// 权限二次校验:非管理员仅可批改自己创建的作业提交
|
||||
// 管理员(dataScope.type === "all")可批改所有提交
|
||||
if (ctx.dataScope.type !== "all") {
|
||||
const submissionForGrading = await getHomeworkSubmissionForGrading(submissionId)
|
||||
if (!submissionForGrading) {
|
||||
return { success: false, message: "Submission not found" }
|
||||
}
|
||||
if (submissionForGrading.creatorId !== ctx.userId) {
|
||||
return { success: false, message: "You can only grade submissions for your own assignments" }
|
||||
}
|
||||
}
|
||||
|
||||
await gradeHomeworkAnswers(
|
||||
submissionId,
|
||||
answers.map((ans) => ({
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
Check,
|
||||
MessageSquarePlus,
|
||||
X,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Save,
|
||||
User,
|
||||
import {
|
||||
Check,
|
||||
MessageSquarePlus,
|
||||
X,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Save,
|
||||
User,
|
||||
AlertCircle,
|
||||
Clock
|
||||
} from "lucide-react"
|
||||
@@ -24,6 +25,7 @@ import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/components/ui/tooltip"
|
||||
import { gradeHomeworkSubmissionAction } from "../actions"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
|
||||
|
||||
@@ -63,6 +65,7 @@ export function HomeworkGradingView({
|
||||
submittedAt,
|
||||
}: HomeworkGradingViewProps) {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("examHomework")
|
||||
const [answers, setAnswers] = useState(() => applyAutoGrades(initialAnswers))
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
@@ -129,11 +132,11 @@ export function HomeworkGradingView({
|
||||
const result = await gradeHomeworkSubmissionAction(null, formData)
|
||||
|
||||
if (result.success) {
|
||||
toast.success("Grading saved successfully")
|
||||
toast.success(t("homework.grade.gradesSaved"))
|
||||
// Optionally redirect or stay
|
||||
router.refresh()
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || "Failed to save grading")
|
||||
toast.error(result.message || t("homework.grade.gradesSaveFailed"))
|
||||
}
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
@@ -167,11 +170,11 @@ export function HomeworkGradingView({
|
||||
{ans.questionType.replace("_", " ")}
|
||||
</span>
|
||||
{isAutoGradable(ans) && (
|
||||
<Badge variant="secondary" className="text-[10px] h-5">Auto-graded</Badge>
|
||||
<Badge variant="secondary" className="text-[10px] h-5">{t("homework.grade.autoGraded")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-base font-medium leading-relaxed pt-2">
|
||||
{ans.questionContent?.text || "No question text"}
|
||||
{ans.questionContent?.text || t("homework.grade.noQuestionText")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
@@ -188,7 +191,7 @@ export function HomeworkGradingView({
|
||||
{/* Student Answer Display */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
|
||||
<User className="h-3 w-3" /> Student Answer
|
||||
<User className="h-3 w-3" /> {t("homework.grade.studentAnswer")}
|
||||
</Label>
|
||||
|
||||
<div className="rounded-md border bg-background p-4 shadow-sm">
|
||||
@@ -250,10 +253,10 @@ export function HomeworkGradingView({
|
||||
{ans.questionType === "text" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold text-emerald-600/90 uppercase tracking-wider flex items-center gap-2">
|
||||
<Check className="h-3 w-3" /> Reference Answer
|
||||
<Check className="h-3 w-3" /> {t("homework.grade.referenceAnswer")}
|
||||
</Label>
|
||||
<div className="rounded-md border border-emerald-200 bg-emerald-50/30 p-4 text-sm text-muted-foreground dark:border-emerald-900/50 dark:bg-emerald-950/10">
|
||||
{getTextCorrectAnswers(ans.questionContent).join(" / ") || "No reference answer provided."}
|
||||
{getTextCorrectAnswers(ans.questionContent).join(" / ") || t("homework.grade.noReferenceAnswer")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -270,7 +273,7 @@ export function HomeworkGradingView({
|
||||
className={getCorrectnessState(ans) === "correct" ? "bg-emerald-600 hover:bg-emerald-700 text-white border-transparent" : "text-muted-foreground hover:text-emerald-600 hover:border-emerald-200"}
|
||||
onClick={() => handleMarkCorrect(ans.id)}
|
||||
>
|
||||
<Check className="mr-1 h-4 w-4" /> Correct
|
||||
<Check className="mr-1 h-4 w-4" /> {t("homework.grade.correctButton")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={getCorrectnessState(ans) === "incorrect" ? "destructive" : "outline"}
|
||||
@@ -278,14 +281,14 @@ export function HomeworkGradingView({
|
||||
className={getCorrectnessState(ans) === "incorrect" ? "" : "text-muted-foreground hover:text-red-600 hover:border-red-200"}
|
||||
onClick={() => handleMarkIncorrect(ans.id)}
|
||||
>
|
||||
<X className="mr-1 h-4 w-4" /> Incorrect
|
||||
<X className="mr-1 h-4 w-4" /> {t("homework.grade.incorrectButton")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="h-6 hidden sm:block" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={`score-${ans.id}`} className="whitespace-nowrap text-sm font-medium">Score:</Label>
|
||||
<Label htmlFor={`score-${ans.id}`} className="whitespace-nowrap text-sm font-medium">{t("homework.grade.scoreLabel")}:</Label>
|
||||
<Input
|
||||
id={`score-${ans.id}`}
|
||||
type="number"
|
||||
@@ -307,7 +310,7 @@ export function HomeworkGradingView({
|
||||
onClick={() => setShowFeedbackByAnswerId(prev => ({ ...prev, [ans.id]: !prev[ans.id] }))}
|
||||
>
|
||||
<MessageSquarePlus className="mr-2 h-4 w-4" />
|
||||
{showFeedbackByAnswerId[ans.id] ? "Hide Feedback" : "Add Feedback"}
|
||||
{showFeedbackByAnswerId[ans.id] ? t("homework.grade.hideFeedback") : t("homework.grade.addFeedback")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -315,7 +318,7 @@ export function HomeworkGradingView({
|
||||
{showFeedbackByAnswerId[ans.id] && (
|
||||
<div className="w-full animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<Textarea
|
||||
placeholder={`Provide feedback for ${studentName}...`}
|
||||
placeholder={t("homework.grade.feedbackPlaceholder", { name: studentName })}
|
||||
value={ans.feedback ?? ""}
|
||||
onChange={(e) => handleFeedbackChange(ans.id, e.target.value)}
|
||||
className="min-h-[80px] bg-background"
|
||||
@@ -333,19 +336,19 @@ export function HomeworkGradingView({
|
||||
<div className="lg:col-span-3 h-full flex flex-col gap-6">
|
||||
<Card className="flex flex-col shadow-md border-t-4 border-t-primary">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg">Grading Summary</CardTitle>
|
||||
<CardTitle className="text-lg">{t("homework.grade.gradingSummary")}</CardTitle>
|
||||
<CardDescription>{assignmentTitle}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Total Score</span>
|
||||
<span className="text-muted-foreground">{t("homework.grade.totalScore")}</span>
|
||||
<span className="font-bold">{currentTotal} / {maxTotal}</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-500 ease-in-out"
|
||||
style={{ width: `${Math.min(100, Math.max(0, progressPercent))}%` }}
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-500 ease-in-out"
|
||||
style={{ width: `${Math.min(100, Math.max(0, progressPercent))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -353,16 +356,16 @@ export function HomeworkGradingView({
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<User className="h-4 w-4" /> Student
|
||||
<User className="h-4 w-4" /> {t("homework.grade.student")}
|
||||
</span>
|
||||
<span className="font-medium">{studentName}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<Clock className="h-4 w-4" /> Submitted
|
||||
<Clock className="h-4 w-4" /> {t("homework.grade.submitted")}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{submittedAt ? new Date(submittedAt).toLocaleDateString() : "N/A"}
|
||||
{submittedAt ? formatDate(submittedAt) : "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -372,15 +375,15 @@ export function HomeworkGradingView({
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="flex flex-col items-center justify-center rounded-md border bg-emerald-50/50 p-2 dark:bg-emerald-950/20">
|
||||
<span className="text-2xl font-bold text-emerald-600">{correctCount}</span>
|
||||
<span className="text-xs text-muted-foreground">Correct</span>
|
||||
<span className="text-xs text-muted-foreground">{t("homework.grade.correct")}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center rounded-md border bg-red-50/50 p-2 dark:bg-red-950/20">
|
||||
<span className="text-2xl font-bold text-red-600">{incorrectCount}</span>
|
||||
<span className="text-xs text-muted-foreground">Incorrect</span>
|
||||
<span className="text-xs text-muted-foreground">{t("homework.grade.incorrect")}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center rounded-md border bg-amber-50/50 p-2 dark:bg-amber-950/20">
|
||||
<span className="text-2xl font-bold text-amber-600">{partialCount}</span>
|
||||
<span className="text-xs text-muted-foreground">Partial</span>
|
||||
<span className="text-xs text-muted-foreground">{t("homework.grade.partial")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -388,7 +391,7 @@ export function HomeworkGradingView({
|
||||
|
||||
<div>
|
||||
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3 block">
|
||||
Question Status
|
||||
{t("homework.grade.questionStatus")}
|
||||
</Label>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{answers.map((ans, i) => {
|
||||
@@ -424,15 +427,15 @@ export function HomeworkGradingView({
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>Saving...</>
|
||||
<>{t("homework.grade.saving")}</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" /> Submit Grades
|
||||
<Save className="mr-2 h-4 w-4" /> {t("homework.grade.submitGrades")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</Button>
|
||||
|
||||
<div className="flex w-full items-center justify-between gap-2 pt-2">
|
||||
<div className="flex w-full items-center justify-between gap-2 pt-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -442,10 +445,10 @@ export function HomeworkGradingView({
|
||||
disabled={!prevSubmissionId}
|
||||
onClick={() => prevSubmissionId && router.push(`/teacher/homework/submissions/${prevSubmissionId}`)}
|
||||
>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" /> Prev
|
||||
<ChevronLeft className="mr-1 h-4 w-4" /> {t("homework.grade.prev")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Previous Student</TooltipContent>
|
||||
<TooltipContent>{t("homework.grade.previousStudent")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
@@ -457,10 +460,10 @@ export function HomeworkGradingView({
|
||||
disabled={!nextSubmissionId}
|
||||
onClick={() => nextSubmissionId && router.push(`/teacher/homework/submissions/${nextSubmissionId}`)}
|
||||
>
|
||||
Next <ChevronRight className="ml-1 h-4 w-4" />
|
||||
{t("homework.grade.next")} <ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Next Student</TooltipContent>
|
||||
<TooltipContent>{t("homework.grade.nextStudent")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</CardFooter>
|
||||
@@ -470,7 +473,7 @@ export function HomeworkGradingView({
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<p>
|
||||
Grades are saved automatically when you click Submit. Students will see their grades and feedback immediately after you submit.
|
||||
{t("homework.grade.gradesAutoSaveNote")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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 { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
|
||||
|
||||
@@ -73,6 +74,7 @@ type HomeworkTakeViewProps = {
|
||||
|
||||
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)
|
||||
@@ -128,10 +130,10 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
if (res.success && res.data) {
|
||||
setSubmissionId(res.data)
|
||||
setSubmissionStatus("started")
|
||||
toast.success("Started")
|
||||
toast.success(t("homework.take.startSuccess"))
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to start")
|
||||
toast.error(res.message || t("homework.take.startFailed"))
|
||||
}
|
||||
setIsBusy(false)
|
||||
}
|
||||
@@ -145,8 +147,8 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
fd.set("questionId", questionId)
|
||||
fd.set("answerJson", JSON.stringify({ answer: payload }))
|
||||
const res = await saveHomeworkAnswerAction(null, fd)
|
||||
if (res.success) toast.success("Saved")
|
||||
else toast.error(res.message || "Failed to save")
|
||||
if (res.success) toast.success(t("homework.take.saved"))
|
||||
else toast.error(res.message || t("homework.take.saveFailed"))
|
||||
// setIsBusy(false)
|
||||
}
|
||||
|
||||
@@ -162,7 +164,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
fd.set("answerJson", JSON.stringify({ answer: payload }))
|
||||
const res = await saveHomeworkAnswerAction(null, fd)
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "Failed to save")
|
||||
toast.error(res.message || t("homework.take.saveFailed"))
|
||||
setIsBusy(false)
|
||||
return
|
||||
}
|
||||
@@ -172,11 +174,11 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
submitFd.set("submissionId", submissionId)
|
||||
const submitRes = await submitHomeworkAction(null, submitFd)
|
||||
if (submitRes.success) {
|
||||
toast.success("Submitted")
|
||||
toast.success(t("homework.take.submitSuccess"))
|
||||
setSubmissionStatus("submitted")
|
||||
router.push("/student/learning/assignments")
|
||||
} else {
|
||||
toast.error(submitRes.message || "Failed to submit")
|
||||
toast.error(submitRes.message || t("homework.take.submitFailed"))
|
||||
}
|
||||
setIsBusy(false)
|
||||
}
|
||||
@@ -198,32 +200,32 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
<Button asChild variant="ghost" size="sm" className="mr-1">
|
||||
<Link href="/student/learning/assignments">
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
Back
|
||||
{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">Questions</h3>
|
||||
<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" ? "Not Started" : submissionStatus}
|
||||
{submissionStatus === "not_started" ? t("homework.take.notStarted") : submissionStatus}
|
||||
</Badge>
|
||||
<span>•</span>
|
||||
<span>{initialData.questions.length} Questions</span>
|
||||
<span>{initialData.questions.length} {t("homework.take.questions")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{!canEdit ? (
|
||||
<Button onClick={handleStart} disabled={isBusy} size="sm">
|
||||
{isBusy ? "Starting..." : "Start Assignment"}
|
||||
{isBusy ? t("homework.take.starting") : t("homework.take.startAssignment")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => setShowSubmitConfirm(true)} disabled={isBusy} size="sm">
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
{isBusy ? "Submitting..." : "Submit Assignment"}
|
||||
{isBusy ? t("homework.take.submitting") : t("homework.take.submitAssignment")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -235,12 +237,12 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
<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">Ready to start?</h3>
|
||||
<h3 className="text-lg font-medium">{t("homework.take.readyToStart")}</h3>
|
||||
<p className="text-muted-foreground max-w-sm mt-2 mb-6">
|
||||
Click the "Start Assignment" button above to begin. Your answers will be saved when you click "Save Answer".
|
||||
{t("homework.take.readyDescription")}
|
||||
</p>
|
||||
<Button onClick={handleStart} disabled={isBusy}>
|
||||
Start Now
|
||||
{t("homework.take.startNow")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -256,10 +258,10 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base font-medium">
|
||||
Question {idx + 1}
|
||||
{t("homework.take.question", { index: idx + 1 })}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{q.questionType.replace("_", " ").replace(/\b\w/g, l => l.toUpperCase())} • {q.maxScore} points
|
||||
{q.questionType.replace("_", " ").replace(/\b\w/g, l => l.toUpperCase())} • {q.maxScore} {t("homework.take.points")}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
@@ -269,9 +271,9 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
|
||||
{q.questionType === "text" ? (
|
||||
<div className="grid gap-2">
|
||||
<Label className="sr-only">Your answer</Label>
|
||||
<Label className="sr-only">{t("homework.take.yourAnswer")}</Label>
|
||||
<Textarea
|
||||
placeholder="Type your answer here..."
|
||||
placeholder={t("homework.take.answerPlaceholder")}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) =>
|
||||
setAnswersByQuestionId((prev) => ({
|
||||
@@ -298,11 +300,11 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
>
|
||||
<div className="flex items-center space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
|
||||
<RadioGroupItem value="true" id={`${q.questionId}-true`} />
|
||||
<Label htmlFor={`${q.questionId}-true`} className="flex-1 cursor-pointer font-normal">True</Label>
|
||||
<Label htmlFor={`${q.questionId}-true`} className="flex-1 cursor-pointer font-normal">{t("homework.take.true")}</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
|
||||
<RadioGroupItem value="false" id={`${q.questionId}-false`} />
|
||||
<Label htmlFor={`${q.questionId}-false`} className="flex-1 cursor-pointer font-normal">False</Label>
|
||||
<Label htmlFor={`${q.questionId}-false`} className="flex-1 cursor-pointer font-normal">{t("homework.take.false")}</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
@@ -362,20 +364,20 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground italic">Unsupported question type</div>
|
||||
<div className="text-sm text-muted-foreground italic">{t("homework.take.unsupportedType")}</div>
|
||||
)}
|
||||
|
||||
{submissionStatus === "graded" && (
|
||||
<div className="mt-6 rounded-md bg-muted/40 p-4 border border-border/50">
|
||||
{q.feedback ? (
|
||||
<div className="text-sm space-y-1">
|
||||
<div className="font-medium text-foreground">Teacher Feedback</div>
|
||||
<div className="font-medium text-foreground">{t("homework.take.teacherFeedback")}</div>
|
||||
<div className="text-muted-foreground bg-background p-2 rounded border border-border/50">
|
||||
{q.feedback}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground italic">No specific feedback provided.</div>
|
||||
<div className="text-sm text-muted-foreground italic">{t("homework.take.noFeedback")}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -390,7 +392,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Save className="mr-2 h-3 w-3" />
|
||||
Save Answer
|
||||
{t("homework.take.saveAnswer")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -404,22 +406,22 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
|
||||
<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">Assignment Info</h3>
|
||||
<h3 className="font-semibold">{t("homework.take.assignmentInfo")}</h3>
|
||||
</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">Status</Label>
|
||||
<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" ? "not started" : submissionStatus}
|
||||
{submissionStatus === "not_started" ? t("homework.take.notStarted") : submissionStatus}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dueAt && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Due Date</Label>
|
||||
<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"
|
||||
@@ -428,11 +430,13 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
<span>{formatDate(dueAt)}</span>
|
||||
</div>
|
||||
{isOverdue && (
|
||||
<p className="mt-1 text-xs text-destructive">Overdue</p>
|
||||
<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 ? "Less than 1 hour left" : `${hoursUntilDue} hour${hoursUntilDue === 1 ? "" : "s"} left`}
|
||||
{hoursUntilDue === 0
|
||||
? t("homework.take.lessThanOneHour")
|
||||
: t("homework.take.hoursLeft", { hours: hoursUntilDue })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -440,33 +444,33 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
|
||||
{maxAttempts > 0 && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Attempts</Label>
|
||||
<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"> / {maxAttempts} used</span>
|
||||
<span className="text-muted-foreground"> {t("homework.take.attemptsUsed", { used: attemptsUsed, max: maxAttempts })}</span>
|
||||
{attemptsRemaining > 0 && (
|
||||
<span className="text-muted-foreground"> · {attemptsRemaining} remaining</span>
|
||||
<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">Description</Label>
|
||||
<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 || "No description provided."}
|
||||
{initialData.assignment.description || t("homework.take.noDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showQuestions && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Progress</Label>
|
||||
<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 hasAnswer = answersByQuestionId[q.questionId]?.answer !== undefined &&
|
||||
const hasAnswer = answersByQuestionId[q.questionId]?.answer !== undefined &&
|
||||
answersByQuestionId[q.questionId]?.answer !== "" &&
|
||||
(Array.isArray(answersByQuestionId[q.questionId]?.answer) ? (answersByQuestionId[q.questionId]?.answer as unknown[]).length > 0 : true)
|
||||
|
||||
|
||||
return (
|
||||
<button
|
||||
key={q.questionId}
|
||||
@@ -479,7 +483,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
"h-8 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={`Jump to question ${i + 1}`}
|
||||
aria-label={t("homework.take.jumpToQuestion", { index: i + 1 })}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
@@ -490,14 +494,14 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{canEdit && (
|
||||
<div className="border-t p-4 bg-muted/20">
|
||||
<Button className="w-full" onClick={() => setShowSubmitConfirm(true)} disabled={isBusy}>
|
||||
{isBusy ? "Submitting..." : "Submit All"}
|
||||
{isBusy ? t("homework.take.submitting") : t("homework.take.submitAll")}
|
||||
</Button>
|
||||
<p className="mt-2 text-xs text-center text-muted-foreground">
|
||||
Make sure you have answered all questions.
|
||||
{t("homework.take.makeSureAnswered")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -507,15 +511,15 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
<AlertDialog open={showSubmitConfirm} onOpenChange={setShowSubmitConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirm Submission</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("homework.take.confirmSubmit")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{unansweredCount > 0
|
||||
? `You have ${unansweredCount} unanswered question${unansweredCount === 1 ? "" : "s"}. Submitted answers cannot be changed. Are you sure you want to submit?`
|
||||
: "All questions have been answered. Submitted answers cannot be changed. Are you sure you want to submit?"}
|
||||
? t("homework.take.unansweredWarning", { count: unansweredCount })
|
||||
: t("homework.take.confirmSubmitDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isBusy}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={isBusy}>{t("homework.take.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={isBusy}
|
||||
onClick={(e) => {
|
||||
@@ -524,7 +528,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
void handleSubmit()
|
||||
}}
|
||||
>
|
||||
{isBusy ? "Submitting..." : "Confirm Submit"}
|
||||
{isBusy ? t("homework.take.submitting") : t("homework.take.confirmSubmitAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -45,7 +45,7 @@ export type HomeworkSubmissionPermissionData = {
|
||||
|
||||
export type CreateHomeworkAssignmentData = {
|
||||
assignmentId: string
|
||||
sourceExamId: string
|
||||
sourceExamId: string | null
|
||||
title: string
|
||||
description: string | null
|
||||
structure: unknown
|
||||
@@ -116,6 +116,32 @@ export const getHomeworkSubmissionForPermission = async (
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批改权限校验:获取提交记录及其作业的创建者信息
|
||||
* 用于 gradeHomeworkSubmissionAction 校验教师是否有权批改该提交
|
||||
* 返回 null 表示提交记录不存在
|
||||
*/
|
||||
export const getHomeworkSubmissionForGrading = async (
|
||||
submissionId: string
|
||||
): Promise<{
|
||||
id: string
|
||||
assignmentId: string
|
||||
creatorId: string
|
||||
sourceExamId: string | null
|
||||
} | null> => {
|
||||
const submission = await db.query.homeworkSubmissions.findFirst({
|
||||
where: eq(homeworkSubmissions.id, submissionId),
|
||||
with: { assignment: true },
|
||||
})
|
||||
if (!submission) return null
|
||||
return {
|
||||
id: submission.id,
|
||||
assignmentId: submission.assignmentId,
|
||||
creatorId: submission.assignment.creatorId,
|
||||
sourceExamId: submission.assignment.sourceExamId,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Write functions ----
|
||||
|
||||
export const createHomeworkAssignment = async (
|
||||
|
||||
Reference in New Issue
Block a user