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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = {

View 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
}
}

View 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")
})
})

View 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)
}

View File

@@ -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(),

View File

@@ -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,

View File

@@ -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