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:
50
src/modules/homework/components/assignment-filters.tsx
Normal file
50
src/modules/homework/components/assignment-filters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
361
src/modules/homework/components/question-renderer.tsx
Normal file
361
src/modules/homework/components/question-renderer.tsx
Normal 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 }
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user