From f26072044397ab65dec21e0a8dc4e2c227748324 Mon Sep 17 00:00:00 2001
From: SpecialX <47072643+wangxiner55@users.noreply.github.com>
Date: Wed, 24 Jun 2026 13:54:24 +0800
Subject: [PATCH] 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
---
src/modules/exams/actions.ts | 17 ++-
.../exams/components/exam-rich-form.tsx | 120 +++++++++++++-----
.../exams/editor/editor-to-structure.ts | 30 ++++-
3 files changed, 130 insertions(+), 37 deletions(-)
diff --git a/src/modules/exams/actions.ts b/src/modules/exams/actions.ts
index b422e22..69e3391 100644
--- a/src/modules/exams/actions.ts
+++ b/src/modules/exams/actions.ts
@@ -1137,17 +1137,28 @@ const buildTiptapDocFromAiResponse = (data: unknown): unknown => {
})
}
- // 子题(阅读理解的小题)
+ // 子题(阅读理解的小题)—— 生成为嵌套的 questionBlock,便于编辑器层级展示与解析
const subQuestions = Array.isArray(contentNode.subQuestions)
? contentNode.subQuestions
: []
for (const sub of subQuestions) {
if (!isRecord(sub)) continue
const subText = typeof sub.text === "string" ? sub.text : ""
+ const subScore = typeof sub.score === "number" ? sub.score : 0
if (subText) {
+ // 子题文本按行拆分为段落
+ const subLines = subText.split("\n").filter((l) => l.trim().length > 0)
+ const subInner =
+ subLines.length > 0
+ ? subLines.map((line) => ({
+ type: "paragraph",
+ content: [{ type: "text", text: line }],
+ }))
+ : [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
inner.push({
- type: "paragraph",
- content: [{ type: "text", text: subText }],
+ type: "questionBlock",
+ attrs: { questionId: "", type: "text", score: subScore },
+ content: subInner,
})
}
}
diff --git a/src/modules/exams/components/exam-rich-form.tsx b/src/modules/exams/components/exam-rich-form.tsx
index 7972e29..fccbd29 100644
--- a/src/modules/exams/components/exam-rich-form.tsx
+++ b/src/modules/exams/components/exam-rich-form.tsx
@@ -329,16 +329,16 @@ function ExamPreview({ structure }: { structure: EditorDoc }) {
): React.ReactNode => {
if (node.type === "group") {
return (
-
+
{node.title || "大题"}
-
+
{(node.children ?? []).map((child) => renderNode(child, depth + 1))}
@@ -347,39 +347,99 @@ function ExamPreview({ structure }: { structure: EditorDoc }) {
const question = structure.questions.find((q) => q.id === node.questionId)
if (!question) return null
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 (
-
-
- {counter.value}.
-
-
-
- {question.content.text || "未命名题目"}
-
- ({question.score}分)
-
-
- {question.content.options && question.content.options.length > 0 && (
-
- {question.content.options.map((opt) => (
-
- {opt.id}.
- {opt.text}
-
- ))}
-
- )}
+
+ {/* 题目头部:题号 + 题型标签 + 分值 */}
+
+ {qNum}.
+
+ {typeLabel}
+
+
+ {question.score} 分
+
+ {/* 题干文本 */}
+ {question.content.text && (
+
+ {question.content.text}
+
+ )}
+ {/* 选项 */}
+ {question.content.options && question.content.options.length > 0 && (
+
+ {question.content.options.map((opt) => (
+
+ {opt.id}.
+ {opt.text}
+
+ ))}
+
+ )}
+ {/* 复合题子题 */}
+ {isComposite && question.content.subQuestions && question.content.subQuestions.length > 0 && (
+
+ {question.content.subQuestions.map((sub, idx) => (
+
+
+
+ ({idx + 1})
+
+ {sub.score !== undefined && (
+
+ {sub.score} 分
+
+ )}
+
+
+ {sub.text}
+
+
+ ))}
+
+ )}
+ {/* 图片 */}
+ {question.content.images && question.content.images.length > 0 && (
+
+ {question.content.images.map((img) => (
+

+ ))}
+
+ )}
)
}
return (
-
- {structure.structure.map((node) => renderNode(node))}
+
+ {structure.title && (
+
+ {structure.title}
+
+ )}
+ {structure.structure.length === 0 ? (
+
+ 暂无题目,请在左侧编辑器中添加
+
+ ) : (
+ structure.structure.map((node) => renderNode(node))
+ )}
)
}
diff --git a/src/modules/exams/editor/editor-to-structure.ts b/src/modules/exams/editor/editor-to-structure.ts
index 0c37dc4..dfb5f43 100644
--- a/src/modules/exams/editor/editor-to-structure.ts
+++ b/src/modules/exams/editor/editor-to-structure.ts
@@ -84,22 +84,44 @@ const buildQuestion = (qb: JSONContent): EditorQuestion => {
: "single_choice") as RichQuestionType
const score = typeof attrs.score === "number" ? attrs.score : 0
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({
type: "doc",
- content: inner.filter(
+ content: nonQuestionBlocks.filter(
(n) =>
n.type !== "orderedList" &&
n.type !== "bulletList" &&
n.type !== "image"
),
})
- const options = parseOptions(inner)
- const blanks = collectBlanks(inner)
- const images = collectImages(inner)
+ const options = parseOptions(nonQuestionBlocks)
+ const blanks = collectBlanks(nonQuestionBlocks)
+ const images = collectImages(nonQuestionBlocks)
const content: RichQuestionContent = { text: text.trim() }
if (options.length > 0) content.options = options
if (blanks.length > 0) content.blanks = blanks
if (images.length > 0) content.images = images
+ if (subQuestions.length > 0) content.subQuestions = subQuestions
return { id, type, score, content }
}