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)
|
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) {
|
||||||
|
// 子题文本按行拆分为段落
|
||||||
|
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({
|
inner.push({
|
||||||
type: "paragraph",
|
type: "questionBlock",
|
||||||
content: [{ type: "text", text: subText }],
|
attrs: { questionId: "", type: "text", score: subScore },
|
||||||
|
content: subInner,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
<span className="font-bold text-foreground min-w-[32px]">{qNum}.</span>
|
||||||
<div className="flex-1 space-y-1">
|
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||||
<div className="text-foreground/90 leading-relaxed whitespace-pre-wrap">
|
{typeLabel}
|
||||||
{question.content.text || "未命名题目"}
|
</span>
|
||||||
<span className="ml-2 text-xs text-muted-foreground">
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
({question.score}分)
|
{question.score} 分
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
{question.content.options && question.content.options.length > 0 && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{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>{opt.text}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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 && (
|
||||||
|
<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="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>
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user