完整性更新
Some checks failed
CI / build-and-test (push) Failing after 3m50s
CI / deploy (push) Has been skipped

现在已经实现了大部分基础功能
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,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>
)
}