完整性更新

现在已经实现了大部分基础功能
This commit is contained in:
SpecialX
2026-01-08 11:14:03 +08:00
parent 0da2eac0b4
commit 57807def37
155 changed files with 26421 additions and 1036 deletions

View File

@@ -0,0 +1,29 @@
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { HomeworkAssignmentExamErrorExplorerLazy } from "@/modules/homework/components/homework-assignment-exam-error-explorer-lazy"
export function HomeworkAssignmentExamContentCard({
structure,
questions,
gradedSampleCount,
}: {
structure: unknown
questions: HomeworkAssignmentQuestionAnalytics[]
gradedSampleCount: number
}) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Exam Content</CardTitle>
</CardHeader>
<CardContent>
<HomeworkAssignmentExamErrorExplorerLazy
structure={structure}
questions={questions}
gradedSampleCount={gradedSampleCount}
/>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,79 @@
"use client"
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
import dynamic from "next/dynamic"
import { Skeleton } from "@/shared/components/ui/skeleton"
function ExamErrorExplorerFallback() {
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 h-[560px]">
<div className="md:col-span-2 flex h-full flex-col overflow-hidden rounded-md border bg-card">
<div className="border-b px-4 py-3 text-sm font-medium"></div>
<div className="flex-1 p-4 space-y-3">
<Skeleton className="h-10 w-[40%]" />
<Skeleton className="h-10 w-[60%]" />
<Skeleton className="h-10 w-[75%]" />
<Skeleton className="h-10 w-[55%]" />
<Skeleton className="h-10 w-[68%]" />
</div>
</div>
<div className="flex h-full flex-col overflow-hidden rounded-md border bg-card">
<div className="border-b px-4 py-3">
<div className="text-sm font-medium"></div>
<div className="mt-2 flex items-center gap-3">
<Skeleton className="size-12 rounded-full" />
<div className="min-w-0 flex-1 grid gap-1">
<div className="flex items-center justify-between">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-10" />
</div>
<div className="flex items-center justify-between">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-12" />
</div>
<div className="flex items-center justify-between">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-10" />
</div>
</div>
</div>
</div>
<div className="flex-1 p-4 space-y-3">
<Skeleton className="h-4 w-[45%]" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
</div>
</div>
)
}
const LazyHomeworkAssignmentExamErrorExplorer = dynamic(
() =>
import("./homework-assignment-exam-error-explorer").then((m) => ({
default: m.HomeworkAssignmentExamErrorExplorer,
})),
{ ssr: false, loading: ExamErrorExplorerFallback }
)
export function HomeworkAssignmentExamErrorExplorerLazy({
structure,
questions,
gradedSampleCount,
}: {
structure: unknown
questions: HomeworkAssignmentQuestionAnalytics[]
gradedSampleCount: number
}) {
return (
<LazyHomeworkAssignmentExamErrorExplorer
structure={structure}
questions={questions}
gradedSampleCount={gradedSampleCount}
/>
)
}

View File

@@ -0,0 +1,44 @@
"use client"
import { useMemo, useState } from "react"
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
import { HomeworkAssignmentExamPreviewPane } from "@/modules/homework/components/homework-assignment-exam-preview-pane"
import { HomeworkAssignmentQuestionErrorDetailPanel } from "@/modules/homework/components/homework-assignment-question-error-detail-panel"
export function HomeworkAssignmentExamErrorExplorer({
structure,
questions,
gradedSampleCount,
heightClassName = "h-[560px]",
}: {
structure: unknown
questions: HomeworkAssignmentQuestionAnalytics[]
gradedSampleCount: number
heightClassName?: string
}) {
const firstQuestionId = questions[0]?.questionId ?? null
const [selectedQuestionId, setSelectedQuestionId] = useState<string | null>(firstQuestionId)
const selected = useMemo(() => {
if (!selectedQuestionId) return null
return questions.find((q) => q.questionId === selectedQuestionId) ?? null
}, [questions, selectedQuestionId])
return (
<div className={`grid grid-cols-1 gap-4 md:grid-cols-3 ${heightClassName}`}>
<HomeworkAssignmentExamPreviewPane
structure={structure}
questions={questions.map((q) => ({
questionId: q.questionId,
questionType: q.questionType,
questionContent: q.questionContent,
maxScore: q.maxScore,
}))}
selectedQuestionId={selectedQuestionId}
onQuestionSelect={setSelectedQuestionId}
/>
<HomeworkAssignmentQuestionErrorDetailPanel selected={selected} gradedSampleCount={gradedSampleCount} />
</div>
)
}

View File

@@ -0,0 +1,35 @@
"use client"
import { ExamViewer } from "@/modules/exams/components/exam-viewer"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
export function HomeworkAssignmentExamPreviewPane({
structure,
questions,
selectedQuestionId,
onQuestionSelect,
}: {
structure: unknown
questions: Array<{
questionId: string
questionType: string
questionContent: unknown
maxScore: number
}>
selectedQuestionId: string | null
onQuestionSelect: (questionId: string) => void
}) {
return (
<div className="md:col-span-2 flex h-full flex-col overflow-hidden rounded-md border bg-card">
<div className="border-b px-4 py-3 text-sm font-medium"></div>
<ScrollArea className="flex-1 p-4">
<ExamViewer
structure={structure}
questions={questions}
selectedQuestionId={selectedQuestionId}
onQuestionSelect={onQuestionSelect}
/>
</ScrollArea>
</div>
)
}

View File

@@ -4,6 +4,7 @@ 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 { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
@@ -13,6 +14,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Textarea } from "@/shared/components/ui/textarea"
import { createHomeworkAssignmentAction } from "../actions"
import type { TeacherClass } from "@/modules/classes/types"
type ExamOption = { id: string; title: string }
@@ -25,11 +27,18 @@ function SubmitButton() {
)
}
export function HomeworkAssignmentForm({ exams }: { exams: ExamOption[] }) {
export function HomeworkAssignmentForm({ exams, classes }: { exams: ExamOption[]; classes: TeacherClass[] }) {
const router = useRouter()
const searchParams = useSearchParams()
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 handleSubmit = async (formData: FormData) => {
@@ -37,7 +46,12 @@ export function HomeworkAssignmentForm({ exams }: { exams: ExamOption[] }) {
toast.error("Please select an exam")
return
}
if (!classId) {
toast.error("Please select a class")
return
}
formData.set("sourceExamId", examId)
formData.set("classId", classId)
formData.set("allowLate", allowLate ? "true" : "false")
formData.set("publish", "true")
@@ -58,6 +72,23 @@ export function HomeworkAssignmentForm({ exams }: { exams: ExamOption[] }) {
<CardContent>
<form action={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2 md:col-span-2">
<Label>Class</Label>
<Select value={classId} onValueChange={setClassId}>
<SelectTrigger>
<SelectValue placeholder="Select a class" />
</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>
<div className="grid gap-2 md:col-span-2">
<Label>Source Exam</Label>
<Select value={examId} onValueChange={setExamId}>
@@ -121,7 +152,7 @@ export function HomeworkAssignmentForm({ exams }: { exams: ExamOption[] }) {
<Textarea
id="targetStudentIdsText"
name="targetStudentIdsText"
placeholder="Leave empty to assign to all students. You can paste IDs separated by comma or newline."
placeholder="Optional. If provided, targets will be limited to students in the selected class."
className="min-h-[90px]"
/>
</div>
@@ -135,4 +166,3 @@ export function HomeworkAssignmentForm({ exams }: { exams: ExamOption[] }) {
</Card>
)
}

View File

@@ -0,0 +1,150 @@
"use client"
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
const getOptions = (content: unknown): Array<{ id: string; text: string }> => {
if (!isRecord(content)) return []
const raw = content.options
if (!Array.isArray(raw)) return []
const out: Array<{ id: string; text: string }> = []
for (const item of raw) {
if (!isRecord(item)) continue
const id = typeof item.id === "string" ? item.id : ""
const text = typeof item.text === "string" ? item.text : ""
if (!id || !text) continue
out.push({ id, text })
}
return out
}
const safeInlineJson = (v: unknown) => {
try {
const s = JSON.stringify(v)
if (typeof s === "string" && s.length > 180) return `${s.slice(0, 180)}`
return s ?? String(v)
} catch {
return String(v)
}
}
const formatAnswer = (answerContent: unknown, question: HomeworkAssignmentQuestionAnalytics | null) => {
if (isRecord(answerContent) && "answer" in answerContent) answerContent = answerContent.answer
if (answerContent === null || answerContent === undefined) return "未作答"
const options = getOptions(question?.questionContent ?? null)
const optionTextById = new Map(options.map((o) => [o.id, o.text] as const))
if (typeof answerContent === "boolean") return answerContent ? "True" : "False"
if (typeof answerContent === "string") return optionTextById.get(answerContent) ?? answerContent
if (Array.isArray(answerContent)) {
const parts = answerContent
.map((x) => (typeof x === "string" ? optionTextById.get(x) ?? x : x))
.map((x) => (typeof x === "string" ? x : safeInlineJson(x)))
return parts.join(", ")
}
return safeInlineJson(answerContent)
}
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
function ErrorRatePieChart({ errorRate }: { errorRate: number }) {
const pct = clamp01(errorRate) * 100
const r = 15.91549430918954
const dashA = pct
const dashB = 100 - pct
const showError = pct > 0
return (
<svg viewBox="0 0 36 36" className="size-12" role="img" aria-label={`错误率 ${pct.toFixed(1)}%`}>
<circle cx="18" cy="18" r={r} fill="none" strokeWidth="3.5" className="stroke-border" />
<circle cx="18" cy="18" r={r} fill="none" strokeWidth="3.5" className="stroke-chart-2" />
{showError ? (
<circle
cx="18"
cy="18"
r={r}
fill="none"
strokeWidth="3.5"
strokeLinecap="round"
strokeDasharray={`${dashA} ${dashB}`}
transform="rotate(-90 18 18)"
className="stroke-destructive"
/>
) : null}
<text x="18" y="19.2" textAnchor="middle" className="fill-foreground text-[8px] font-medium tabular-nums">
{pct.toFixed(0)}%
</text>
</svg>
)
}
export function HomeworkAssignmentQuestionErrorDetailPanel({
selected,
gradedSampleCount,
}: {
selected: HomeworkAssignmentQuestionAnalytics | null
gradedSampleCount: number
}) {
const wrongAnswers = selected?.wrongAnswers ?? []
const errorCount = selected?.errorCount ?? 0
const errorRate = selected?.errorRate ?? 0
return (
<div className="flex h-full flex-col overflow-hidden rounded-md border bg-card">
<div className="border-b px-4 py-3">
<div className="text-sm font-medium"></div>
{selected ? (
<div className="mt-2 flex items-center gap-3">
<div className="shrink-0">
<ErrorRatePieChart errorRate={errorRate} />
</div>
<div className="min-w-0 flex-1 grid gap-1 text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span></span>
<span className="tabular-nums text-foreground">{errorCount}</span>
</div>
<div className="flex items-center justify-between">
<span></span>
<span className="tabular-nums text-foreground">{(errorRate * 100).toFixed(1)}%</span>
</div>
<div className="flex items-center justify-between">
<span></span>
<span className="tabular-nums text-foreground">{gradedSampleCount}</span>
</div>
</div>
</div>
) : (
<div className="mt-2 text-xs text-muted-foreground"></div>
)}
</div>
<div className="flex-1 overflow-hidden">
<ScrollArea className="h-full p-4">
{!selected ? (
<div className="text-sm text-muted-foreground"></div>
) : wrongAnswers.length === 0 ? (
<div className="text-sm text-muted-foreground"></div>
) : (
<div className="space-y-2">
<div className="text-xs text-muted-foreground"></div>
<div className="space-y-2">
{wrongAnswers.map((item, idx) => (
<div key={`${selected.questionId}-${idx}`} className="rounded-md border bg-muted/20 px-3 py-2">
<div className="flex items-start gap-3">
<div className="w-24 shrink-0 text-xs text-muted-foreground truncate">{item.studentName}</div>
<div className="min-w-0 flex-1 text-sm wrap-break-word whitespace-pre-wrap">
{formatAnswer(item.answerContent, selected)}
</div>
</div>
</div>
))}
</div>
</div>
)}
</ScrollArea>
</div>
</div>
)
}

View File

@@ -0,0 +1,49 @@
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
export function HomeworkAssignmentQuestionErrorDetailsCard({
questions,
gradedSampleCount,
}: {
questions: HomeworkAssignmentQuestionAnalytics[]
gradedSampleCount: number
}) {
return (
<Card className="md:col-span-1">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Question Error Details</CardTitle>
</CardHeader>
<CardContent className="p-0">
{questions.length === 0 || gradedSampleCount === 0 ? (
<div className="p-4 text-sm text-muted-foreground">No data available.</div>
) : (
<ScrollArea className="h-72">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[70px]">Question</TableHead>
<TableHead className="text-right">Error Count</TableHead>
<TableHead className="text-right">Error Rate</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{questions.map((q, index) => (
<TableRow key={q.questionId}>
<TableCell className="text-sm">
<div className="font-medium">Q{index + 1}</div>
</TableCell>
<TableCell className="text-right text-sm tabular-nums">{q.errorCount}</TableCell>
<TableCell className="text-right text-sm tabular-nums">{(q.errorRate * 100).toFixed(1)}%</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,134 @@
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
function ErrorRateChart({
questions,
gradedSampleCount,
}: {
questions: HomeworkAssignmentQuestionAnalytics[]
gradedSampleCount: number
}) {
const w = 100
const h = 60
const padL = 10
const padR = 3
const padT = 4
const padB = 10
const plotW = w - padL - padR
const plotH = h - padT - padB
const n = questions.length
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
const xFor = (i: number) => padL + (n <= 1 ? 0 : (i / (n - 1)) * plotW)
const yFor = (rate: number) => padT + (1 - clamp01(rate)) * plotH
const points = questions.map((q, i) => `${xFor(i)},${yFor(q.errorRate)}`).join(" ")
const areaD =
n === 0
? ""
: `M ${padL} ${padT + plotH} L ${points.split(" ").join(" L ")} L ${padL + plotW} ${padT + plotH} Z`
const gridYs = [
{ v: 1, label: "100%" },
{ v: 0.5, label: "50%" },
{ v: 0, label: "0%" },
]
return (
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" className="h-full w-full">
{gridYs.map((g) => {
const y = yFor(g.v)
return (
<g key={g.label}>
<line x1={padL} y1={y} x2={padL + plotW} y2={y} className="stroke-border" strokeWidth={0.5} />
<text x={2} y={y + 1.2} className="fill-muted-foreground text-[3px]">
{g.label}
</text>
</g>
)
})}
<line x1={padL} y1={padT} x2={padL} y2={padT + plotH} className="stroke-border" strokeWidth={0.7} />
<line
x1={padL}
y1={padT + plotH}
x2={padL + plotW}
y2={padT + plotH}
className="stroke-border"
strokeWidth={0.7}
/>
{n >= 2 ? <path d={areaD} className="fill-primary/10" /> : null}
<polyline
points={points}
fill="none"
className="stroke-primary"
strokeWidth={1.2}
strokeLinejoin="round"
strokeLinecap="round"
/>
{questions.map((q, i) => {
const cx = xFor(i)
const cy = yFor(q.errorRate)
const label = `Q${i + 1}`
return (
<g key={q.questionId}>
<circle cx={cx} cy={cy} r={1.2} className="fill-primary" />
<title>{`${label}: ${(q.errorRate * 100).toFixed(1)}% (${q.errorCount} / ${gradedSampleCount})`}</title>
</g>
)
})}
{questions.map((q, i) => {
if (n > 12 && i % 2 === 1) return null
const x = xFor(i)
return (
<text
key={`x-${q.questionId}`}
x={x}
y={h - 2}
textAnchor="middle"
className="fill-muted-foreground text-[3px]"
>
{i + 1}
</text>
)
})}
</svg>
)
}
export function HomeworkAssignmentQuestionErrorOverviewCard({
questions,
gradedSampleCount,
}: {
questions: HomeworkAssignmentQuestionAnalytics[]
gradedSampleCount: number
}) {
return (
<Card className="md:col-span-1">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Question Error Overview</CardTitle>
</CardHeader>
<CardContent>
{questions.length === 0 || gradedSampleCount === 0 ? (
<div className="text-sm text-muted-foreground">
No graded submissions yet. Error analytics will appear here after grading.
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Graded students</span>
<span className="font-medium text-foreground">{gradedSampleCount}</span>
</div>
<div className="h-56 rounded-md border bg-muted/40 px-3 py-2">
<ErrorRateChart questions={questions} gradedSampleCount={gradedSampleCount} />
</div>
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -3,6 +3,7 @@
import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Check, MessageSquarePlus, X } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
@@ -11,6 +12,7 @@ import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
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"
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
@@ -44,12 +46,22 @@ export function HomeworkGradingView({
answers: initialAnswers,
}: HomeworkGradingViewProps) {
const router = useRouter()
const [answers, setAnswers] = useState(initialAnswers)
const [answers, setAnswers] = useState(() => applyAutoGrades(initialAnswers))
const [isSubmitting, setIsSubmitting] = useState(false)
const [showFeedbackByAnswerId, setShowFeedbackByAnswerId] = useState<Record<string, boolean>>({})
const handleScoreChange = (id: string, val: string) => {
const score = val === "" ? 0 : parseInt(val)
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score } : a)))
const handleManualScoreChange = (id: string, val: string) => {
const parsed = val === "" ? 0 : Number(val)
const nextScore = Number.isFinite(parsed) ? parsed : 0
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score: nextScore } : a)))
}
const handleMarkCorrect = (id: string) => {
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score: a.maxScore } : a)))
}
const handleMarkIncorrect = (id: string) => {
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score: 0 } : a)))
}
const handleFeedbackChange = (id: string, val: string) => {
@@ -57,14 +69,18 @@ export function HomeworkGradingView({
}
const currentTotal = answers.reduce((sum, a) => sum + (a.score || 0), 0)
const binaryAnswers = answers.filter(shouldUseBinaryGrading)
const correctCount = binaryAnswers.reduce((sum, a) => sum + (a.score === a.maxScore ? 1 : 0), 0)
const incorrectCount = binaryAnswers.reduce((sum, a) => sum + (a.score === 0 ? 1 : 0), 0)
const ungradedCount = binaryAnswers.length - correctCount - incorrectCount
const handleSubmit = async () => {
setIsSubmitting(true)
const payload = answers.map((a) => ({
id: a.id,
score: a.score || 0,
feedback: a.feedback,
}))
const payload = answers.map((a) => {
const feedback =
typeof a.feedback === "string" && a.feedback.trim().length > 0 ? a.feedback.trim() : undefined
return { id: a.id, score: a.score || 0, feedback }
})
const formData = new FormData()
formData.set("submissionId", submissionId)
@@ -102,9 +118,7 @@ export function HomeworkGradingView({
<div className="rounded-md bg-muted/50 p-4">
<Label className="mb-2 block text-xs text-muted-foreground">Student Answer</Label>
<p className="text-sm font-medium">
{isRecord(ans.studentAnswer) && typeof ans.studentAnswer.answer === "string"
? ans.studentAnswer.answer
: JSON.stringify(ans.studentAnswer)}
{formatStudentAnswer(ans.studentAnswer)}
</p>
</div>
@@ -122,6 +136,21 @@ export function HomeworkGradingView({
<span className="text-muted-foreground">Total Score</span>
<span className="font-bold text-lg text-primary">{currentTotal}</span>
</div>
{binaryAnswers.length > 0 ? (
<div className="mt-3 flex items-center gap-2">
<Badge variant="outline" className="border-emerald-200 bg-emerald-50 text-emerald-700">
Correct {correctCount}
</Badge>
<Badge variant="outline" className="border-red-200 bg-red-50 text-red-700">
Incorrect {incorrectCount}
</Badge>
{ungradedCount > 0 ? (
<Badge variant="outline" className="text-muted-foreground">
Ungraded {ungradedCount}
</Badge>
) : null}
</div>
) : null}
</div>
<ScrollArea className="flex-1 p-4">
@@ -129,32 +158,90 @@ export function HomeworkGradingView({
{answers.map((ans, index) => (
<Card key={ans.id} className="border-l-4 border-l-primary/20">
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm font-medium flex justify-between">
Q{index + 1}
<span className="text-xs text-muted-foreground">Max: {ans.maxScore}</span>
</CardTitle>
<div className="flex items-center justify-between gap-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<span>Q{index + 1}</span>
<span className="text-xs text-muted-foreground font-normal">Max: {ans.maxScore}</span>
{shouldUseBinaryGrading(ans) ? (
<Badge
variant="outline"
className={getCorrectnessBadgeClassName(ans)}
>
{getCorrectnessLabel(ans)}
</Badge>
) : null}
</CardTitle>
<div className="flex items-center gap-1">
{shouldUseBinaryGrading(ans) ? (
<>
<Button
type="button"
variant="outline"
size="icon"
aria-label="mark correct"
className={getMarkCorrectButtonClassName(ans)}
onClick={() => handleMarkCorrect(ans.id)}
>
<Check />
</Button>
<Button
type="button"
variant="outline"
size="icon"
aria-label="mark incorrect"
className={getMarkIncorrectButtonClassName(ans)}
onClick={() => handleMarkIncorrect(ans.id)}
>
<X />
</Button>
</>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="add feedback"
className={getFeedbackIconButtonClassName(ans, showFeedbackByAnswerId[ans.id] ?? false)}
onClick={() =>
setShowFeedbackByAnswerId((prev) => ({ ...prev, [ans.id]: !(prev[ans.id] ?? false) }))
}
>
<MessageSquarePlus />
</Button>
</TooltipTrigger>
<TooltipContent>add feedback</TooltipContent>
</Tooltip>
</div>
</div>
</CardHeader>
<CardContent className="py-3 px-4 space-y-3">
<div className="grid gap-2">
<Label htmlFor={`score-${ans.id}`}>Score</Label>
<Input
id={`score-${ans.id}`}
type="number"
min={0}
max={ans.maxScore}
value={ans.score ?? ""}
onChange={(e) => handleScoreChange(ans.id, e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`fb-${ans.id}`}>Feedback</Label>
<Textarea
id={`fb-${ans.id}`}
placeholder="Optional feedback..."
className="min-h-[60px] resize-none"
value={ans.feedback ?? ""}
onChange={(e) => handleFeedbackChange(ans.id, e.target.value)}
/>
{!shouldUseBinaryGrading(ans) ? (
<div className="grid gap-2">
<Label htmlFor={`score-${ans.id}`}>Score</Label>
<Input
id={`score-${ans.id}`}
type="number"
min={0}
max={ans.maxScore}
value={ans.score ?? ""}
onChange={(e) => handleManualScoreChange(ans.id, e.target.value)}
/>
</div>
) : null}
{(showFeedbackByAnswerId[ans.id] ?? false) ? (
<Textarea
id={`fb-${ans.id}`}
placeholder="Optional feedback..."
className="min-h-[60px] resize-none"
value={ans.feedback ?? ""}
onChange={(e) => handleFeedbackChange(ans.id, e.target.value)}
/>
) : null}
</div>
</CardContent>
</Card>
@@ -171,3 +258,156 @@ export function HomeworkGradingView({
</div>
)
}
type ChoiceOption = { id?: unknown; isCorrect?: unknown }
const normalizeText = (v: string) => v.trim().replace(/\s+/g, " ").toLowerCase()
const extractAnswerValue = (studentAnswer: unknown): unknown => {
if (isRecord(studentAnswer) && "answer" in studentAnswer) return studentAnswer.answer
return studentAnswer
}
const getChoiceCorrectIds = (content: QuestionContent | null): string[] => {
if (!content) return []
const raw = content.options
if (!Array.isArray(raw)) return []
const ids: string[] = []
for (const item of raw) {
const opt = item as ChoiceOption
const id = typeof opt.id === "string" ? opt.id : null
const isCorrect = opt.isCorrect === true
if (id && isCorrect) ids.push(id)
}
return ids
}
const getTextCorrectAnswers = (content: QuestionContent | null): string[] => {
if (!content) return []
const raw = content.correctAnswer
if (typeof raw === "string") return [raw]
if (Array.isArray(raw)) return raw.filter((x): x is string => typeof x === "string")
return []
}
const getJudgmentCorrectAnswer = (content: QuestionContent | null): boolean | null => {
if (!content) return null
return typeof content.correctAnswer === "boolean" ? content.correctAnswer : null
}
const shouldUseBinaryGrading = (ans: Answer): boolean => {
if (ans.questionType === "single_choice") return true
if (ans.questionType === "multiple_choice") return true
if (ans.questionType === "judgment") return true
if (ans.questionType === "text") return getTextCorrectAnswers(ans.questionContent).length > 0
return false
}
const isAutoGradable = (ans: Answer): boolean => {
if (ans.questionType === "single_choice" || ans.questionType === "multiple_choice") return getChoiceCorrectIds(ans.questionContent).length > 0
if (ans.questionType === "judgment") return getJudgmentCorrectAnswer(ans.questionContent) !== null
if (ans.questionType === "text") return getTextCorrectAnswers(ans.questionContent).length > 0
return false
}
const computeIsCorrect = (ans: Answer): boolean | null => {
const studentVal = extractAnswerValue(ans.studentAnswer)
if (ans.questionType === "single_choice") {
const correct = getChoiceCorrectIds(ans.questionContent)
if (correct.length === 0) return null
if (typeof studentVal !== "string") return false
return correct.includes(studentVal)
}
if (ans.questionType === "multiple_choice") {
const correct = getChoiceCorrectIds(ans.questionContent)
if (correct.length === 0) return null
const studentArr = Array.isArray(studentVal) ? studentVal.filter((x): x is string => typeof x === "string") : []
const correctSet = new Set(correct)
const studentSet = new Set(studentArr)
if (studentSet.size !== correctSet.size) return false
for (const id of correctSet) {
if (!studentSet.has(id)) return false
}
return true
}
if (ans.questionType === "judgment") {
const correct = getJudgmentCorrectAnswer(ans.questionContent)
if (correct === null) return null
if (typeof studentVal !== "boolean") return false
return studentVal === correct
}
if (ans.questionType === "text") {
const correctAnswers = getTextCorrectAnswers(ans.questionContent)
if (correctAnswers.length === 0) return null
if (typeof studentVal !== "string") return false
const normalizedStudent = normalizeText(studentVal)
return correctAnswers.some((c) => normalizeText(c) === normalizedStudent)
}
return null
}
const applyAutoGrades = (incoming: Answer[]): Answer[] => {
return incoming.map((a) => {
if (a.score !== null) return a
if (!isAutoGradable(a)) return a
const isCorrect = computeIsCorrect(a)
if (isCorrect === null) return a
return { ...a, score: isCorrect ? a.maxScore : 0 }
})
}
type CorrectnessState = "ungraded" | "correct" | "incorrect" | "partial"
const getCorrectnessState = (ans: Answer): CorrectnessState => {
if (ans.score === null) return "ungraded"
if (ans.score === ans.maxScore) return "correct"
if (ans.score === 0) return "incorrect"
return "partial"
}
const getCorrectnessLabel = (ans: Answer): string => {
const s = getCorrectnessState(ans)
if (s === "correct") return "Correct"
if (s === "incorrect") return "Incorrect"
if (s === "partial") return "Partial"
return "Ungraded"
}
const getCorrectnessBadgeClassName = (ans: Answer): string => {
const s = getCorrectnessState(ans)
if (s === "correct") return "border-emerald-200 bg-emerald-50 text-emerald-700"
if (s === "incorrect") return "border-red-200 bg-red-50 text-red-700"
if (s === "partial") return "border-amber-200 bg-amber-50 text-amber-800"
return "text-muted-foreground"
}
const getMarkCorrectButtonClassName = (ans: Answer): string => {
const active = getCorrectnessState(ans) === "correct"
return active ? "border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100" : "text-muted-foreground"
}
const getMarkIncorrectButtonClassName = (ans: Answer): string => {
const active = getCorrectnessState(ans) === "incorrect"
return active ? "border-red-300 bg-red-50 text-red-700 hover:bg-red-100" : "text-muted-foreground"
}
const getFeedbackIconButtonClassName = (ans: Answer, isOpen: boolean): string => {
const hasFeedback = typeof ans.feedback === "string" && ans.feedback.trim().length > 0
if (isOpen) return "text-primary"
if (hasFeedback) return "text-primary/80"
return "text-muted-foreground"
}
const formatStudentAnswer = (studentAnswer: unknown): string => {
const v = extractAnswerValue(studentAnswer)
if (typeof v === "string") return v
if (typeof v === "boolean") return v ? "True" : "False"
if (Array.isArray(v)) return v.map((x) => (typeof x === "string" ? x : JSON.stringify(x))).join(", ")
if (v == null) return "—"
return JSON.stringify(v)
}