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:
@@ -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) {
|
||||
inner.push({
|
||||
// 子题文本按行拆分为段落
|
||||
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: subText }],
|
||||
content: [{ type: "text", text: line }],
|
||||
}))
|
||||
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
|
||||
inner.push({
|
||||
type: "questionBlock",
|
||||
attrs: { questionId: "", type: "text", score: subScore },
|
||||
content: subInner,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,16 +329,16 @@ function ExamPreview({ structure }: { structure: EditorDoc }) {
|
||||
): React.ReactNode => {
|
||||
if (node.type === "group") {
|
||||
return (
|
||||
<div key={node.id} className="mb-4">
|
||||
<div key={node.id} className="mb-6">
|
||||
<h3
|
||||
className={cn(
|
||||
"font-semibold text-foreground/90",
|
||||
depth === 0 ? "text-base" : "text-sm"
|
||||
"font-bold border-b pb-1 mb-3",
|
||||
depth === 0 ? "text-base text-foreground" : "text-sm text-foreground/80"
|
||||
)}
|
||||
>
|
||||
{node.title || "大题"}
|
||||
</h3>
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="space-y-4 pl-2">
|
||||
{(node.children ?? []).map((child) => renderNode(child, depth + 1))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div key={node.id} className="flex gap-2">
|
||||
<span className="min-w-[28px] font-semibold text-foreground">
|
||||
{counter.value}.
|
||||
<div key={node.id} className="rounded border border-border/60 bg-background p-3">
|
||||
{/* 题目头部:题号 + 题型标签 + 分值 */}
|
||||
<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>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="text-foreground/90 leading-relaxed whitespace-pre-wrap">
|
||||
{question.content.text || "未命名题目"}
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({question.score}分)
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{question.score} 分
|
||||
</span>
|
||||
</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 && (
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1.5 pl-8 mb-2">
|
||||
{question.content.options.map((opt) => (
|
||||
<div
|
||||
key={opt.id}
|
||||
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>
|
||||
</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 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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{structure.structure.map((node) => renderNode(node))}
|
||||
<div className="space-y-4">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user