Files
NextEdu/src/modules/homework/components/question-renderer.tsx
SpecialX dfffb61e94 feat(homework,classes,course-plans): add scans, student data, take confirm, error boundaries, dialogs, hooks, calendar
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
2026-07-03 10:25:35 +08:00

362 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 }