diff --git a/src/modules/exams/components/exam-rich-form.tsx b/src/modules/exams/components/exam-rich-form.tsx index a4937f0..be058a3 100644 --- a/src/modules/exams/components/exam-rich-form.tsx +++ b/src/modules/exams/components/exam-rich-form.tsx @@ -180,6 +180,24 @@ export function ExamRichForm() { ? editorDocToStructure(editorDoc, values.title) : null + // 调试:查看复合题的子题解析结果 + if (previewStructure && typeof window !== "undefined") { + const composites = previewStructure.questions.filter((q) => q.type === "composite") + if (composites.length > 0) { + // eslint-disable-next-line no-console + console.log("[ExamPreview] composites:", composites.map((q) => ({ + id: q.id, + textLength: q.content.text.length, + textPreview: q.content.text.slice(0, 80), + subQuestionCount: q.content.subQuestions?.length ?? 0, + subQuestions: q.content.subQuestions?.map((s) => ({ + text: s.text.slice(0, 50), + score: s.score, + })), + }))) + } + } + return (
{/* 顶部工具栏:基本信息(单行) + AI 自动标记 + 保存 */} diff --git a/src/modules/exams/editor/editor-to-structure.ts b/src/modules/exams/editor/editor-to-structure.ts index 7ca19d8..64610ab 100644 --- a/src/modules/exams/editor/editor-to-structure.ts +++ b/src/modules/exams/editor/editor-to-structure.ts @@ -11,19 +11,7 @@ import type { const extractText = (node: JSONContent | undefined): string => { if (!node) return "" if (node.type === "text") return node.text ?? "" - if (Array.isArray(node.content)) { - // 段落之间插入换行符,避免不同段落文本被直接连接 - return node.content - .map((child) => { - const text = extractText(child) - // 段落/列表项后加换行,保持文本结构 - if (child.type === "paragraph" || child.type === "listItem") { - return text + "\n" - } - return text - }) - .join("") - } + if (Array.isArray(node.content)) return node.content.map(extractText).join("") return "" } @@ -118,14 +106,15 @@ const buildQuestion = (qb: JSONContent): EditorQuestion => { return true }) - // 提取题干文本(过滤掉列表和图片,它们单独处理) - const textNodes = nonQuestionBlocks.filter( - (n) => - n.type !== "orderedList" && - n.type !== "bulletList" && - n.type !== "image" - ) - const text = extractText({ type: "doc", content: textNodes }) + const text = extractText({ + type: "doc", + content: nonQuestionBlocks.filter( + (n) => + n.type !== "orderedList" && + n.type !== "bulletList" && + n.type !== "image" + ), + }) const options = parseOptions(nonQuestionBlocks) const blanks = collectBlanks(nonQuestionBlocks) const images = collectImages(nonQuestionBlocks) @@ -134,112 +123,9 @@ const buildQuestion = (qb: JSONContent): EditorQuestion => { if (blanks.length > 0) content.blanks = blanks if (images.length > 0) content.images = images if (subQuestions.length > 0) content.subQuestions = subQuestions - - // 复合题:如果没有显式子题,尝试从文本模式识别子题 - // (如 "1.xxx", "2.xxx", "(1)xxx", "①xxx" 等) - if (type === "composite" && subQuestions.length === 0 && content.text) { - const detected = detectSubQuestionsFromText(content.text) - if (detected.length > 0) { - content.subQuestions = detected - // 移除被识别为子题的文本,保留选段/材料部分 - const materialText = extractMaterialText(content.text, detected) - content.text = materialText - } - } - return { id, type, score, content } } -/** - * 从文本中检测子题(如 "1.xxx", "2.xxx", "(1)xxx", "①xxx" 等) - * 返回检测到的子题列表(不含原文中的材料部分) - * - * 检测策略: - * 1. 优先识别带编号的行(1.xxx, (1)xxx, ①xxx 等) - * 2. 如果检测到编号子题,且其前一行带分值(如"xxx(3分)"), - * 则把前一行也作为子题1 - */ -const detectSubQuestionsFromText = ( - text: string -): Array<{ id: string; text: string; score?: number }> => { - const lines = text.split("\n").map((l) => l.trim()).filter(Boolean) - const subQuestionPattern = /^(?:\(?(\d+)\)?|①|②|③|④|⑤|⑥|⑦|⑧|⑨|⑩)[.、))]?\s*(.+)/ - const scorePattern = /[((](\d+)\s*分[))]/ - const subs: Array<{ id: string; text: string; score?: number }> = [] - - // 先找所有带编号的子题 - const numberedIndices: number[] = [] - for (let i = 0; i < lines.length; i++) { - if (lines[i].match(subQuestionPattern)) { - numberedIndices.push(i) - } - } - - if (numberedIndices.length === 0) return [] - - // 如果第一个编号子题前面有带分值的行,把它们也作为子题 - const firstNumberedIdx = numberedIndices[0] - if (firstNumberedIdx > 0) { - // 检查前一行是否带分值(可能是未编号的子题1) - for (let i = firstNumberedIdx - 1; i >= 0; i--) { - const line = lines[i] - const scoreMatch = line.match(scorePattern) - if (scoreMatch && line.length < 100) { - // 短行 + 带分值 = 可能是子题 - subs.unshift({ - id: createId(), - text: line, - score: Number(scoreMatch[1]), - }) - } else { - break - } - } - } - - // 处理带编号的子题 - let currentSub: { id: string; text: string } | null = null - for (let i = firstNumberedIdx; i < lines.length; i++) { - const line = lines[i] - const match = line.match(subQuestionPattern) - if (match) { - if (currentSub) subs.push(currentSub) - const content = match[2] || "" - const scoreMatch = content.match(scorePattern) - const score = scoreMatch ? Number(scoreMatch[1]) : undefined - currentSub = { id: createId(), text: content } - if (score !== undefined) { - subs.push({ ...currentSub, score }) - currentSub = null - } - } else if (currentSub) { - currentSub.text += "\n" + line - } - } - if (currentSub) subs.push(currentSub) - - return subs -} - -/** - * 从原文中提取材料文本(移除被识别为子题的部分) - */ -const extractMaterialText = ( - fullText: string, - subs: Array<{ id: string; text: string }> -): string => { - let material = fullText - for (const sub of subs) { - // 移除子题文本(取第一行作为匹配依据) - const firstLine = sub.text.split("\n")[0] - if (firstLine) { - material = material.replace(firstLine, "") - } - } - // 清理多余的空行 - return material.replace(/\n{3,}/g, "\n\n").trim() -} - /** 统计结构节点的题目数和总分(递归) */ const computeStats = (node: EditorStructureNode): { count: number; score: number } => { if (node.type === "question") { diff --git a/src/modules/exams/editor/extensions/group-block.tsx b/src/modules/exams/editor/extensions/group-block.tsx index f6bd7b8..942562b 100644 --- a/src/modules/exams/editor/extensions/group-block.tsx +++ b/src/modules/exams/editor/extensions/group-block.tsx @@ -80,6 +80,7 @@ export const GroupBlock = Node.create({ group: "block", content: "block+", defining: true, + isolating: true, addAttributes() { return { title: { default: "" }, diff --git a/src/modules/exams/editor/extensions/question-block.tsx b/src/modules/exams/editor/extensions/question-block.tsx index 9ab0ad3..c77b4de 100644 --- a/src/modules/exams/editor/extensions/question-block.tsx +++ b/src/modules/exams/editor/extensions/question-block.tsx @@ -64,6 +64,7 @@ export const QuestionBlock = Node.create({ group: "block", content: "block+", defining: true, + isolating: true, addAttributes() { return { questionId: { default: "" }, diff --git a/src/modules/exams/editor/extensions/section-block.tsx b/src/modules/exams/editor/extensions/section-block.tsx index d11c413..654cdc0 100644 --- a/src/modules/exams/editor/extensions/section-block.tsx +++ b/src/modules/exams/editor/extensions/section-block.tsx @@ -86,6 +86,7 @@ export const SectionBlock = Node.create({ group: "block", content: "block+", defining: true, + isolating: true, addAttributes() { return { title: { default: "" }, diff --git a/src/modules/exams/editor/selection-toolbar.tsx b/src/modules/exams/editor/selection-toolbar.tsx index 0face48..746eb66 100644 --- a/src/modules/exams/editor/selection-toolbar.tsx +++ b/src/modules/exams/editor/selection-toolbar.tsx @@ -151,40 +151,43 @@ export function SelectionToolbar({ const insertQuestion = (type: QuestionBlockType) => { const score = type === "composite" ? 5 : 2 if (hasTextSelection) { - // 用 slice 获取选区内的完整节点结构(保留段落/列表/图片等), - // 然后用 questionBlock 包裹这些内容。这比 wrapIn 更可靠, - // 因为 wrapIn 在选区跨越多个段落或部分段落时可能失败或丢失内容。 - const { from, to } = editor.state.selection - const slice = editor.state.doc.slice(from, to) - const sliceContent = slice.content.toJSON - ? (slice.content.toJSON() as unknown[]) - : Array.isArray(slice.content.content) - ? slice.content.content.map((n) => n.toJSON()) - : [] - - // 确保 content: "block+" 满足(非空) - const validContent = - sliceContent.length > 0 - ? sliceContent + // 先尝试 wrapIn(普通情况下可用) + const wrapped = editor.commands.wrapInQuestion({ type, score }) + if (!wrapped) { + // wrapIn 失败(通常在 isolating 节点如复合题块内): + // 获取选中文本,删除选区,插入包含选中文本的 questionBlock + // 这样不会清空内容,而是把选中文本转为新的子题 + const { from, to } = editor.state.selection + const selectedText = editor.state.doc.textBetween(from, to, "\n") + const lines = selectedText + .split("\n") + .filter((l) => l.trim().length > 0) + const content = lines.length > 0 + ? lines.map((line) => ({ + type: "paragraph", + content: [{ type: "text", text: line }], + })) : [{ type: "paragraph", content: [{ type: "text", text: " " }] }] - editor.chain() - .focus() - .deleteRange({ from, to }) - .insertContentAt(from, { - type: "questionBlock", - attrs: { type, score, questionId: "" }, - content: validContent, - }) - .run() + editor.chain() + .focus() + .deleteSelection() + .insertContent({ + type: "questionBlock", + attrs: { type, score, questionId: "" }, + content, + }) + .run() + } } else { // 未选中文本时:插入空题目块 editor.chain().focus().insertQuestion({ type, score }).run() } } - /** 通用包裹:用 slice 获取选区完整节点结构,包裹到指定节点中 */ - const wrapSelection = ( + /** 通用降级包裹:wrapIn 失败时,把选中文本转为指定节点 */ + const wrapOrInsert = ( + wrapFn: () => boolean, insertFn: () => void, nodeType: "groupBlock" | "sectionBlock", attrs: Record, @@ -194,33 +197,34 @@ export function SelectionToolbar({ insertFn() return } - // 用 slice 获取选区内的完整节点结构,确保不丢失内容 - const { from, to } = editor.state.selection - const slice = editor.state.doc.slice(from, to) - const sliceContent = slice.content.toJSON - ? (slice.content.toJSON() as unknown[]) - : Array.isArray(slice.content.content) - ? slice.content.content.map((n) => n.toJSON()) - : [] - - const validContent = - sliceContent.length > 0 - ? sliceContent + const wrapped = wrapFn() + if (!wrapped) { + // wrapIn 失败:获取选中文本,删除选区,插入包含选中文本的节点 + const { from, to } = editor.state.selection + const selectedText = editor.state.doc.textBetween(from, to, "\n") + const lines = selectedText.split("\n").filter((l) => l.trim().length > 0) + const content = lines.length > 0 + ? lines.map((line) => ({ + type: "paragraph", + content: [{ type: "text", text: line }], + })) : [{ type: "paragraph", content: [{ type: "text", text: " " }] }] - editor.chain() - .focus() - .deleteRange({ from, to }) - .insertContentAt(from, { - type: nodeType, - attrs: { ...attrs, title: defaultTitle }, - content: validContent, - }) - .run() + editor.chain() + .focus() + .deleteSelection() + .insertContent({ + type: nodeType, + attrs: { ...attrs, title: defaultTitle }, + content, + }) + .run() + } } const insertGroup = () => { - wrapSelection( + wrapOrInsert( + () => editor.commands.wrapInGroup("一、选择题", ""), () => editor.chain().focus().insertGroup("一、选择题", "").run(), "groupBlock", { instruction: "" }, @@ -229,7 +233,8 @@ export function SelectionToolbar({ } const insertSection = () => { - wrapSelection( + wrapOrInsert( + () => editor.commands.wrapInSection("第Ⅰ卷 选择题", 1), () => editor.chain().focus().insertSection("第Ⅰ卷 选择题", 1).run(), "sectionBlock", { level: 1 },