fix(dashboard): v3 审计修复 — 数据完整性、i18n、类型安全、死代码清理

P0 修复(严重):
- admin ContentRow 标签与值错配(stats.users→textbooks 等 6 处)
- admin/error.tsx 硬编码中文替换为 useTranslations
- UserGrowthChart 空数据时渲染 EmptyState(userGrowth/homeworkTrend 永远为空数组)

P1 修复(高):
- 新增 admin/dashboard 和 student/dashboard 的 loading.tsx + error.tsx
- 抽取 DashboardLoadingSkeleton 和 DashboardErrorFallback 共享组件,消除 5 套重复文件
- formatDate/formatLongDate 传入用户 locale(admin/teacher/student 共 6 个组件)
- 移除死代码:getCachedAdminDashboard、AvatarImage src={undefined}、TeacherStats isLoading prop
- filterTodaySchedule 改为泛型函数,消除 as 类型断言
- 辅助函数 getStatus/getDueUrgency 新增显式返回类型
- UserGrowthChart 新增 labelKey prop 区分用户增长/作业提交趋势标签

P2 修复(中):
- 4 个组件从客户端转为服务端组件(DashboardGreetingHeader、TeacherQuickActions、TeacherDashboardHeader、StudentDashboardHeader)
- Student dashboard 空状态新增 CTA(viewSchedule、viewAll)
- TeacherHomeworkCard 图标按钮新增 aria-label
- TeacherTodoCard 排序逻辑重写为可读的 if/return 模式

同步更新:
- docs/architecture/005_architecture_data.json 新增 DashboardLoadingSkeleton、DashboardErrorFallback 条目
- 新增 docs/architecture/audit/dashboard-audit-report-v3.md 审计报告
- dashboard.json 新增 6 个 i18n 键(textbooks/chapters/questions/exams/totalAssignments/totalSubmissions)
This commit is contained in:
SpecialX
2026-06-22 18:36:46 +08:00
parent f62b8c0f86
commit 682d385ee2
41 changed files with 4387 additions and 1979 deletions

View File

@@ -0,0 +1,50 @@
"use client"
import { useQueryState, parseAsString } from "nuqs"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
export function AssignmentFilters() {
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all"))
const hasFilters = Boolean(search || status !== "all")
return (
<FilterBar
layout="between"
hasFilters={hasFilters}
onReset={() => {
setSearch(null)
setStatus(null)
}}
>
<FilterSearchInput
value={search}
onChange={(v) => setSearch(v || null)}
placeholder="Search assignments..."
/>
<div className="flex flex-wrap gap-2 w-full md:w-auto">
<Select value={status} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
<SelectTrigger className="w-[160px] bg-background border-muted-foreground/20">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="submitted">Submitted</SelectItem>
<SelectItem value="graded">Graded</SelectItem>
</SelectContent>
</Select>
</div>
</FilterBar>
)
}

View File

@@ -26,8 +26,15 @@ 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
import { QuestionRenderer } from "./question-renderer"
import {
applyAutoGrades as applyAutoGradesUtil,
extractAnswerValue,
getCorrectnessState as getCorrectnessStateUtil,
getOptions,
getTextCorrectAnswers,
isAutoGradable as isAutoGradableUtil,
} from "../lib/question-content-utils"
type QuestionContent = { text?: string } & Record<string, unknown>
@@ -154,180 +161,186 @@ export function HomeworkGradingView({
<div className="lg:col-span-9 h-full overflow-hidden flex flex-col rounded-md border bg-muted/10">
<ScrollArea className="flex-1 p-4 lg:p-8">
<div className="mx-auto max-w-4xl space-y-8 pb-20">
{answers.map((ans, index) => (
<Card id={`question-card-${ans.id}`} key={ans.id} className={`overflow-hidden transition-all ${
ans.score === ans.maxScore ? "border-l-4 border-l-emerald-500" :
ans.score === 0 && ans.maxScore > 0 ? "border-l-4 border-l-red-500" : "border-l-4 border-l-muted"
}`}>
<CardHeader className="bg-card pb-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Badge variant="outline" className="h-6 w-6 shrink-0 justify-center rounded-full p-0">
{index + 1}
</Badge>
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{ans.questionType.replace("_", " ")}
</span>
{isAutoGradable(ans) && (
<Badge variant="secondary" className="text-[10px] h-5">{t("homework.grade.autoGraded")}</Badge>
{answers.map((ans, index) => {
const correctness = getCorrectnessState(ans)
const borderClass =
correctness === "correct"
? "border-l-4 border-l-emerald-500"
: correctness === "incorrect"
? "border-l-4 border-l-red-500"
: "border-l-4 border-l-muted"
return (
<Card id={`question-card-${ans.id}`} key={ans.id} className={`overflow-hidden transition-all ${borderClass}`}>
<CardHeader className="bg-card pb-4">
<QuestionRenderer
questionId={ans.id}
questionType={ans.questionType}
questionContent={ans.questionContent}
maxScore={ans.maxScore}
index={index}
mode="grade"
value={extractAnswerValue(ans.studentAnswer)}
showCorrectAnswer={true}
headerExtra={
<div className="flex flex-col items-end gap-1">
<Badge variant="outline" className="whitespace-nowrap">
<span className="sr-only">{t("homework.grade.scoreLabel")}: </span>
{ans.score ?? 0} / {ans.maxScore} pts
</Badge>
{isAutoGradable(ans) && (
<Badge variant="secondary" className="text-[10px] h-5">{t("homework.grade.autoGraded")}</Badge>
)}
</div>
}
/>
</CardHeader>
<Separator />
<CardContent className="bg-card/50 p-6 space-y-6">
{/* 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" /> {t("homework.grade.studentAnswer")}
</Label>
<div className="rounded-md border bg-background p-4 shadow-sm">
{(ans.questionType === "single_choice" || ans.questionType === "multiple_choice") &&
Array.isArray(ans.questionContent?.options) ? (
<div className="space-y-2">
{getOptions(ans.questionContent).map((opt) => {
const answerValue = extractAnswerValue(ans.studentAnswer)
const isSelected = Array.isArray(answerValue)
? answerValue.filter((x): x is string => typeof x === "string").includes(opt.id)
: typeof answerValue === "string" && answerValue === opt.id
const isCorrect = opt.isCorrect === true
let containerClass = "border-transparent hover:bg-muted/50"
let indicatorClass = "border-muted-foreground/30 text-muted-foreground"
if (isSelected) {
if (isCorrect) {
containerClass = "border-emerald-500 bg-emerald-50/50 dark:bg-emerald-950/20"
indicatorClass = "border-emerald-500 bg-emerald-500 text-white"
} else {
containerClass = "border-red-500 bg-red-50/50 dark:bg-red-950/20"
indicatorClass = "border-red-500 bg-red-500 text-white"
}
} else if (isCorrect) {
containerClass = "border-emerald-200 bg-emerald-50/30 dark:border-emerald-800 dark:bg-emerald-950/10"
indicatorClass = "border-emerald-500 text-emerald-600 dark:text-emerald-400"
}
return (
<div
key={opt.id}
className={`flex items-center gap-3 rounded-md border p-3 text-sm transition-colors ${containerClass}`}
>
<div className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs font-medium ${indicatorClass}`}>
{opt.id}
</div>
<span className="flex-1">{opt.text}</span>
<span className="sr-only">
{isCorrect ? t("homework.grade.correct") : ""} {isSelected && !isCorrect ? t("homework.grade.incorrect") : ""}
</span>
{isCorrect && <Check className="h-4 w-4 text-emerald-600" aria-hidden="true" />}
{isSelected && !isCorrect && <X className="h-4 w-4 text-red-600" aria-hidden="true" />}
</div>
)
})}
</div>
) : (
<p className="whitespace-pre-wrap text-sm leading-relaxed">
{formatStudentAnswer(ans.studentAnswer)}
</p>
)}
</div>
<CardTitle className="text-base font-medium leading-relaxed pt-2">
{ans.questionContent?.text || t("homework.grade.noQuestionText")}
</CardTitle>
</div>
<div className="flex flex-col items-end gap-1">
<Badge variant="outline" className="whitespace-nowrap">
{ans.score ?? 0} / {ans.maxScore} pts
</Badge>
</div>
</div>
</CardHeader>
<Separator />
<CardContent className="bg-card/50 p-6 space-y-6">
{/* 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" /> {t("homework.grade.studentAnswer")}
</Label>
<div className="rounded-md border bg-background p-4 shadow-sm">
{(ans.questionType === "single_choice" || ans.questionType === "multiple_choice") &&
Array.isArray(ans.questionContent?.options) ? (
<div className="space-y-2">
{(ans.questionContent.options as ChoiceOption[]).map((opt: ChoiceOption) => {
const isSelected = Array.isArray(extractAnswerValue(ans.studentAnswer))
? (extractAnswerValue(ans.studentAnswer) as string[]).includes(opt.id as string)
: extractAnswerValue(ans.studentAnswer) === opt.id
const isCorrect = opt.isCorrect === true
// Visual logic:
// If selected and correct -> Green + Check
// If selected and wrong -> Red + X
// If not selected but correct -> Green outline (show missed correct answer)
let containerClass = "border-transparent hover:bg-muted/50"
let indicatorClass = "border-muted-foreground/30 text-muted-foreground"
if (isSelected) {
if (isCorrect) {
containerClass = "border-emerald-500 bg-emerald-50/50 dark:bg-emerald-950/20"
indicatorClass = "border-emerald-500 bg-emerald-500 text-white"
} else {
containerClass = "border-red-500 bg-red-50/50 dark:bg-red-950/20"
indicatorClass = "border-red-500 bg-red-500 text-white"
}
} else if (isCorrect) {
containerClass = "border-emerald-200 bg-emerald-50/30 dark:border-emerald-800 dark:bg-emerald-950/10"
indicatorClass = "border-emerald-500 text-emerald-600 dark:text-emerald-400"
}
return (
<div
key={opt.id as string}
className={`flex items-center gap-3 rounded-md border p-3 text-sm transition-colors ${containerClass}`}
>
<div className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs font-medium ${indicatorClass}`}>
{opt.id as string}
</div>
<span className="flex-1">{opt.text}</span>
{isCorrect && <Check className="h-4 w-4 text-emerald-600" />}
{isSelected && !isCorrect && <X className="h-4 w-4 text-red-600" />}
</div>
)
})}
</div>
) : (
<p className="whitespace-pre-wrap text-sm leading-relaxed">
{formatStudentAnswer(ans.studentAnswer)}
</p>
)}
</div>
</div>
{/* Reference Answer (for text/non-choice questions) */}
{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" /> {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(" / ") || t("homework.grade.noReferenceAnswer")}
</div>
</div>
)}
</CardContent>
<CardFooter className="bg-muted/30 p-4 border-t flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between w-full gap-4">
{/* Grading Controls */}
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Button
variant={getCorrectnessState(ans) === "correct" ? "default" : "outline"}
size="sm"
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" /> {t("homework.grade.correctButton")}
</Button>
<Button
variant={getCorrectnessState(ans) === "incorrect" ? "destructive" : "outline"}
size="sm"
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" /> {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">{t("homework.grade.scoreLabel")}:</Label>
<Input
id={`score-${ans.id}`}
type="number"
min={0}
max={ans.maxScore}
className="w-20 h-8"
value={ans.score ?? ""}
onChange={(e) => handleManualScoreChange(ans.id, e.target.value)}
/>
<span className="text-sm text-muted-foreground">/ {ans.maxScore}</span>
</div>
{/* Reference Answer (for text/non-choice questions) */}
{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" /> {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(" / ") || t("homework.grade.noReferenceAnswer")}
</div>
</div>
)}
</CardContent>
{/* Feedback Toggle */}
<Button
variant="ghost"
size="sm"
className={showFeedbackByAnswerId[ans.id] ? "bg-primary/10 text-primary" : "text-muted-foreground"}
onClick={() => setShowFeedbackByAnswerId(prev => ({ ...prev, [ans.id]: !prev[ans.id] }))}
>
<MessageSquarePlus className="mr-2 h-4 w-4" />
{showFeedbackByAnswerId[ans.id] ? t("homework.grade.hideFeedback") : t("homework.grade.addFeedback")}
</Button>
</div>
<CardFooter className="bg-muted/30 p-4 border-t flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between w-full gap-4">
{/* Grading Controls */}
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Button
variant={correctness === "correct" ? "default" : "outline"}
size="sm"
aria-pressed={correctness === "correct"}
className={correctness === "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" /> {t("homework.grade.correctButton")}
</Button>
<Button
variant={correctness === "incorrect" ? "destructive" : "outline"}
size="sm"
aria-pressed={correctness === "incorrect"}
className={correctness === "incorrect" ? "" : "text-muted-foreground hover:text-red-600 hover:border-red-200"}
onClick={() => handleMarkIncorrect(ans.id)}
>
<X className="mr-1 h-4 w-4" /> {t("homework.grade.incorrectButton")}
</Button>
</div>
{/* Feedback Textarea */}
{showFeedbackByAnswerId[ans.id] && (
<div className="w-full animate-in fade-in slide-in-from-top-2 duration-200">
<Textarea
placeholder={t("homework.grade.feedbackPlaceholder", { name: studentName })}
value={ans.feedback ?? ""}
onChange={(e) => handleFeedbackChange(ans.id, e.target.value)}
className="min-h-[80px] bg-background"
/>
<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">{t("homework.grade.scoreLabel")}:</Label>
<Input
id={`score-${ans.id}`}
type="number"
min={0}
max={ans.maxScore}
className="w-20 h-8"
value={ans.score ?? ""}
onChange={(e) => handleManualScoreChange(ans.id, e.target.value)}
/>
<span className="text-sm text-muted-foreground">/ {ans.maxScore}</span>
</div>
</div>
{/* Feedback Toggle */}
<Button
variant="ghost"
size="sm"
aria-pressed={Boolean(showFeedbackByAnswerId[ans.id])}
className={showFeedbackByAnswerId[ans.id] ? "bg-primary/10 text-primary" : "text-muted-foreground"}
onClick={() => setShowFeedbackByAnswerId(prev => ({ ...prev, [ans.id]: !prev[ans.id] }))}
>
<MessageSquarePlus className="mr-2 h-4 w-4" />
{showFeedbackByAnswerId[ans.id] ? t("homework.grade.hideFeedback") : t("homework.grade.addFeedback")}
</Button>
</div>
)}
</CardFooter>
</Card>
))}
{/* Feedback Textarea */}
{showFeedbackByAnswerId[ans.id] && (
<div className="w-full animate-in fade-in slide-in-from-top-2 duration-200">
<Textarea
placeholder={t("homework.grade.feedbackPlaceholder", { name: studentName })}
value={ans.feedback ?? ""}
onChange={(e) => handleFeedbackChange(ans.id, e.target.value)}
className="min-h-[80px] bg-background"
/>
</div>
)}
</CardFooter>
</Card>
)
})}
</div>
</ScrollArea>
</div>
@@ -482,108 +495,22 @@ export function HomeworkGradingView({
)
}
type ChoiceOption = { id?: unknown; isCorrect?: unknown; text?: string }
// Delegate to shared pure functions in lib/question-content-utils
// (kept here only as thin wrappers to preserve existing call sites)
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 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 }
const isAutoGradable = (ans: Answer): boolean =>
isAutoGradableUtil({
questionType: ans.questionType,
questionContent: ans.questionContent,
})
}
const applyAutoGrades = (incoming: Answer[]): Answer[] =>
applyAutoGradesUtil(incoming)
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 getCorrectnessState = (ans: Answer): CorrectnessState =>
getCorrectnessStateUtil({ score: ans.score, maxScore: ans.maxScore })
const formatStudentAnswer = (studentAnswer: unknown): string => {
const v = extractAnswerValue(studentAnswer)

View File

@@ -5,14 +5,11 @@ 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"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { Card, CardHeader } from "@/shared/components/ui/card"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import {
AlertDialog,
@@ -24,48 +21,14 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { Clock, CheckCircle2, Save, FileText, ChevronLeft, TriangleAlert } from "lucide-react"
import { Clock, CheckCircle2, Save, FileText, ChevronLeft, TriangleAlert, CloudUpload, CloudOff, Check, Loader2 } from "lucide-react"
import { formatDate, cn } from "@/shared/lib/utils"
import type { StudentHomeworkTakeData } from "../types"
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction } from "../actions"
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
type Option = { id: string; text: string }
const getQuestionText = (content: unknown): string => {
if (!isRecord(content)) return ""
return typeof content.text === "string" ? content.text : ""
}
const getOptions = (content: unknown): Option[] => {
if (!isRecord(content)) return []
const raw = content.options
if (!Array.isArray(raw)) return []
const out: Option[] = []
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 toAnswerShape = (questionType: string, v: unknown) => {
if (questionType === "text") return { answer: typeof v === "string" ? v : "" }
if (questionType === "judgment") return { answer: typeof v === "boolean" ? v : false }
if (questionType === "single_choice") return { answer: typeof v === "string" ? v : "" }
if (questionType === "multiple_choice") return { answer: Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [] }
return { answer: v }
}
const parseSavedAnswer = (saved: unknown, questionType: string) => {
if (isRecord(saved) && "answer" in saved) return toAnswerShape(questionType, saved.answer)
return toAnswerShape(questionType, saved)
}
import { QuestionRenderer } from "./question-renderer"
import { parseSavedAnswer } from "../lib/question-content-utils"
import { useDebouncedAutoSave, loadOfflineCache, clearOfflineCache } from "../hooks/use-debounced-auto-save"
type HomeworkTakeViewProps = {
assignmentId: string
@@ -98,6 +61,44 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
const canEdit = isStarted && Boolean(submissionId)
const showQuestions = submissionStatus !== "not_started"
// P2-9: 自动保存 + 离线缓存
const offlineStorageKey = `homework-draft-${assignmentId}`
const autoSave = useDebouncedAutoSave({
submissionId,
answers: answersByQuestionId,
enabled: canEdit,
storageKey: offlineStorageKey,
})
// 挂载时尝试从 localStorage 恢复未提交的答案
useEffect(() => {
if (!canEdit) return
const cached = loadOfflineCache(offlineStorageKey)
if (!cached) return
setAnswersByQuestionId((prev) => {
const merged: Record<string, { answer: unknown }> = { ...prev }
let changed = false
for (const questionId of Object.keys(cached)) {
const cachedEntry = cached[questionId]
if (!cachedEntry) continue
const prevEntry = prev[questionId]
const cachedJson = JSON.stringify(cachedEntry.answer)
const prevJson = prevEntry ? JSON.stringify(prevEntry.answer) : ""
if (cachedJson !== prevJson) {
merged[questionId] = { answer: cachedEntry.answer }
changed = true
}
}
if (changed) {
toast.success(t("homework.take.autoSaveRestored"))
}
return merged
})
// 仅恢复一次,恢复后清除缓存(避免重复提示)
clearOfflineCache(offlineStorageKey)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [canEdit])
// 离开警告:作答中未提交时关闭/刷新页面会丢失答案
useEffect(() => {
if (!canEdit) return
@@ -155,25 +156,15 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
const handleSubmit = async () => {
if (!submissionId) return
setIsBusy(true)
// Save all first
for (const q of initialData.questions) {
const payload = answersByQuestionId[q.questionId]?.answer ?? null
const fd = new FormData()
fd.set("submissionId", submissionId)
fd.set("questionId", q.questionId)
fd.set("answerJson", JSON.stringify({ answer: payload }))
const res = await saveHomeworkAnswerAction(null, fd)
if (!res.success) {
toast.error(res.message || t("homework.take.saveFailed"))
setIsBusy(false)
return
}
}
// P2-9: 提交前 flush 自动保存队列,确保所有答案已落库
await autoSave.flush()
const submitFd = new FormData()
submitFd.set("submissionId", submissionId)
const submitRes = await submitHomeworkAction(null, submitFd)
if (submitRes.success) {
// 提交成功后清除离线缓存
clearOfflineCache(offlineStorageKey)
toast.success(t("homework.take.submitSuccess"))
setSubmissionStatus("submitted")
router.push("/student/learning/assignments")
@@ -248,155 +239,46 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
)}
{showQuestions && initialData.questions.map((q, idx) => {
const text = getQuestionText(q.questionContent)
const options = getOptions(q.questionContent)
const value = answersByQuestionId[q.questionId]?.answer
return (
<Card key={q.questionId} id={`question-${q.questionId}`} className="border-l-4 border-l-primary shadow-sm scroll-mt-4">
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<CardTitle className="text-base font-medium">
{t("homework.take.question", { index: idx + 1 })}
</CardTitle>
<CardDescription className="text-xs">
{q.questionType.replace("_", " ").replace(/\b\w/g, l => l.toUpperCase())} {q.maxScore} {t("homework.take.points")}
</CardDescription>
</div>
</div>
<QuestionRenderer
questionId={q.questionId}
questionType={q.questionType}
questionContent={q.questionContent}
maxScore={q.maxScore}
index={idx}
mode={submissionStatus === "graded" ? "review" : "take"}
value={value}
disabled={!canEdit}
onChange={(answer) =>
setAnswersByQuestionId((prev) => ({
...prev,
[q.questionId]: { answer },
}))
}
showCorrectAnswer={submissionStatus === "graded"}
feedback={submissionStatus === "graded" ? q.feedback : null}
footerExtra={
canEdit ? (
<div className="flex justify-end pt-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleSaveQuestion(q.questionId)}
disabled={isBusy}
className="text-muted-foreground hover:text-foreground"
>
<Save className="mr-2 h-3 w-3" />
{t("homework.take.saveAnswer")}
</Button>
</div>
) : null
}
/>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-sm font-medium leading-relaxed">{text || "—"}</div>
{q.questionType === "text" ? (
<div className="grid gap-2">
<Label className="sr-only">{t("homework.take.yourAnswer")}</Label>
<Textarea
placeholder={t("homework.take.answerPlaceholder")}
value={typeof value === "string" ? value : ""}
onChange={(e) =>
setAnswersByQuestionId((prev) => ({
...prev,
[q.questionId]: { answer: e.target.value },
}))
}
className="min-h-[120px] resize-y"
disabled={!canEdit}
/>
</div>
) : q.questionType === "judgment" ? (
<div className="grid gap-2">
<RadioGroup
value={typeof value === "boolean" ? (value ? "true" : "false") : ""}
onValueChange={(v) =>
setAnswersByQuestionId((prev) => ({
...prev,
[q.questionId]: { answer: v === "true" },
}))
}
disabled={!canEdit}
className="flex flex-col gap-2"
>
<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">{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">{t("homework.take.false")}</Label>
</div>
</RadioGroup>
</div>
) : q.questionType === "single_choice" ? (
<div className="grid gap-2">
<RadioGroup
value={typeof value === "string" ? value : ""}
onValueChange={(v) =>
setAnswersByQuestionId((prev) => ({
...prev,
[q.questionId]: { answer: v },
}))
}
disabled={!canEdit}
className="flex flex-col gap-2"
>
{options.map((o) => (
<div key={o.id} className="flex items-center space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
<RadioGroupItem value={o.id} id={`${q.questionId}-${o.id}`} />
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 cursor-pointer font-normal">
{o.text}
</Label>
</div>
))}
</RadioGroup>
</div>
) : q.questionType === "multiple_choice" ? (
<div className="grid gap-2">
<div className="flex flex-col gap-2">
{options.map((o) => {
const selected = Array.isArray(value) ? value.includes(o.id) : false
return (
<div key={o.id} className="flex items-start space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
<Checkbox
id={`${q.questionId}-${o.id}`}
checked={selected}
onCheckedChange={(checked) => {
const isChecked = checked === true
setAnswersByQuestionId((prev) => {
const current = Array.isArray(prev[q.questionId]?.answer)
? (prev[q.questionId]?.answer as string[])
: []
const next = isChecked
? Array.from(new Set([...current, o.id]))
: current.filter((x) => x !== o.id)
return { ...prev, [q.questionId]: { answer: next } }
})
}}
disabled={!canEdit}
/>
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 cursor-pointer font-normal leading-normal">
{o.text}
</Label>
</div>
)
})}
</div>
</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">{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">{t("homework.take.noFeedback")}</div>
)}
</div>
)}
{canEdit ? (
<div className="flex justify-end pt-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleSaveQuestion(q.questionId)}
disabled={isBusy}
className="text-muted-foreground hover:text-foreground"
>
<Save className="mr-2 h-3 w-3" />
{t("homework.take.saveAnswer")}
</Button>
</div>
) : null}
</CardContent>
</Card>
)
})}
@@ -407,6 +289,21 @@ 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">{t("homework.take.assignmentInfo")}</h3>
{canEdit && (
<div className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground" role="status" aria-live="polite">
{autoSave.status === "saving" && <Loader2 className="h-3 w-3 animate-spin" />}
{autoSave.status === "saved" && <Check className="h-3 w-3 text-green-500" />}
{autoSave.status === "error" && <CloudOff className="h-3 w-3 text-destructive" />}
{autoSave.status === "idle" && <CloudUpload className="h-3 w-3" />}
<span className={
autoSave.status === "saved" ? "text-green-600" :
autoSave.status === "error" ? "text-destructive" :
"text-muted-foreground"
}>
{t(`homework.take.autoSave${autoSave.status.charAt(0).toUpperCase()}${autoSave.status.slice(1)}`)}
</span>
</div>
)}
</div>
<div className="flex-1 p-4 overflow-y-auto">
<div className="space-y-6">
@@ -467,9 +364,10 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
<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 &&
answersByQuestionId[q.questionId]?.answer !== "" &&
(Array.isArray(answersByQuestionId[q.questionId]?.answer) ? (answersByQuestionId[q.questionId]?.answer as unknown[]).length > 0 : true)
const answer = answersByQuestionId[q.questionId]?.answer
const hasAnswer = answer !== undefined &&
answer !== "" &&
(Array.isArray(answer) ? answer.length > 0 : true)
return (
<button
@@ -484,6 +382,8 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
hasAnswer ? "bg-primary text-primary-foreground border-primary" : "bg-background text-muted-foreground border-input"
)}
aria-label={t("homework.take.jumpToQuestion", { index: i + 1 })}
aria-pressed={hasAnswer}
title={hasAnswer ? t("homework.take.answered") : t("homework.take.unanswered")}
>
{i + 1}
</button>

View File

@@ -0,0 +1,361 @@
"use client"
import { type ReactNode } from "react"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { Label } from "@/shared/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
import { Textarea } from "@/shared/components/ui/textarea"
import { useTranslations } from "next-intl"
import {
extractAnswerValue,
getOptions,
getQuestionText,
isRecord,
type QuestionOption,
type QuestionType,
} from "../lib/question-content-utils"
/**
* 题目渲染模式
* - `take`: 学生作答交互
* - `review`: 学生查看批改结果(只读 + 正确答案高亮)
* - `grade`: 教师批改(只读学生答案 + 正确答案 + 评分面板 slot
*/
export type QuestionRenderMode = "take" | "review" | "grade"
export interface QuestionRendererProps {
questionId: string
questionType: QuestionType
questionContent: unknown
maxScore: number
index: number
mode: QuestionRenderMode
/** 学生答案take 模式下为当前值review/grade 模式下为已提交值) */
value?: unknown
/** take 模式下的禁用状态 */
disabled?: boolean
/** take 模式下答案变更回调 */
onChange?: (answer: unknown) => void
/** review/grade 模式下是否显示正确答案 */
showCorrectAnswer?: boolean
/** review/grade 模式下是否显示批改反馈 */
feedback?: string | null
/** 题目头部右侧额外内容(如分数 Badge */
headerExtra?: ReactNode
/** 题目底部额外内容(如批改面板) */
footerExtra?: ReactNode
/** 题目卡片额外 className */
className?: string
}
/**
* 题目渲染器(只读展示 + 答案输入组合)
*
* 通过 `mode` 切换交互行为:
* - `take`: 渲染可编辑的作答输入控件
* - `review`: 渲染只读答案 + 正确答案高亮
* - `grade`: 渲染只读学生答案 + 正确答案(由父组件通过 `footerExtra` 注入批改面板)
*/
export function QuestionRenderer({
questionId,
questionType,
questionContent,
maxScore,
index,
mode,
value,
disabled,
onChange,
showCorrectAnswer,
feedback,
headerExtra,
footerExtra,
className,
}: QuestionRendererProps) {
const t = useTranslations("examHomework")
const text = getQuestionText(questionContent)
const options = getOptions(questionContent)
const isReadOnly = mode !== "take"
const showFeedback = isReadOnly && Boolean(feedback)
return (
<article
id={`question-${questionId}`}
className={className}
aria-labelledby={`question-${questionId}-title`}
>
<header className="flex items-start justify-between gap-4">
<div className="space-y-1">
<h3
id={`question-${questionId}-title`}
className="text-base font-medium"
>
{t("homework.take.question", { index: index + 1 })}
</h3>
<p className="text-xs text-muted-foreground">
{questionType.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase())} {maxScore} {t("homework.take.points")}
</p>
</div>
{headerExtra}
</header>
<div className="mt-4 text-sm font-medium leading-relaxed">{text || "—"}</div>
<div className="mt-4">
<QuestionAnswerInput
questionId={questionId}
questionType={questionType}
options={options}
value={value}
disabled={disabled}
readOnly={isReadOnly}
onChange={onChange}
showCorrectAnswer={showCorrectAnswer === true}
questionContent={questionContent}
/>
</div>
{showFeedback && (
<div className="mt-6 rounded-md bg-muted/40 p-4 border border-border/50">
<div className="text-sm space-y-1">
<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">
{feedback}
</div>
</div>
</div>
)}
{footerExtra}
</article>
)
}
interface QuestionAnswerInputProps {
questionId: string
questionType: QuestionType
options: QuestionOption[]
value: unknown
disabled?: boolean
readOnly?: boolean
onChange?: (answer: unknown) => void
showCorrectAnswer?: boolean
questionContent: unknown
}
function QuestionAnswerInput({
questionId,
questionType,
options,
value,
disabled,
readOnly,
onChange,
showCorrectAnswer,
questionContent,
}: QuestionAnswerInputProps) {
const t = useTranslations("examHomework")
if (questionType === "text") {
if (readOnly) {
const textValue = typeof value === "string" ? value : ""
return (
<div className="grid gap-2">
<Label className="sr-only">{t("homework.take.yourAnswer")}</Label>
<div className="rounded-md border p-3 bg-muted/20 text-sm min-h-[60px]">
{textValue || (
<span className="text-muted-foreground italic">{t("homework.review.noAnswer")}</span>
)}
</div>
{showCorrectAnswer && (
<CorrectAnswerDisplay questionType="text" questionContent={questionContent} />
)}
</div>
)
}
return (
<div className="grid gap-2">
<Label className="sr-only">{t("homework.take.yourAnswer")}</Label>
<Textarea
placeholder={t("homework.take.answerPlaceholder")}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange?.(e.target.value)}
className="min-h-[120px] resize-y"
disabled={disabled}
/>
</div>
)
}
if (questionType === "judgment") {
const boolValue = typeof value === "boolean" ? (value ? "true" : "false") : ""
return (
<div className="grid gap-2">
<RadioGroup
value={boolValue}
onValueChange={(v) => onChange?.(v === "true")}
disabled={disabled || readOnly}
className="flex flex-col gap-2"
>
<div className="flex items-center space-x-2 rounded-md border p-3 bg-muted/20">
<RadioGroupItem value="true" id={`${questionId}-true`} />
<Label htmlFor={`${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 bg-muted/20">
<RadioGroupItem value="false" id={`${questionId}-false`} />
<Label htmlFor={`${questionId}-false`} className="flex-1 cursor-pointer font-normal">
{t("homework.take.false")}
</Label>
</div>
</RadioGroup>
{showCorrectAnswer && readOnly && (
<CorrectAnswerDisplay questionType="judgment" questionContent={questionContent} />
)}
</div>
)
}
if (questionType === "single_choice") {
const strValue = typeof value === "string" ? value : ""
return (
<div className="grid gap-2">
<RadioGroup
value={strValue}
onValueChange={(v) => onChange?.(v)}
disabled={disabled || readOnly}
className="flex flex-col gap-2"
>
{options.map((o) => {
const isCorrectOption = showCorrectAnswer && o.isCorrect === true
return (
<div
key={o.id}
className={`flex items-center space-x-2 rounded-md border p-3 ${
readOnly ? "bg-muted/20" : "hover:bg-muted/50 transition-colors"
} ${isCorrectOption ? "border-emerald-300 bg-emerald-50" : ""}`}
>
<RadioGroupItem value={o.id} id={`${questionId}-${o.id}`} />
<Label htmlFor={`${questionId}-${o.id}`} className="flex-1 cursor-pointer font-normal">
{o.text}
{isCorrectOption && (
<span className="ml-2 text-xs font-medium text-emerald-700">
{t("homework.review.correctMarker")}
</span>
)}
</Label>
</div>
)
})}
</RadioGroup>
</div>
)
}
if (questionType === "multiple_choice") {
const selectedIds = Array.isArray(value)
? value.filter((x): x is string => typeof x === "string")
: []
return (
<div className="grid gap-2">
<div className="flex flex-col gap-2">
{options.map((o) => {
const selected = selectedIds.includes(o.id)
const isCorrectOption = showCorrectAnswer && o.isCorrect === true
return (
<div
key={o.id}
className={`flex items-start space-x-2 rounded-md border p-3 ${
readOnly ? "bg-muted/20" : "hover:bg-muted/50 transition-colors"
} ${isCorrectOption ? "border-emerald-300 bg-emerald-50" : ""}`}
>
<Checkbox
id={`${questionId}-${o.id}`}
checked={selected}
onCheckedChange={(checked) => {
if (readOnly) return
const isChecked = checked === true
const next = isChecked
? Array.from(new Set([...selectedIds, o.id]))
: selectedIds.filter((x) => x !== o.id)
onChange?.(next)
}}
disabled={disabled || readOnly}
/>
<Label htmlFor={`${questionId}-${o.id}`} className="flex-1 cursor-pointer font-normal leading-normal">
{o.text}
{isCorrectOption && (
<span className="ml-2 text-xs font-medium text-emerald-700">
{t("homework.review.correctMarker")}
</span>
)}
</Label>
</div>
)
})}
</div>
</div>
)
}
return (
<div className="text-sm text-muted-foreground italic">
{t("homework.take.unsupportedType")}
</div>
)
}
/**
* 正确答案展示review/grade 模式)
*/
function CorrectAnswerDisplay({
questionType,
questionContent,
}: {
questionType: QuestionType
questionContent: unknown
}) {
const t = useTranslations("examHomework")
if (questionType === "text") {
const correctTexts = (() => {
if (!isRecord(questionContent)) return []
const raw = questionContent.correctAnswer
if (typeof raw === "string") return [raw]
if (Array.isArray(raw)) return raw.filter((x): x is string => typeof x === "string")
return []
})()
if (correctTexts.length === 0) return null
return (
<div className="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm">
<div className="font-medium text-emerald-700 mb-1">{t("homework.review.correctAnswer")}</div>
<div className="text-emerald-900">{correctTexts.join(" / ")}</div>
</div>
)
}
if (questionType === "judgment") {
const correct = (() => {
if (!isRecord(questionContent)) return null
return typeof questionContent.correctAnswer === "boolean"
? questionContent.correctAnswer
: null
})()
if (correct === null) return null
return (
<div className="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm">
<span className="font-medium text-emerald-700">{t("homework.review.correctAnswer")}: </span>
<span className="text-emerald-900">{correct ? t("homework.take.true") : t("homework.take.false")}</span>
</div>
)
}
return null
}
/**
* 提取学生答案值(便于父组件格式化展示)
*/
export { extractAnswerValue }

View File

@@ -3,76 +3,26 @@
import { useMemo } from "react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Label } from "@/shared/components/ui/label"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { FileText, ChevronLeft } from "lucide-react"
import Link from "next/link"
import { useTranslations } from "next-intl"
import type { StudentHomeworkTakeData } from "../types"
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
type Option = { id: string; text: string; isCorrect?: boolean }
const getQuestionText = (content: unknown): string => {
if (!isRecord(content)) return ""
return typeof content.text === "string" ? content.text : ""
}
const getOptions = (content: unknown): Option[] => {
if (!isRecord(content)) return []
const raw = content.options
if (!Array.isArray(raw)) return []
const out: Option[] = []
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
const isCorrect = item.isCorrect === true
out.push({ id, text, isCorrect })
}
return out
}
const getChoiceCorrectIds = (content: unknown): string[] => {
return getOptions(content).filter((o) => o.isCorrect).map((o) => o.id)
}
const getJudgmentCorrectAnswer = (content: unknown): boolean | null => {
if (!isRecord(content)) return null
return typeof content.correctAnswer === "boolean" ? content.correctAnswer : null
}
const getTextCorrectAnswers = (content: unknown): string[] => {
if (!isRecord(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 toAnswerShape = (questionType: string, v: unknown) => {
if (questionType === "text") return { answer: typeof v === "string" ? v : "" }
if (questionType === "judgment") return { answer: typeof v === "boolean" ? v : false }
if (questionType === "single_choice") return { answer: typeof v === "string" ? v : "" }
if (questionType === "multiple_choice") return { answer: Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [] }
return { answer: v }
}
const parseSavedAnswer = (saved: unknown, questionType: string) => {
if (isRecord(saved) && "answer" in saved) return toAnswerShape(questionType, saved.answer)
return toAnswerShape(questionType, saved)
}
import { QuestionRenderer } from "./question-renderer"
import {
getCorrectnessState,
parseSavedAnswer,
} from "../lib/question-content-utils"
type HomeworkReviewViewProps = {
initialData: StudentHomeworkTakeData
}
export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
const t = useTranslations("examHomework")
const submissionStatus = initialData.submission?.status ?? "not_started"
const isGraded = submissionStatus === "graded"
@@ -96,22 +46,22 @@ export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
</div>
<div>
<h3 className="font-semibold leading-none">
{isGraded ? "Graded Report" : "Submission Details"}
{isGraded ? t("homework.review.gradedReport") : t("homework.review.submissionDetails")}
</h3>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] capitalize">
{submissionStatus}
</Badge>
<span></span>
<span>{initialData.questions.length} Questions</span>
<span>{initialData.questions.length} {t("homework.review.questionsUnit")}</span>
</div>
</div>
</div>
<Button asChild variant="outline" size="sm">
<Link href="/student/learning/assignments">
<ChevronLeft className="mr-2 h-4 w-4" />
Back to List
{t("homework.review.backToList")}
</Link>
</Button>
</div>
@@ -119,162 +69,57 @@ export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
<ScrollArea className="flex-1 bg-muted/10">
<div className="space-y-6 p-6 max-w-4xl mx-auto">
{initialData.questions.map((q, idx) => {
const text = getQuestionText(q.questionContent)
const options = getOptions(q.questionContent)
const value = answersByQuestionId[q.questionId]?.answer
const correctness = isGraded
? getCorrectnessState({ score: q.score ?? null, maxScore: q.maxScore })
: "ungraded"
const borderClass =
correctness === "correct"
? "border-l-4 border-l-emerald-500"
: correctness === "incorrect"
? "border-l-4 border-l-red-500"
: correctness === "partial"
? "border-l-4 border-l-yellow-500"
: "border-l-4 border-l-primary"
return (
<Card key={q.questionId} className={`shadow-sm ${isGraded ? 'border-l-4' : 'border-l-4 border-l-primary'}`}
style={isGraded ? { borderLeftColor: q.score === q.maxScore && q.maxScore > 0 ? '#10b981' : q.score && q.score > 0 ? '#eab308' : '#ef4444' } : undefined}
>
<Card key={q.questionId} className={`shadow-sm ${borderClass}`}>
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<CardTitle className="text-base font-medium flex items-center gap-2">
Question {idx + 1}
{isGraded && (
<Badge variant="outline" className={`ml-2 ${q.score === q.maxScore ? "text-emerald-600 border-emerald-200 bg-emerald-50" : "text-red-600 border-red-200 bg-red-50"}`}>
{q.score} / {q.maxScore}
</Badge>
)}
</CardTitle>
<CardDescription className="text-xs flex flex-col gap-1.5">
<span>{q.questionType.replace("_", " ").replace(/\b\w/g, l => l.toUpperCase())} {q.maxScore} points</span>
{q.knowledgePoints && q.knowledgePoints.length > 0 && (
<div className="flex flex-wrap gap-1">
{q.knowledgePoints.map((kp) => (
<Badge key={kp.id} variant="secondary" className="text-[10px] px-1.5 py-0 h-5">
{kp.name}
</Badge>
))}
</div>
)}
</CardDescription>
</div>
</div>
<QuestionRenderer
questionId={q.questionId}
questionType={q.questionType}
questionContent={q.questionContent}
maxScore={q.maxScore}
index={idx}
mode="review"
value={value}
showCorrectAnswer={isGraded}
feedback={isGraded ? q.feedback : null}
headerExtra={
isGraded ? (
<Badge
variant="outline"
aria-label={t(`homework.grade.${correctness === "ungraded" ? "partial" : correctness}`)}
className={
correctness === "correct"
? "text-emerald-600 border-emerald-200 bg-emerald-50"
: "text-red-600 border-red-200 bg-red-50"
}
>
{q.score} / {q.maxScore}
</Badge>
) : null
}
/>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-sm font-medium leading-relaxed">{text || "—"}</div>
{q.questionType === "text" ? (
<div className="grid gap-2">
<Label className="sr-only">Your answer</Label>
<div className="rounded-md border p-3 bg-muted/20 text-sm min-h-[60px]">
{typeof value === "string" ? value : <span className="text-muted-foreground italic">No answer provided</span>}
</div>
{isGraded && (() => {
const correctTexts = getTextCorrectAnswers(q.questionContent)
if (correctTexts.length === 0) return null
return (
<div className="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm">
<div className="font-medium text-emerald-700 mb-1">Correct Answer</div>
<div className="text-emerald-900">{correctTexts.join(" / ")}</div>
</div>
)
})()}
</div>
) : q.questionType === "judgment" ? (
<div className="grid gap-2">
<RadioGroup
value={typeof value === "boolean" ? (value ? "true" : "false") : ""}
disabled
className="flex flex-col gap-2"
>
<div className="flex items-center space-x-2 rounded-md border p-3 bg-muted/20">
<RadioGroupItem value="true" id={`${q.questionId}-true`} />
<Label htmlFor={`${q.questionId}-true`} className="flex-1 font-normal">True</Label>
</div>
<div className="flex items-center space-x-2 rounded-md border p-3 bg-muted/20">
<RadioGroupItem value="false" id={`${q.questionId}-false`} />
<Label htmlFor={`${q.questionId}-false`} className="flex-1 font-normal">False</Label>
</div>
</RadioGroup>
{isGraded && (() => {
const correct = getJudgmentCorrectAnswer(q.questionContent)
if (correct === null) return null
return (
<div className="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm">
<span className="font-medium text-emerald-700">Correct Answer: </span>
<span className="text-emerald-900">{correct ? "True" : "False"}</span>
</div>
)
})()}
</div>
) : q.questionType === "single_choice" ? (
<div className="grid gap-2">
<RadioGroup
value={typeof value === "string" ? value : ""}
disabled
className="flex flex-col gap-2"
>
{options.map((o) => {
const correctIds = isGraded ? getChoiceCorrectIds(q.questionContent) : []
const isCorrectOption = isGraded && correctIds.includes(o.id)
return (
<div
key={o.id}
className={`flex items-center space-x-2 rounded-md border p-3 bg-muted/20 ${
isCorrectOption ? "border-emerald-300 bg-emerald-50" : ""
}`}
>
<RadioGroupItem value={o.id} id={`${q.questionId}-${o.id}`} />
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 font-normal">
{o.text}
{isCorrectOption && (
<span className="ml-2 text-xs font-medium text-emerald-700"> Correct</span>
)}
</Label>
</div>
)
})}
</RadioGroup>
</div>
) : q.questionType === "multiple_choice" ? (
<div className="grid gap-2">
<div className="flex flex-col gap-2">
{options.map((o) => {
const selected = Array.isArray(value) ? value.includes(o.id) : false
const correctIds = isGraded ? getChoiceCorrectIds(q.questionContent) : []
const isCorrectOption = isGraded && correctIds.includes(o.id)
return (
<div
key={o.id}
className={`flex items-start space-x-2 rounded-md border p-3 bg-muted/20 ${
isCorrectOption ? "border-emerald-300 bg-emerald-50" : ""
}`}
>
<Checkbox
id={`${q.questionId}-${o.id}`}
checked={selected}
disabled
/>
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 font-normal leading-normal">
{o.text}
{isCorrectOption && (
<span className="ml-2 text-xs font-medium text-emerald-700"> Correct</span>
)}
</Label>
</div>
)
})}
</div>
</div>
) : (
<div className="text-sm text-muted-foreground italic">Unsupported question type</div>
)}
{isGraded && (
<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="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>
)}
<CardContent className="space-y-4 pt-0">
{q.knowledgePoints && q.knowledgePoints.length > 0 && (
<div className="flex flex-wrap gap-1">
{q.knowledgePoints.map((kp) => (
<Badge key={kp.id} variant="secondary" className="text-[10px] px-1.5 py-0 h-5">
{kp.name}
</Badge>
))}
</div>
)}
</CardContent>
@@ -287,29 +132,29 @@ export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
<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="secondary" className="capitalize">
{submissionStatus}
</Badge>
</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.review.description")}</Label>
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
{initialData.assignment.description || "No description provided."}
{initialData.assignment.description || t("homework.review.noDescription")}
</p>
</div>
{isGraded && (
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Total Score</Label>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.review.totalScore")}</Label>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-3xl font-bold text-primary">
{initialData.submission?.score ?? 0}
@@ -319,36 +164,38 @@ export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
</span>
</div>
<div className="mt-4 space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="h-3 w-3 rounded-full bg-emerald-600"></div>
<span>Correct</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="h-3 w-3 rounded-full bg-yellow-500"></div>
<span>Partial</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="h-3 w-3 rounded-full bg-red-500"></div>
<span>Incorrect</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="h-3 w-3 rounded-full bg-emerald-600" aria-hidden="true" />
<span>{t("homework.grade.correct")}</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="h-3 w-3 rounded-full bg-yellow-500" aria-hidden="true" />
<span>{t("homework.grade.partial")}</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="h-3 w-3 rounded-full bg-red-500" aria-hidden="true" />
<span>{t("homework.grade.incorrect")}</span>
</div>
</div>
</div>
)}
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">
{isGraded ? "Question Breakdown" : "Response Summary"}
{isGraded ? t("homework.review.questionBreakdown") : t("homework.review.responseSummary")}
</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)
(Array.isArray(answersByQuestionId[q.questionId]?.answer)
? (answersByQuestionId[q.questionId]?.answer as unknown[]).length > 0
: true)
const score = q.score ?? 0
const max = q.maxScore
let statusClass = "bg-background text-muted-foreground border-input"
if (isGraded) {
if (score === max && max > 0) statusClass = "bg-emerald-600 text-white border-emerald-600"
else if (score > 0) statusClass = "bg-yellow-500 text-white border-yellow-500"
@@ -356,14 +203,11 @@ export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
} else if (hasAnswer) {
statusClass = "bg-primary text-primary-foreground border-primary"
}
return (
<div
<div
key={q.questionId}
className={`
h-8 w-8 rounded flex items-center justify-center text-xs font-medium border
${statusClass}
`}
className={`h-8 w-8 rounded flex items-center justify-center text-xs font-medium border ${statusClass}`}
>
{i + 1}
</div>