155 lines
6.1 KiB
TypeScript
155 lines
6.1 KiB
TypeScript
"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 bg-muted/5">
|
|
<div className="border-b px-6 py-4 bg-muted/5">
|
|
<div className="text-sm font-medium">Error Analysis</div>
|
|
</div>
|
|
<ScrollArea className="flex-1">
|
|
<div className="p-6 space-y-6">
|
|
{selected ? (
|
|
<>
|
|
<div className="flex items-center gap-4 p-4 bg-background rounded-lg border shadow-sm">
|
|
<div className="shrink-0">
|
|
<ErrorRatePieChart errorRate={errorRate} />
|
|
</div>
|
|
<div className="min-w-0 flex-1 grid gap-1">
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-muted-foreground">Question</span>
|
|
<span className="font-medium">Q{selected.questionId.slice(-4)}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-muted-foreground">Errors</span>
|
|
<span className="font-medium text-destructive">
|
|
{errorCount} <span className="text-muted-foreground text-xs">/ {gradedSampleCount}</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Wrong Answers ({wrongAnswers.length})</div>
|
|
{wrongAnswers.length === 0 ? (
|
|
<div className="text-sm text-muted-foreground italic py-4 text-center bg-background rounded-md border border-dashed">
|
|
No wrong answers recorded.
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{wrongAnswers.map((wa, i) => (
|
|
<div key={i} className="rounded-md border bg-background p-3 text-sm shadow-sm">
|
|
<div className="mb-1 flex items-center justify-between">
|
|
<span className="text-xs font-medium text-muted-foreground">Student Answer</span>
|
|
<span className="text-xs text-muted-foreground">{wa.count ?? 1} student{(wa.count ?? 1) > 1 ? "s" : ""}</span>
|
|
</div>
|
|
<div className="font-medium text-destructive break-words">
|
|
{formatAnswer(wa.answerContent, selected)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="flex h-full flex-col items-center justify-center text-center text-muted-foreground py-12">
|
|
<p>Select a question from the left</p>
|
|
<p className="text-xs mt-1">to view error analysis</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
)
|
|
}
|