homework: - Add data-access-scans, data-access-student, data-access-utils, data-access-exam-cross - Add excellent-submissions, homework-take-confirm-dialog, homework-take-sidebar components classes: - Add class-delete-dialog, class-error-boundary, class-form-dialog, class-form-utils - Add class-list-table, class-list-toolbar, class-skeleton - Add schedule-create-dialog, schedule-delete-dialog, schedule-edit-dialog, schedule-utils - Add data-access-teacher and hooks directory course-plans: - Add course-plan-calendar, sortable-week-row, template-picker-dialog components - Add lib directory
362 lines
12 KiB
TypeScript
362 lines
12 KiB
TypeScript
"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,
|
||
type QuestionOption,
|
||
type QuestionType,
|
||
} from "../lib/question-content-utils"
|
||
import { isRecord } from "@/shared/lib/type-guards"
|
||
|
||
/**
|
||
* 题目渲染模式
|
||
* - `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 }
|