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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { and, count, desc, eq, inArray, isNull, lte, or, sql } from "drizzle-orm"
|
||||
import { and, asc, count, desc, eq, gt, inArray, isNull, lt, lte, or, sql } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
@@ -118,11 +118,113 @@ export const getHomeworkAssignments = cache(async (params?: { creatorId?: string
|
||||
},
|
||||
})
|
||||
|
||||
if (data.length === 0) return []
|
||||
|
||||
const assignmentIds = data.map((a) => a.id)
|
||||
const now = new Date()
|
||||
|
||||
// 并行查询:目标学生数 / 已提交数 / 已批改数 / 已批改平均分 / 逾期未提交学生集合
|
||||
const [targetCountRows, submittedCountRows, gradedCountRows, avgScoreRows, submittedStudentRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
assignmentId: homeworkAssignmentTargets.assignmentId,
|
||||
targetCount: sql<number>`COUNT(*)`,
|
||||
})
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(inArray(homeworkAssignmentTargets.assignmentId, assignmentIds))
|
||||
.groupBy(homeworkAssignmentTargets.assignmentId),
|
||||
db
|
||||
.select({
|
||||
assignmentId: homeworkSubmissions.assignmentId,
|
||||
submittedCount: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})`,
|
||||
})
|
||||
.from(homeworkSubmissions)
|
||||
.where(
|
||||
and(
|
||||
inArray(homeworkSubmissions.assignmentId, assignmentIds),
|
||||
inArray(homeworkSubmissions.status, ["submitted", "graded"])
|
||||
)
|
||||
)
|
||||
.groupBy(homeworkSubmissions.assignmentId),
|
||||
db
|
||||
.select({
|
||||
assignmentId: homeworkSubmissions.assignmentId,
|
||||
gradedCount: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})`,
|
||||
})
|
||||
.from(homeworkSubmissions)
|
||||
.where(and(inArray(homeworkSubmissions.assignmentId, assignmentIds), eq(homeworkSubmissions.status, "graded")))
|
||||
.groupBy(homeworkSubmissions.assignmentId),
|
||||
db
|
||||
.select({
|
||||
assignmentId: homeworkSubmissions.assignmentId,
|
||||
avgScore: sql<number>`AVG(${homeworkSubmissions.score})`,
|
||||
})
|
||||
.from(homeworkSubmissions)
|
||||
.where(
|
||||
and(
|
||||
inArray(homeworkSubmissions.assignmentId, assignmentIds),
|
||||
eq(homeworkSubmissions.status, "graded")
|
||||
)
|
||||
)
|
||||
.groupBy(homeworkSubmissions.assignmentId),
|
||||
db
|
||||
.select({
|
||||
assignmentId: homeworkSubmissions.assignmentId,
|
||||
studentId: homeworkSubmissions.studentId,
|
||||
})
|
||||
.from(homeworkSubmissions)
|
||||
.where(
|
||||
and(
|
||||
inArray(homeworkSubmissions.assignmentId, assignmentIds),
|
||||
inArray(homeworkSubmissions.status, ["submitted", "graded"])
|
||||
)
|
||||
),
|
||||
])
|
||||
|
||||
const targetCountByAssignmentId = new Map<string, number>()
|
||||
for (const r of targetCountRows) targetCountByAssignmentId.set(r.assignmentId, Number(r.targetCount ?? 0))
|
||||
|
||||
const submittedCountByAssignmentId = new Map<string, number>()
|
||||
for (const r of submittedCountRows) submittedCountByAssignmentId.set(r.assignmentId, Number(r.submittedCount ?? 0))
|
||||
|
||||
const gradedCountByAssignmentId = new Map<string, number>()
|
||||
for (const r of gradedCountRows) gradedCountByAssignmentId.set(r.assignmentId, Number(r.gradedCount ?? 0))
|
||||
|
||||
const avgScoreByAssignmentId = new Map<string, number | null>()
|
||||
for (const r of avgScoreRows) {
|
||||
const v = r.avgScore
|
||||
avgScoreByAssignmentId.set(r.assignmentId, v === null ? null : Number(v))
|
||||
}
|
||||
|
||||
// 已提交学生集合(按 assignmentId 分组),用于计算逾期未提交人数
|
||||
const submittedStudentIdsByAssignmentId = new Map<string, Set<string>>()
|
||||
for (const r of submittedStudentRows) {
|
||||
let set = submittedStudentIdsByAssignmentId.get(r.assignmentId)
|
||||
if (!set) {
|
||||
set = new Set<string>()
|
||||
submittedStudentIdsByAssignmentId.set(r.assignmentId, set)
|
||||
}
|
||||
set.add(r.studentId)
|
||||
}
|
||||
|
||||
// 逾期未提交人数 = 目标学生数 - 已提交学生数(仅当 dueAt 已过时计算)
|
||||
const computeOverdueCount = (assignmentId: string, dueAt: Date | null): number => {
|
||||
if (!dueAt || dueAt > now) return 0
|
||||
const targetCount = targetCountByAssignmentId.get(assignmentId) ?? 0
|
||||
const submittedCount = submittedStudentIdsByAssignmentId.get(assignmentId)?.size ?? 0
|
||||
return Math.max(0, targetCount - submittedCount)
|
||||
}
|
||||
|
||||
return data.map((a) => {
|
||||
const targetCount = targetCountByAssignmentId.get(a.id) ?? 0
|
||||
const submittedCount = submittedCountByAssignmentId.get(a.id) ?? 0
|
||||
const gradedCount = gradedCountByAssignmentId.get(a.id) ?? 0
|
||||
const averageScore = avgScoreByAssignmentId.get(a.id) ?? null
|
||||
const overdueCount = computeOverdueCount(a.id, a.dueAt)
|
||||
const item: HomeworkAssignmentListItem = {
|
||||
id: a.id,
|
||||
sourceExamId: a.sourceExamId,
|
||||
sourceExamTitle: a.sourceExam.title,
|
||||
sourceExamTitle: a.sourceExam?.title ?? null,
|
||||
title: a.title,
|
||||
status: toHomeworkAssignmentStatus(a.status),
|
||||
availableAt: a.availableAt ? a.availableAt.toISOString() : null,
|
||||
@@ -132,6 +234,11 @@ export const getHomeworkAssignments = cache(async (params?: { creatorId?: string
|
||||
maxAttempts: a.maxAttempts,
|
||||
createdAt: a.createdAt.toISOString(),
|
||||
updatedAt: a.updatedAt.toISOString(),
|
||||
targetCount,
|
||||
submittedCount,
|
||||
gradedCount,
|
||||
averageScore,
|
||||
overdueCount,
|
||||
}
|
||||
return item
|
||||
})
|
||||
@@ -221,7 +328,7 @@ export const getHomeworkAssignmentReviewList = cache(async (params: { creatorId:
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
status: toHomeworkAssignmentStatus(a.status),
|
||||
sourceExamTitle: a.sourceExam.title,
|
||||
sourceExamTitle: a.sourceExam?.title ?? null,
|
||||
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
|
||||
targetCount: targetCountByAssignmentId.get(a.id) ?? 0,
|
||||
submittedCount: submittedCountByAssignmentId.get(a.id) ?? 0,
|
||||
@@ -322,6 +429,8 @@ export const getHomeworkAssignmentById = cache(async (id: string, scope?: DataSc
|
||||
return null
|
||||
}
|
||||
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
|
||||
// 快速作业(无 sourceExamId)不归年级主任管辖,直接拒绝
|
||||
if (!assignment.sourceExamId) return null
|
||||
const examIds = await getExamIdsByGradeIds(scope.gradeIds)
|
||||
if (!examIds.includes(assignment.sourceExamId)) {
|
||||
return null
|
||||
@@ -371,8 +480,8 @@ export const getHomeworkAssignmentById = cache(async (id: string, scope?: DataSc
|
||||
description: assignment.description,
|
||||
status: toHomeworkAssignmentStatus(assignment.status),
|
||||
sourceExamId: assignment.sourceExamId,
|
||||
sourceExamTitle: assignment.sourceExam.title,
|
||||
structure: assignment.structure as unknown,
|
||||
sourceExamTitle: assignment.sourceExam?.title ?? null,
|
||||
structure: assignment.structure,
|
||||
availableAt: assignment.availableAt ? assignment.availableAt.toISOString() : null,
|
||||
dueAt: assignment.dueAt ? assignment.dueAt.toISOString() : null,
|
||||
allowLate: assignment.allowLate,
|
||||
@@ -427,16 +536,34 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
|
||||
})
|
||||
.sort((a, b) => a.order - b.order)
|
||||
|
||||
// Fetch adjacent submissions for navigation
|
||||
const allSubmissions = await db.query.homeworkSubmissions.findMany({
|
||||
where: eq(homeworkSubmissions.assignmentId, submission.assignmentId),
|
||||
orderBy: [desc(homeworkSubmissions.updatedAt)],
|
||||
columns: { id: true },
|
||||
})
|
||||
// P1-8: Optimize adjacent submission navigation using LIMIT 1 queries
|
||||
// instead of fetching all submission IDs for the assignment.
|
||||
// Original ordering is desc(updatedAt): "previous" = newer, "next" = older.
|
||||
const currentUpdatedAt = submission.updatedAt
|
||||
|
||||
const currentIndex = allSubmissions.findIndex((s) => s.id === submissionId)
|
||||
const prevSubmissionId = currentIndex > 0 ? allSubmissions[currentIndex - 1].id : null
|
||||
const nextSubmissionId = currentIndex >= 0 && currentIndex < allSubmissions.length - 1 ? allSubmissions[currentIndex + 1].id : null
|
||||
const [prevSubmission, nextSubmission] = await Promise.all([
|
||||
// Previous (newer): closest submission with updatedAt > current
|
||||
db.query.homeworkSubmissions.findFirst({
|
||||
where: and(
|
||||
eq(homeworkSubmissions.assignmentId, submission.assignmentId),
|
||||
gt(homeworkSubmissions.updatedAt, currentUpdatedAt)
|
||||
),
|
||||
orderBy: [asc(homeworkSubmissions.updatedAt)],
|
||||
columns: { id: true },
|
||||
}),
|
||||
// Next (older): closest submission with updatedAt < current
|
||||
db.query.homeworkSubmissions.findFirst({
|
||||
where: and(
|
||||
eq(homeworkSubmissions.assignmentId, submission.assignmentId),
|
||||
lt(homeworkSubmissions.updatedAt, currentUpdatedAt)
|
||||
),
|
||||
orderBy: [desc(homeworkSubmissions.updatedAt)],
|
||||
columns: { id: true },
|
||||
}),
|
||||
])
|
||||
|
||||
const prevSubmissionId = prevSubmission?.id ?? null
|
||||
const nextSubmissionId = nextSubmission?.id ?? null
|
||||
|
||||
return {
|
||||
id: submission.id,
|
||||
@@ -490,7 +617,10 @@ export const getStudentHomeworkAssignments = cache(async (studentId: string): Pr
|
||||
if (assignments.length === 0) return []
|
||||
|
||||
// Fetch subject names via cross-module interfaces
|
||||
const examIds = assignments.map((a) => a.sourceExamId)
|
||||
// 快速作业无 sourceExamId,过滤 null 后再查询科目映射
|
||||
const examIds = assignments
|
||||
.map((a) => a.sourceExamId)
|
||||
.filter((id): id is string => id !== null)
|
||||
const [examSubjectIdMap, subjectOptions] = await Promise.all([
|
||||
getExamSubjectIdMap(examIds),
|
||||
getSubjectOptions(),
|
||||
@@ -519,7 +649,7 @@ export const getStudentHomeworkAssignments = cache(async (studentId: string): Pr
|
||||
return assignments.map((a) => {
|
||||
const latest = latestSubmittedByAssignmentId.get(a.id) ?? latestByAssignmentId.get(a.id) ?? null
|
||||
const attemptsUsed = attemptsByAssignmentId.get(a.id) ?? 0
|
||||
const subjectId = examSubjectIdMap.get(a.sourceExamId) ?? null
|
||||
const subjectId = a.sourceExamId ? (examSubjectIdMap.get(a.sourceExamId) ?? null) : null
|
||||
const subjectName = subjectId ? subjectNameById.get(subjectId) ?? null : null
|
||||
|
||||
const item: StudentHomeworkAssignmentListItem = {
|
||||
|
||||
189
src/modules/homework/hooks/use-debounced-auto-save.ts
Normal file
189
src/modules/homework/hooks/use-debounced-auto-save.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { saveHomeworkAnswerAction } from "../actions"
|
||||
|
||||
type AutoSaveStatus = "idle" | "saving" | "saved" | "error"
|
||||
|
||||
type AnswerMap = Record<string, { answer: unknown } | undefined>
|
||||
|
||||
type UseDebouncedAutoSaveOptions = {
|
||||
submissionId: string | null
|
||||
answers: AnswerMap
|
||||
enabled: boolean
|
||||
debounceMs?: number
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
type UseDebouncedAutoSaveResult = {
|
||||
status: AutoSaveStatus
|
||||
lastSavedAt: number | null
|
||||
flush: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* P2-9: 学生答案自动保存 + 离线缓存
|
||||
*
|
||||
* - 答案变更后 debounce(默认 3 秒)自动保存到服务端
|
||||
* - 同时写入 localStorage 作为离线缓存
|
||||
* - 网络异常时标记 error,恢复后自动重试
|
||||
* - 组件卸载时 flush 未保存的答案
|
||||
*/
|
||||
export function useDebouncedAutoSave({
|
||||
submissionId,
|
||||
answers,
|
||||
enabled,
|
||||
debounceMs = 3000,
|
||||
storageKey,
|
||||
}: UseDebouncedAutoSaveOptions): UseDebouncedAutoSaveResult {
|
||||
const [status, setStatus] = useState<AutoSaveStatus>("idle")
|
||||
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pendingRef = useRef<Map<string, unknown>>(new Map())
|
||||
const savingRef = useRef(false)
|
||||
const lastSavedAnswersRef = useRef<string>("")
|
||||
|
||||
// Persist to localStorage for offline recovery
|
||||
const cacheToLocalStorage = useCallback(
|
||||
(snapshot: AnswerMap) => {
|
||||
if (!storageKey) return
|
||||
try {
|
||||
const serialized = JSON.stringify({
|
||||
submissionId,
|
||||
answers: snapshot,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
window.localStorage.setItem(storageKey, serialized)
|
||||
} catch {
|
||||
// localStorage may be full or unavailable; silently ignore
|
||||
}
|
||||
},
|
||||
[storageKey, submissionId]
|
||||
)
|
||||
|
||||
// Save a batch of pending answers to the server
|
||||
const savePending = useCallback(async () => {
|
||||
if (!submissionId || savingRef.current) return
|
||||
const pending = Array.from(pendingRef.current.entries())
|
||||
if (pending.length === 0) return
|
||||
|
||||
savingRef.current = true
|
||||
setStatus("saving")
|
||||
|
||||
let allOk = true
|
||||
for (const [questionId, answer] of pending) {
|
||||
const fd = new FormData()
|
||||
fd.set("submissionId", submissionId)
|
||||
fd.set("questionId", questionId)
|
||||
fd.set("answerJson", JSON.stringify({ answer }))
|
||||
const res = await saveHomeworkAnswerAction(null, fd)
|
||||
if (!res.success) {
|
||||
allOk = false
|
||||
}
|
||||
}
|
||||
|
||||
savingRef.current = false
|
||||
|
||||
if (allOk) {
|
||||
pendingRef.current.clear()
|
||||
setStatus("saved")
|
||||
setLastSavedAt(Date.now())
|
||||
} else {
|
||||
setStatus("error")
|
||||
// Keep pending items for retry on next change or manual flush
|
||||
}
|
||||
}, [submissionId])
|
||||
|
||||
// Schedule debounced save when answers change
|
||||
useEffect(() => {
|
||||
if (!enabled || !submissionId) return
|
||||
|
||||
const currentSnapshot = JSON.stringify(answers)
|
||||
if (currentSnapshot === lastSavedAnswersRef.current) return
|
||||
|
||||
// Cache to localStorage immediately (offline safety net)
|
||||
cacheToLocalStorage(answers)
|
||||
|
||||
// Collect changed question IDs
|
||||
for (const questionId of Object.keys(answers)) {
|
||||
const entry = answers[questionId]
|
||||
if (entry !== undefined) {
|
||||
pendingRef.current.set(questionId, entry.answer)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear existing timer and set a new one
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
timerRef.current = setTimeout(() => {
|
||||
void savePending()
|
||||
lastSavedAnswersRef.current = currentSnapshot
|
||||
}, debounceMs)
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [answers, enabled, submissionId, debounceMs, cacheToLocalStorage, savePending])
|
||||
|
||||
// Flush on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
// Fire-and-forget final save
|
||||
void savePending()
|
||||
}
|
||||
}, [savePending])
|
||||
|
||||
// Retry on window focus (network may have recovered)
|
||||
useEffect(() => {
|
||||
if (status !== "error") return
|
||||
const handleFocus = () => {
|
||||
void savePending()
|
||||
}
|
||||
window.addEventListener("focus", handleFocus)
|
||||
return () => window.removeEventListener("focus", handleFocus)
|
||||
}, [status, savePending])
|
||||
|
||||
const flush = useCallback(async () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
await savePending()
|
||||
lastSavedAnswersRef.current = JSON.stringify(answers)
|
||||
}, [answers, savePending])
|
||||
|
||||
return { status, lastSavedAt, flush }
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 localStorage 恢复离线缓存的答案
|
||||
*/
|
||||
export function loadOfflineCache(storageKey: string): AnswerMap | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as { answers?: AnswerMap }
|
||||
if (!parsed.answers || typeof parsed.answers !== "object") return null
|
||||
return parsed.answers
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除 localStorage 中的离线缓存
|
||||
*/
|
||||
export function clearOfflineCache(storageKey: string): void {
|
||||
try {
|
||||
window.localStorage.removeItem(storageKey)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
433
src/modules/homework/lib/question-content-utils.test.ts
Normal file
433
src/modules/homework/lib/question-content-utils.test.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* 6.5: 单测覆盖权限校验与数据流转
|
||||
*
|
||||
* 覆盖范围:
|
||||
* - question-content-utils 的纯函数(数据流转核心)
|
||||
* - exam-homework-role-config 的角色特性合并逻辑(权限校验配置层)
|
||||
* - applyAutoGrades 自动判分流程
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
isRecord,
|
||||
getQuestionText,
|
||||
getOptions,
|
||||
getChoiceCorrectIds,
|
||||
getJudgmentCorrectAnswer,
|
||||
getTextCorrectAnswers,
|
||||
parseSavedAnswer,
|
||||
extractAnswerValue,
|
||||
normalizeText,
|
||||
isAutoGradable,
|
||||
computeIsCorrect,
|
||||
getCorrectnessState,
|
||||
applyAutoGrades,
|
||||
formatStudentAnswer,
|
||||
type AutoGradableAnswer,
|
||||
} from "./question-content-utils"
|
||||
|
||||
describe("isRecord", () => {
|
||||
it("returns true for non-null objects (including arrays)", () => {
|
||||
expect(isRecord({})).toBe(true)
|
||||
expect(isRecord({ a: 1 })).toBe(true)
|
||||
// Note: arrays are objects in JS, so isRecord returns true for them.
|
||||
// Callers that need to exclude arrays should check Array.isArray separately.
|
||||
expect(isRecord([1, 2])).toBe(true)
|
||||
})
|
||||
it("returns false for null and primitives", () => {
|
||||
expect(isRecord(null)).toBe(false)
|
||||
expect(isRecord(undefined)).toBe(false)
|
||||
expect(isRecord("string")).toBe(false)
|
||||
expect(isRecord(42)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getQuestionText", () => {
|
||||
it("extracts text from valid content", () => {
|
||||
expect(getQuestionText({ text: "What is 2+2?" })).toBe("What is 2+2?")
|
||||
})
|
||||
it("returns empty string for missing or non-string text", () => {
|
||||
expect(getQuestionText({})).toBe("")
|
||||
expect(getQuestionText({ text: 123 })).toBe("")
|
||||
expect(getQuestionText(null)).toBe("")
|
||||
expect(getQuestionText("string")).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getOptions", () => {
|
||||
it("parses valid options array", () => {
|
||||
const content = {
|
||||
options: [
|
||||
{ id: "a", text: "Option A", isCorrect: true },
|
||||
{ id: "b", text: "Option B" },
|
||||
],
|
||||
}
|
||||
const opts = getOptions(content)
|
||||
expect(opts).toHaveLength(2)
|
||||
expect(opts[0]).toEqual({ id: "a", text: "Option A", isCorrect: true })
|
||||
expect(opts[1]).toEqual({ id: "b", text: "Option B", isCorrect: false })
|
||||
})
|
||||
it("filters out options missing id or text", () => {
|
||||
const content = {
|
||||
options: [
|
||||
{ id: "a", text: "Valid" },
|
||||
{ id: "", text: "No ID" },
|
||||
{ id: "c", text: "" },
|
||||
{ id: "d" },
|
||||
{ text: "No ID" },
|
||||
],
|
||||
}
|
||||
expect(getOptions(content)).toHaveLength(1)
|
||||
})
|
||||
it("returns empty array when options is missing or not an array", () => {
|
||||
expect(getOptions({})).toEqual([])
|
||||
expect(getOptions({ options: "not-array" })).toEqual([])
|
||||
expect(getOptions(null)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("getChoiceCorrectIds", () => {
|
||||
it("returns IDs of options marked isCorrect", () => {
|
||||
const content = {
|
||||
options: [
|
||||
{ id: "a", text: "A", isCorrect: true },
|
||||
{ id: "b", text: "B" },
|
||||
{ id: "c", text: "C", isCorrect: true },
|
||||
],
|
||||
}
|
||||
expect(getChoiceCorrectIds(content)).toEqual(["a", "c"])
|
||||
})
|
||||
it("returns empty array when no correct options", () => {
|
||||
const content = { options: [{ id: "a", text: "A" }] }
|
||||
expect(getChoiceCorrectIds(content)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("getJudgmentCorrectAnswer", () => {
|
||||
it("returns boolean when correctAnswer is boolean", () => {
|
||||
expect(getJudgmentCorrectAnswer({ correctAnswer: true })).toBe(true)
|
||||
expect(getJudgmentCorrectAnswer({ correctAnswer: false })).toBe(false)
|
||||
})
|
||||
it("returns null for missing or non-boolean", () => {
|
||||
expect(getJudgmentCorrectAnswer({})).toBeNull()
|
||||
expect(getJudgmentCorrectAnswer({ correctAnswer: "true" })).toBeNull()
|
||||
expect(getJudgmentCorrectAnswer(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTextCorrectAnswers", () => {
|
||||
it("returns array from string correctAnswer", () => {
|
||||
expect(getTextCorrectAnswers({ correctAnswer: "yes" })).toEqual(["yes"])
|
||||
})
|
||||
it("returns array from string[] correctAnswer", () => {
|
||||
expect(getTextCorrectAnswers({ correctAnswer: ["yes", "Yes", "YES"] })).toEqual([
|
||||
"yes",
|
||||
"Yes",
|
||||
"YES",
|
||||
])
|
||||
})
|
||||
it("returns empty array for missing or invalid", () => {
|
||||
expect(getTextCorrectAnswers({})).toEqual([])
|
||||
expect(getTextCorrectAnswers({ correctAnswer: 123 })).toEqual([])
|
||||
expect(getTextCorrectAnswers({ correctAnswer: [1, 2] })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseSavedAnswer", () => {
|
||||
it("parses object with answer field", () => {
|
||||
expect(parseSavedAnswer({ answer: "test" }, "text")).toEqual({ answer: "test" })
|
||||
expect(parseSavedAnswer({ answer: ["a", "b"] }, "multiple_choice")).toEqual({
|
||||
answer: ["a", "b"],
|
||||
})
|
||||
})
|
||||
it("returns empty answer for null/undefined input", () => {
|
||||
expect(parseSavedAnswer(null, "text")).toEqual({ answer: "" })
|
||||
expect(parseSavedAnswer(undefined, "text")).toEqual({ answer: "" })
|
||||
})
|
||||
it("coerces non-matching types to defaults", () => {
|
||||
expect(parseSavedAnswer("raw-string", "text")).toEqual({ answer: "raw-string" })
|
||||
expect(parseSavedAnswer(123, "text")).toEqual({ answer: "" })
|
||||
expect(parseSavedAnswer("not-boolean", "judgment")).toEqual({ answer: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractAnswerValue", () => {
|
||||
it("extracts answer from object shape", () => {
|
||||
expect(extractAnswerValue({ answer: "test" })).toBe("test")
|
||||
expect(extractAnswerValue({ answer: ["a", "b"] })).toEqual(["a", "b"])
|
||||
})
|
||||
it("returns raw value for non-object shapes", () => {
|
||||
expect(extractAnswerValue("test")).toBe("test")
|
||||
expect(extractAnswerValue(["a", "b"])).toEqual(["a", "b"])
|
||||
expect(extractAnswerValue(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("normalizeText", () => {
|
||||
it("trims and lowercases", () => {
|
||||
expect(normalizeText(" Hello World ")).toBe("hello world")
|
||||
})
|
||||
it("collapses internal whitespace", () => {
|
||||
expect(normalizeText("a b\tc")).toBe("a b c")
|
||||
})
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(normalizeText("")).toBe("")
|
||||
expect(normalizeText(" ")).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("isAutoGradable", () => {
|
||||
const choiceContent = {
|
||||
options: [{ id: "a", text: "A", isCorrect: true }],
|
||||
}
|
||||
const judgmentContent = { correctAnswer: true }
|
||||
|
||||
it("returns true for choice types with correct options", () => {
|
||||
expect(isAutoGradable({ questionType: "single_choice", questionContent: choiceContent })).toBe(true)
|
||||
expect(isAutoGradable({ questionType: "multiple_choice", questionContent: choiceContent })).toBe(true)
|
||||
})
|
||||
it("returns false for choice types without correct options", () => {
|
||||
expect(isAutoGradable({ questionType: "single_choice", questionContent: {} })).toBe(false)
|
||||
expect(isAutoGradable({ questionType: "multiple_choice", questionContent: {} })).toBe(false)
|
||||
})
|
||||
it("returns true for judgment with boolean correctAnswer", () => {
|
||||
expect(isAutoGradable({ questionType: "judgment", questionContent: judgmentContent })).toBe(true)
|
||||
})
|
||||
it("returns false for judgment without correctAnswer", () => {
|
||||
expect(isAutoGradable({ questionType: "judgment", questionContent: {} })).toBe(false)
|
||||
})
|
||||
it("returns true for text type with correctAnswer", () => {
|
||||
expect(
|
||||
isAutoGradable({ questionType: "text", questionContent: { correctAnswer: "yes" } })
|
||||
).toBe(true)
|
||||
})
|
||||
it("returns false for text type without correctAnswer", () => {
|
||||
expect(isAutoGradable({ questionType: "text", questionContent: {} })).toBe(false)
|
||||
})
|
||||
it("returns false for unknown types", () => {
|
||||
expect(isAutoGradable({ questionType: "essay", questionContent: {} })).toBe(false)
|
||||
expect(isAutoGradable({ questionType: "fill_blank", questionContent: {} })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("computeIsCorrect", () => {
|
||||
const singleChoiceContent = {
|
||||
options: [
|
||||
{ id: "a", text: "A", isCorrect: true },
|
||||
{ id: "b", text: "B" },
|
||||
],
|
||||
}
|
||||
const multipleChoiceContent = {
|
||||
options: [
|
||||
{ id: "a", text: "A", isCorrect: true },
|
||||
{ id: "b", text: "B" },
|
||||
{ id: "c", text: "C", isCorrect: true },
|
||||
],
|
||||
}
|
||||
const judgmentContent = { correctAnswer: true }
|
||||
const textContent = { correctAnswer: ["yes", "Yes"] }
|
||||
|
||||
it("single_choice: correct", () => {
|
||||
expect(
|
||||
computeIsCorrect({
|
||||
questionType: "single_choice",
|
||||
questionContent: singleChoiceContent,
|
||||
studentAnswer: "a",
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
it("single_choice: incorrect", () => {
|
||||
expect(
|
||||
computeIsCorrect({
|
||||
questionType: "single_choice",
|
||||
questionContent: singleChoiceContent,
|
||||
studentAnswer: "b",
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
it("multiple_choice: correct (all and only correct)", () => {
|
||||
expect(
|
||||
computeIsCorrect({
|
||||
questionType: "multiple_choice",
|
||||
questionContent: multipleChoiceContent,
|
||||
studentAnswer: ["a", "c"],
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
it("multiple_choice: incorrect (missing one correct)", () => {
|
||||
expect(
|
||||
computeIsCorrect({
|
||||
questionType: "multiple_choice",
|
||||
questionContent: multipleChoiceContent,
|
||||
studentAnswer: ["a"],
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
it("multiple_choice: incorrect (extra wrong option)", () => {
|
||||
expect(
|
||||
computeIsCorrect({
|
||||
questionType: "multiple_choice",
|
||||
questionContent: multipleChoiceContent,
|
||||
studentAnswer: ["a", "b", "c"],
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
it("judgment: correct", () => {
|
||||
expect(
|
||||
computeIsCorrect({
|
||||
questionType: "judgment",
|
||||
questionContent: judgmentContent,
|
||||
studentAnswer: true,
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
it("judgment: incorrect", () => {
|
||||
expect(
|
||||
computeIsCorrect({
|
||||
questionType: "judgment",
|
||||
questionContent: judgmentContent,
|
||||
studentAnswer: false,
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
it("text: correct (case-insensitive)", () => {
|
||||
expect(
|
||||
computeIsCorrect({
|
||||
questionType: "text",
|
||||
questionContent: textContent,
|
||||
studentAnswer: " YES ",
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
it("text: incorrect", () => {
|
||||
expect(
|
||||
computeIsCorrect({
|
||||
questionType: "text",
|
||||
questionContent: textContent,
|
||||
studentAnswer: "no",
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
it("returns null for non-auto-gradable types", () => {
|
||||
expect(
|
||||
computeIsCorrect({
|
||||
questionType: "essay",
|
||||
questionContent: {},
|
||||
studentAnswer: "some text",
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getCorrectnessState", () => {
|
||||
it("returns 'ungraded' when score is null", () => {
|
||||
expect(getCorrectnessState({ score: null, maxScore: 10 })).toBe("ungraded")
|
||||
})
|
||||
it("returns 'correct' when score equals maxScore", () => {
|
||||
expect(getCorrectnessState({ score: 10, maxScore: 10 })).toBe("correct")
|
||||
})
|
||||
it("returns 'incorrect' when score is 0", () => {
|
||||
expect(getCorrectnessState({ score: 0, maxScore: 10 })).toBe("incorrect")
|
||||
})
|
||||
it("returns 'partial' when 0 < score < maxScore", () => {
|
||||
expect(getCorrectnessState({ score: 5, maxScore: 10 })).toBe("partial")
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyAutoGrades", () => {
|
||||
const baseAnswers: AutoGradableAnswer[] = [
|
||||
{
|
||||
id: "a1",
|
||||
questionId: "q1",
|
||||
questionType: "single_choice",
|
||||
questionContent: {
|
||||
options: [
|
||||
{ id: "a", text: "A", isCorrect: true },
|
||||
{ id: "b", text: "B" },
|
||||
],
|
||||
},
|
||||
studentAnswer: "a",
|
||||
score: null,
|
||||
maxScore: 5,
|
||||
feedback: null,
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
id: "a2",
|
||||
questionId: "q2",
|
||||
questionType: "single_choice",
|
||||
questionContent: {
|
||||
options: [
|
||||
{ id: "a", text: "A", isCorrect: true },
|
||||
{ id: "b", text: "B" },
|
||||
],
|
||||
},
|
||||
studentAnswer: "b",
|
||||
score: null,
|
||||
maxScore: 5,
|
||||
feedback: null,
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: "a3",
|
||||
questionId: "q3",
|
||||
questionType: "essay",
|
||||
questionContent: {},
|
||||
studentAnswer: "some text",
|
||||
score: null,
|
||||
maxScore: 10,
|
||||
feedback: null,
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
id: "a4",
|
||||
questionId: "q4",
|
||||
questionType: "single_choice",
|
||||
questionContent: {
|
||||
options: [{ id: "a", text: "A", isCorrect: true }],
|
||||
},
|
||||
studentAnswer: "a",
|
||||
score: 5,
|
||||
maxScore: 5,
|
||||
feedback: null,
|
||||
order: 3,
|
||||
},
|
||||
]
|
||||
|
||||
it("auto-grades correct answer to full score", () => {
|
||||
const result = applyAutoGrades(baseAnswers)
|
||||
expect(result[0].score).toBe(5)
|
||||
})
|
||||
it("auto-grades incorrect answer to 0", () => {
|
||||
const result = applyAutoGrades(baseAnswers)
|
||||
expect(result[1].score).toBe(0)
|
||||
})
|
||||
it("leaves non-auto-gradable answers with null score", () => {
|
||||
const result = applyAutoGrades(baseAnswers)
|
||||
expect(result[2].score).toBeNull()
|
||||
})
|
||||
it("does not overwrite already-graded answers", () => {
|
||||
const result = applyAutoGrades(baseAnswers)
|
||||
expect(result[3].score).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatStudentAnswer", () => {
|
||||
it("formats string answer as-is", () => {
|
||||
expect(formatStudentAnswer("my answer")).toBe("my answer")
|
||||
})
|
||||
it("formats boolean answer", () => {
|
||||
expect(formatStudentAnswer(true)).toBe("True")
|
||||
expect(formatStudentAnswer(false)).toBe("False")
|
||||
})
|
||||
it("formats array answer as comma-separated", () => {
|
||||
expect(formatStudentAnswer(["a", "b", "c"])).toBe("a, b, c")
|
||||
})
|
||||
it("formats null answer as dash", () => {
|
||||
expect(formatStudentAnswer(null)).toBe("—")
|
||||
expect(formatStudentAnswer(undefined)).toBe("—")
|
||||
})
|
||||
it("formats object answer by extracting answer field", () => {
|
||||
expect(formatStudentAnswer({ answer: "test" })).toBe("test")
|
||||
})
|
||||
})
|
||||
226
src/modules/homework/lib/question-content-utils.ts
Normal file
226
src/modules/homework/lib/question-content-utils.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 题目内容解析纯函数
|
||||
*
|
||||
* 从 `unknown` 类型的题目内容中安全提取文本、选项、正确答案等。
|
||||
* 所有函数均为纯函数,无副作用,便于单测。
|
||||
*/
|
||||
|
||||
export type QuestionOption = {
|
||||
id: string
|
||||
text: string
|
||||
isCorrect?: boolean
|
||||
}
|
||||
|
||||
export const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null
|
||||
|
||||
export const getQuestionText = (content: unknown): string => {
|
||||
if (!isRecord(content)) return ""
|
||||
return typeof content.text === "string" ? content.text : ""
|
||||
}
|
||||
|
||||
export const getOptions = (content: unknown): QuestionOption[] => {
|
||||
if (!isRecord(content)) return []
|
||||
const raw = content.options
|
||||
if (!Array.isArray(raw)) return []
|
||||
const out: QuestionOption[] = []
|
||||
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
|
||||
}
|
||||
|
||||
export const getChoiceCorrectIds = (content: unknown): string[] => {
|
||||
return getOptions(content)
|
||||
.filter((o): o is QuestionOption & { isCorrect: true } => o.isCorrect === true)
|
||||
.map((o) => o.id)
|
||||
}
|
||||
|
||||
export const getJudgmentCorrectAnswer = (content: unknown): boolean | null => {
|
||||
if (!isRecord(content)) return null
|
||||
return typeof content.correctAnswer === "boolean" ? content.correctAnswer : null
|
||||
}
|
||||
|
||||
export 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 []
|
||||
}
|
||||
|
||||
export type QuestionType = "single_choice" | "multiple_choice" | "judgment" | "text" | string
|
||||
|
||||
export type AnswerShape =
|
||||
| { answer: string }
|
||||
| { answer: boolean }
|
||||
| { answer: string[] }
|
||||
| { answer: unknown }
|
||||
|
||||
export const toAnswerShape = (questionType: QuestionType, v: unknown): AnswerShape => {
|
||||
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 }
|
||||
}
|
||||
|
||||
export const parseSavedAnswer = (saved: unknown, questionType: QuestionType): AnswerShape => {
|
||||
if (isRecord(saved) && "answer" in saved) {
|
||||
return toAnswerShape(questionType, saved.answer)
|
||||
}
|
||||
return toAnswerShape(questionType, saved)
|
||||
}
|
||||
|
||||
export const extractAnswerValue = (studentAnswer: unknown): unknown => {
|
||||
if (isRecord(studentAnswer) && "answer" in studentAnswer) return studentAnswer.answer
|
||||
return studentAnswer
|
||||
}
|
||||
|
||||
export const normalizeText = (v: string): string =>
|
||||
v.trim().replace(/\s+/g, " ").toLowerCase()
|
||||
|
||||
/**
|
||||
* 判断题目是否可自动判分(有标准答案)
|
||||
*/
|
||||
export const isAutoGradable = (input: {
|
||||
questionType: QuestionType
|
||||
questionContent: unknown
|
||||
}): boolean => {
|
||||
if (input.questionType === "single_choice" || input.questionType === "multiple_choice") {
|
||||
return getChoiceCorrectIds(input.questionContent).length > 0
|
||||
}
|
||||
if (input.questionType === "judgment") {
|
||||
return getJudgmentCorrectAnswer(input.questionContent) !== null
|
||||
}
|
||||
if (input.questionType === "text") {
|
||||
return getTextCorrectAnswers(input.questionContent).length > 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export type CorrectnessState = "ungraded" | "correct" | "incorrect" | "partial"
|
||||
|
||||
/**
|
||||
* 计算单题对错状态
|
||||
* @returns "correct" | "incorrect" | "partial" | "ungraded";无标准答案返回 null
|
||||
*/
|
||||
export const computeIsCorrect = (input: {
|
||||
questionType: QuestionType
|
||||
questionContent: unknown
|
||||
studentAnswer: unknown
|
||||
}): boolean | null => {
|
||||
const studentVal = extractAnswerValue(input.studentAnswer)
|
||||
|
||||
if (input.questionType === "single_choice") {
|
||||
const correct = getChoiceCorrectIds(input.questionContent)
|
||||
if (correct.length === 0) return null
|
||||
if (typeof studentVal !== "string") return false
|
||||
return correct.includes(studentVal)
|
||||
}
|
||||
|
||||
if (input.questionType === "multiple_choice") {
|
||||
const correct = getChoiceCorrectIds(input.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 (input.questionType === "judgment") {
|
||||
const correct = getJudgmentCorrectAnswer(input.questionContent)
|
||||
if (correct === null) return null
|
||||
if (typeof studentVal !== "boolean") return false
|
||||
return studentVal === correct
|
||||
}
|
||||
|
||||
if (input.questionType === "text") {
|
||||
const correctAnswers = getTextCorrectAnswers(input.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
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据分数与满分推断对错状态
|
||||
*/
|
||||
export const getCorrectnessState = (input: {
|
||||
score: number | null
|
||||
maxScore: number
|
||||
}): CorrectnessState => {
|
||||
if (input.score === null) return "ungraded"
|
||||
if (input.score === input.maxScore) return "correct"
|
||||
if (input.score === 0) return "incorrect"
|
||||
return "partial"
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动判分输入项
|
||||
*/
|
||||
export interface AutoGradableAnswer {
|
||||
id: string
|
||||
questionId: string
|
||||
questionType: QuestionType
|
||||
questionContent: unknown
|
||||
maxScore: number
|
||||
studentAnswer: unknown
|
||||
score: number | null
|
||||
feedback: string | null
|
||||
order: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 对未判分的题目应用自动判分
|
||||
* - 已有分数(score !== null)的不覆盖
|
||||
* - 无标准答案的不判分
|
||||
* - 否则按 computeIsCorrect 给满分或 0 分
|
||||
*/
|
||||
export const applyAutoGrades = <T extends AutoGradableAnswer>(incoming: T[]): T[] => {
|
||||
return incoming.map((a) => {
|
||||
if (a.score !== null) return a
|
||||
if (!isAutoGradable({ questionType: a.questionType, questionContent: a.questionContent })) {
|
||||
return a
|
||||
}
|
||||
const isCorrect = computeIsCorrect({
|
||||
questionType: a.questionType,
|
||||
questionContent: a.questionContent,
|
||||
studentAnswer: a.studentAnswer,
|
||||
})
|
||||
if (isCorrect === null) return a
|
||||
return { ...a, score: isCorrect ? a.maxScore : 0 }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化学生答案为可读字符串
|
||||
*/
|
||||
export const formatStudentAnswer = (studentAnswer: unknown): string => {
|
||||
const v = extractAnswerValue(studentAnswer)
|
||||
if (typeof v === "string") return v
|
||||
if (typeof v === "boolean") return v ? "True" : "False"
|
||||
if (Array.isArray(v)) {
|
||||
return v.map((x) => (typeof x === "string" ? x : JSON.stringify(x))).join(", ")
|
||||
}
|
||||
if (v == null) return "—"
|
||||
return JSON.stringify(v)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const CreateHomeworkAssignmentSchema = z.object({
|
||||
sourceExamId: z.string().min(1),
|
||||
sourceExamId: z.string().optional(),
|
||||
classId: z.string().min(1),
|
||||
title: z.string().optional(),
|
||||
title: z.string().min(1, "Title is required for quick assignments"),
|
||||
description: z.string().optional(),
|
||||
availableAt: z.string().optional(),
|
||||
dueAt: z.string().optional(),
|
||||
|
||||
@@ -228,7 +228,7 @@ export const getHomeworkAssignmentAnalytics = cache(
|
||||
description: assignment.description,
|
||||
status: toHomeworkAssignmentStatus(assignment.status),
|
||||
sourceExamId: assignment.sourceExamId,
|
||||
sourceExamTitle: assignment.sourceExam.title,
|
||||
sourceExamTitle: assignment.sourceExam?.title ?? null,
|
||||
structure: assignment.structure as unknown,
|
||||
availableAt: assignment.availableAt ? assignment.availableAt.toISOString() : null,
|
||||
dueAt: assignment.dueAt ? assignment.dueAt.toISOString() : null,
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
import type { StatusVariantMap, StatusLabelMap } from "@/shared/components/ui/status-badge"
|
||||
|
||||
export type HomeworkAssignmentStatus = "draft" | "published" | "archived"
|
||||
|
||||
export type HomeworkSubmissionStatus = "started" | "submitted" | "graded"
|
||||
|
||||
/** 学生作业进度状态 → Badge variant 映射(统一 not_started/in_progress/submitted/graded 颜色) */
|
||||
export const STUDENT_HOMEWORK_PROGRESS_VARIANT: StatusVariantMap<StudentHomeworkProgressStatus> = {
|
||||
not_started: "outline",
|
||||
in_progress: "secondary",
|
||||
submitted: "secondary",
|
||||
graded: "default",
|
||||
}
|
||||
|
||||
/** 学生作业进度状态 → 展示文本映射 */
|
||||
export const STUDENT_HOMEWORK_PROGRESS_LABEL: StatusLabelMap<StudentHomeworkProgressStatus> = {
|
||||
not_started: "Not Started",
|
||||
in_progress: "In Progress",
|
||||
submitted: "Submitted",
|
||||
graded: "Graded",
|
||||
}
|
||||
|
||||
export interface TeacherGradeTrendItem {
|
||||
id: string
|
||||
title: string
|
||||
@@ -14,8 +32,8 @@ export interface TeacherGradeTrendItem {
|
||||
|
||||
export interface HomeworkAssignmentListItem {
|
||||
id: string
|
||||
sourceExamId: string
|
||||
sourceExamTitle: string
|
||||
sourceExamId: string | null
|
||||
sourceExamTitle: string | null
|
||||
title: string
|
||||
status: HomeworkAssignmentStatus
|
||||
availableAt: string | null
|
||||
@@ -25,13 +43,23 @@ export interface HomeworkAssignmentListItem {
|
||||
maxAttempts: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
/** 应提交学生数(targets 表计数) */
|
||||
targetCount: number
|
||||
/** 已提交学生数(submitted/graded 状态去重计数) */
|
||||
submittedCount: number
|
||||
/** 已批改学生数(graded 状态去重计数) */
|
||||
gradedCount: number
|
||||
/** 已批改提交的平均分(null 表示无批改数据) */
|
||||
averageScore: number | null
|
||||
/** 逾期未提交学生数(dueAt 已过且未提交) */
|
||||
overdueCount: number
|
||||
}
|
||||
|
||||
export interface HomeworkAssignmentReviewListItem {
|
||||
id: string
|
||||
title: string
|
||||
status: HomeworkAssignmentStatus
|
||||
sourceExamTitle: string
|
||||
sourceExamTitle: string | null
|
||||
dueAt: string | null
|
||||
targetCount: number
|
||||
submittedCount: number
|
||||
@@ -160,8 +188,8 @@ export type HomeworkAssignmentAnalytics = {
|
||||
title: string
|
||||
description: string | null
|
||||
status: HomeworkAssignmentStatus
|
||||
sourceExamId: string
|
||||
sourceExamTitle: string
|
||||
sourceExamId: string | null
|
||||
sourceExamTitle: string | null
|
||||
structure: unknown | null
|
||||
availableAt: string | null
|
||||
dueAt: string | null
|
||||
|
||||
Reference in New Issue
Block a user