fix(exams): fix composite question sub-questions not showing in preview

- Fix buildQuestion in editor-to-structure.ts: recursively detect nested
  questionBlock nodes as subQuestions (composite questions now properly
  extract child questions instead of treating them as plain text)
- Fix buildQuestionBlock in actions.ts: AI auto-mark now generates nested
  questionBlock nodes for sub-questions instead of plain paragraphs, so
  they are properly structured and detectable by the parser
- Rewrite ExamPreview component with proper layout:
  - Question header: number + type label badge + score
  - Indented question text and options
  - Composite sub-questions shown in nested block with left border
  - Image thumbnails
  - Empty state message
  - Title centered at top
  - Group titles with bottom border
This commit is contained in:
SpecialX
2026-06-24 13:54:24 +08:00
parent 7380f1e6c8
commit f260720443
3 changed files with 130 additions and 37 deletions

View File

@@ -1137,17 +1137,28 @@ const buildTiptapDocFromAiResponse = (data: unknown): unknown => {
}) })
} }
// 子题(阅读理解的小题) // 子题(阅读理解的小题)—— 生成为嵌套的 questionBlock,便于编辑器层级展示与解析
const subQuestions = Array.isArray(contentNode.subQuestions) const subQuestions = Array.isArray(contentNode.subQuestions)
? contentNode.subQuestions ? contentNode.subQuestions
: [] : []
for (const sub of subQuestions) { for (const sub of subQuestions) {
if (!isRecord(sub)) continue if (!isRecord(sub)) continue
const subText = typeof sub.text === "string" ? sub.text : "" const subText = typeof sub.text === "string" ? sub.text : ""
const subScore = typeof sub.score === "number" ? sub.score : 0
if (subText) { if (subText) {
inner.push({ // 子题文本按行拆分为段落
const subLines = subText.split("\n").filter((l) => l.trim().length > 0)
const subInner =
subLines.length > 0
? subLines.map((line) => ({
type: "paragraph", type: "paragraph",
content: [{ type: "text", text: subText }], content: [{ type: "text", text: line }],
}))
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
inner.push({
type: "questionBlock",
attrs: { questionId: "", type: "text", score: subScore },
content: subInner,
}) })
} }
} }

View File

@@ -329,16 +329,16 @@ function ExamPreview({ structure }: { structure: EditorDoc }) {
): React.ReactNode => { ): React.ReactNode => {
if (node.type === "group") { if (node.type === "group") {
return ( return (
<div key={node.id} className="mb-4"> <div key={node.id} className="mb-6">
<h3 <h3
className={cn( className={cn(
"font-semibold text-foreground/90", "font-bold border-b pb-1 mb-3",
depth === 0 ? "text-base" : "text-sm" depth === 0 ? "text-base text-foreground" : "text-sm text-foreground/80"
)} )}
> >
{node.title || "大题"} {node.title || "大题"}
</h3> </h3>
<div className="mt-2 space-y-2"> <div className="space-y-4 pl-2">
{(node.children ?? []).map((child) => renderNode(child, depth + 1))} {(node.children ?? []).map((child) => renderNode(child, depth + 1))}
</div> </div>
</div> </div>
@@ -347,39 +347,99 @@ function ExamPreview({ structure }: { structure: EditorDoc }) {
const question = structure.questions.find((q) => q.id === node.questionId) const question = structure.questions.find((q) => q.id === node.questionId)
if (!question) return null if (!question) return null
counter.value += 1 counter.value += 1
const qNum = counter.value
const isComposite = question.type === "composite"
const typeLabel = question.type === "single_choice" ? "单选"
: question.type === "multiple_choice" ? "多选"
: question.type === "judgment" ? "判断"
: question.type === "composite" ? "复合"
: "简答"
return ( return (
<div key={node.id} className="flex gap-2"> <div key={node.id} className="rounded border border-border/60 bg-background p-3">
<span className="min-w-[28px] font-semibold text-foreground"> {/* 题目头部:题号 + 题型标签 + 分值 */}
{counter.value}. <div className="flex items-center gap-2 mb-2">
<span className="font-bold text-foreground min-w-[32px]">{qNum}.</span>
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{typeLabel}
</span> </span>
<div className="flex-1 space-y-1"> <span className="ml-auto text-xs text-muted-foreground">
<div className="text-foreground/90 leading-relaxed whitespace-pre-wrap"> {question.score}
{question.content.text || "未命名题目"}
<span className="ml-2 text-xs text-muted-foreground">
({question.score})
</span> </span>
</div> </div>
{/* 题干文本 */}
{question.content.text && (
<div className="mb-2 text-sm text-foreground/90 leading-relaxed whitespace-pre-wrap pl-8">
{question.content.text}
</div>
)}
{/* 选项 */}
{question.content.options && question.content.options.length > 0 && ( {question.content.options && question.content.options.length > 0 && (
<div className="space-y-1"> <div className="space-y-1.5 pl-8 mb-2">
{question.content.options.map((opt) => ( {question.content.options.map((opt) => (
<div <div
key={opt.id} key={opt.id}
className="text-sm text-foreground/80 flex gap-2" className="text-sm text-foreground/80 flex gap-2"
> >
<span className="min-w-[20px]">{opt.id}.</span> <span className="font-medium min-w-[20px]">{opt.id}.</span>
<span>{opt.text}</span> <span>{opt.text}</span>
</div> </div>
))} ))}
</div> </div>
)} )}
{/* 复合题子题 */}
{isComposite && question.content.subQuestions && question.content.subQuestions.length > 0 && (
<div className="mt-3 ml-4 space-y-3 border-l-2 border-primary/30 pl-4">
{question.content.subQuestions.map((sub, idx) => (
<div key={sub.id} className="text-sm">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-foreground/80">
({idx + 1})
</span>
{sub.score !== undefined && (
<span className="text-xs text-muted-foreground">
{sub.score}
</span>
)}
</div> </div>
<div className="text-foreground/80 whitespace-pre-wrap pl-6">
{sub.text}
</div>
</div>
))}
</div>
)}
{/* 图片 */}
{question.content.images && question.content.images.length > 0 && (
<div className="mt-2 pl-8 flex flex-wrap gap-2">
{question.content.images.map((img) => (
<img
key={img.fileId}
src={img.url}
alt={img.alt || ""}
className="max-h-32 rounded border"
/>
))}
</div>
)}
</div> </div>
) )
} }
return ( return (
<div className="space-y-3"> <div className="space-y-4">
{structure.structure.map((node) => renderNode(node))} {structure.title && (
<h2 className="text-center text-lg font-bold border-b pb-2">
{structure.title}
</h2>
)}
{structure.structure.length === 0 ? (
<p className="text-center text-sm text-muted-foreground py-8">
,
</p>
) : (
structure.structure.map((node) => renderNode(node))
)}
</div> </div>
) )
} }

View File

@@ -84,22 +84,44 @@ const buildQuestion = (qb: JSONContent): EditorQuestion => {
: "single_choice") as RichQuestionType : "single_choice") as RichQuestionType
const score = typeof attrs.score === "number" ? attrs.score : 0 const score = typeof attrs.score === "number" ? attrs.score : 0
const inner = qb.content ?? [] const inner = qb.content ?? []
// 分离出嵌套的 questionBlock(复合题的子题)、选项列表、图片、普通文本
const subQuestions: Array<{ id: string; text: string; answer?: string; score?: number }> = []
const nonQuestionBlocks = inner.filter((n) => {
if (n.type === "questionBlock") {
const sub = buildQuestion(n)
const subText = sub.content.text
const subOptions = sub.content.options
?.map((o) => `${o.id}. ${o.text}`)
.join("\n")
const fullText = [subText, subOptions].filter(Boolean).join("\n")
subQuestions.push({
id: sub.id,
text: fullText || "",
score: sub.score,
})
return false
}
return true
})
const text = extractText({ const text = extractText({
type: "doc", type: "doc",
content: inner.filter( content: nonQuestionBlocks.filter(
(n) => (n) =>
n.type !== "orderedList" && n.type !== "orderedList" &&
n.type !== "bulletList" && n.type !== "bulletList" &&
n.type !== "image" n.type !== "image"
), ),
}) })
const options = parseOptions(inner) const options = parseOptions(nonQuestionBlocks)
const blanks = collectBlanks(inner) const blanks = collectBlanks(nonQuestionBlocks)
const images = collectImages(inner) const images = collectImages(nonQuestionBlocks)
const content: RichQuestionContent = { text: text.trim() } const content: RichQuestionContent = { text: text.trim() }
if (options.length > 0) content.options = options if (options.length > 0) content.options = options
if (blanks.length > 0) content.blanks = blanks if (blanks.length > 0) content.blanks = blanks
if (images.length > 0) content.images = images if (images.length > 0) content.images = images
if (subQuestions.length > 0) content.subQuestions = subQuestions
return { id, type, score, content } return { id, type, score, content }
} }