feat(exams): add section/group structure nodes with auto stats

Distinguish structural levels from questions:
- sectionBlock (new): top-level volume like "第Ⅰ卷 选择题(共24分)"
- groupBlock (enhanced): section like "一、选择题" with instruction field
- questionBlock (composite): reserved for reading comprehension

Key changes:
- New section-block.tsx extension with level attr (1=卷, 2=部分) and
  auto-computed question count + total score in NodeView
- group-block.tsx: add instruction field ("每小题3分"), auto stats display
- editor-to-structure.ts: recursive buildStructureNode supports arbitrary
  nesting (section > group > question), computeStats accumulates scores
- exam-rich-form.tsx ExamPreview: render section/group/question with
  distinct styles and stats badges
- selection-toolbar.tsx: add "分卷" button (Layers icon)
- exam-rich-editor.tsx: register SectionBlock, expose insertSection/
  wrapInSection via ref
- actions.ts: AI prompt now outputs volumes[] + groups[] structure with
  instruction; buildTiptapDocFromAiResponse generates nested sectionBlock
- i18n: add markSection keys (zh-CN/en)

Structural nodes are NOT questions: their question count and total score
are automatically computed from child questions, not manually set.
This commit is contained in:
SpecialX
2026-06-24 14:07:29 +08:00
parent f260720443
commit 1f28efbeb6
11 changed files with 451 additions and 107 deletions

View File

@@ -921,52 +921,62 @@ export async function autoMarkExamAction(
"", "",
"## 识别规则", "## 识别规则",
"", "",
"1. **大题分组**:识别\"一、选择题\"\"二、填空题\"\"三、阅读理解\"等大题标题,输出到 sections。每个 section 含 title 和 questions。", "1. **分卷**:识别\"第Ⅰ卷\"\"第Ⅱ卷\"\"第一部分\"等顶层分卷标记,输出到 volumes。每个 volume 含 title、instruction(可选)和 groups。",
"2. **题型识别**:", "2. **大题分组**:识别\"一、选择题\"\"二、填空题\"\"三、阅读理解\"等大题标题,输出到 groups(若在 volume 内则归入对应 volume,否则归入顶层 groups)。每个 group 含 title、instruction(如\"每小题3分,共24分\")和 questions。",
"3. **题型识别**:",
" - 选择题(单选/多选):题干 + A/B/C/D 选项 → type: \"single_choice\" 或 \"multiple_choice\"", " - 选择题(单选/多选):题干 + A/B/C/D 选项 → type: \"single_choice\" 或 \"multiple_choice\"",
" - 判断题:题干 + 对/错 → type: \"judgment\"", " - 判断题:题干 + 对/错 → type: \"judgment\"",
" - 填空题:题干含横线空(_______或( )) → type: \"text\",在 blanks 中标记空数", " - 填空题:题干含横线空(_______或( )) → type: \"text\",在 blanks 中标记空数",
" - 简答/作文题:题干 + \"不少于X字\" → type: \"text\"", " - 简答/作文题:题干 + \"不少于X字\" → type: \"text\"",
" - 阅读理解(含选段+多小题)→ type: \"composite\",subQuestions 存小题", " - 阅读理解(含选段+多小题)→ type: \"composite\",subQuestions 存小题",
"3. **分值**:从\"每小题3分\"\"共24分\"\"(12分)\"等提取,分摊到每题。", "4. **分值**:从\"每小题3分\"\"共24分\"\"(12分)\"等提取,分摊到每题。",
"4. **选项**:A. B. C. D. 格式,输出到 content.options,每项含 id 和 text。", "5. **选项**:A. B. C. D. 格式,输出到 content.options,每项含 id 和 text。",
"5. **填空**:横线\"_______\"或括号\"( )\"位置,在 content.blanks 中标记(数组长度=空数)。", "6. **填空**:横线\"_______\"或括号\"( )\"位置,在 content.blanks 中标记(数组长度=空数)。",
"6. **加点字**:拼音注音题中加点的字(如\"解\"剖),在 content.dottedTexts 中列出原文片段。", "7. **加点字**:拼音注音题中加点的字(如\"解\"剖),在 content.dottedTexts 中列出原文片段。",
"7. **子题**:阅读理解下的\"1. 2. 3.\"小题,输出到 content.subQuestions,每项含 text。", "8. **子题**:阅读理解下的\"1. 2. 3.\"小题,输出到 content.subQuestions,每项含 text 和 score。",
"8. **阅读材料**:阅读理解的文章选段,放到 content.readingMaterial 字段。", "9. **阅读材料**:阅读理解的文章选段,放到 content.readingMaterial 字段。",
"10. **说明文字**:\"每小题3分,共24分\"\"选择正确答案的番号填涂在答题卡上\"等放到 group.instruction,不要混入题干。",
"", "",
"## 输出 JSON schema", "## 输出 JSON schema",
"", "",
"```json", "```json",
"{", "{",
' "title": "试卷标题",', ' "title": "试卷标题",',
' "sections": [', ' "volumes": [',
" {", " {",
' "title": "一、选择题",', ' "title": "第Ⅰ卷 选择题",',
' "questions": [', ' "groups": [',
" {", " {",
' "type": "single_choice",', ' "title": "一、选择正确答案的番号填涂在答题卡上",',
' "score": 3,', ' "instruction": "每小题3分,共24分",',
' "content": {', ' "questions": [',
' "text": "下面加点字的注音全部正确的一项是",', " {",
' "options": [{"id":"A","text":"解剖(pō) 蹭饭(cènɡ)"},{"id":"B","text":"栖息(qī) 譬如(pì)"}],', ' "type": "single_choice",',
' "blanks": [],', ' "score": 3,',
' "dottedTexts": ["解","蹭","徉"],', ' "content": {',
' "subQuestions": []', ' "text": "下面加点字的注音全部正确的一项是",',
" }", ' "options": [{"id":"A","text":"解剖(pō) 蹭饭(cènɡ)"},{"id":"B","text":"栖息(qī) 譬如(pì)"}],',
' "blanks": [],',
' "dottedTexts": ["解","蹭","徉"],',
' "subQuestions": []',
" }",
" }",
" ]",
" }", " }",
" ]", " ]",
" }", " }",
" ]", " ],",
' "groups": []',
"}", "}",
"```", "```",
"", "",
"## 注意事项", "## 注意事项",
"- 不要输出 markdown 代码块标记(```),直接输出 JSON。", "- 不要输出 markdown 代码块标记(```),直接输出 JSON。",
"- 不要输出 ... 或 [...] 等占位符,必须输出完整数据。", "- 不要输出 ... 或 [...] 等占位符,必须输出完整数据。",
"- 如果没有 sections,可直接返回 { \"questions\": [...] }。", "- 如果没有 volumes,可直接返回 { \"groups\": [...] } 或 { \"questions\": [...] }。",
"- 阅读理解的选段文本放到 content.readingMaterial,不要混入题干 text。", "- 阅读理解的选段文本放到 content.readingMaterial,不要混入题干 text。",
"- 作文题的\"不少于300字\"等要求保留在 text 中。", "- 作文题的\"不少于300字\"等要求保留在 text 中。",
"- \"共24分\"等总分信息不要写进 title,放到 instruction;实际总分由子题自动累加。",
].join("\n") ].join("\n")
const { createAiChatCompletion } = await import("@/shared/lib/ai") const { createAiChatCompletion } = await import("@/shared/lib/ai")
@@ -1060,7 +1070,6 @@ const buildTiptapDocFromAiResponse = (data: unknown): unknown => {
if (!isRecord(data)) return { type: "doc", content: [] } if (!isRecord(data)) return { type: "doc", content: [] }
const content: unknown[] = [] const content: unknown[] = []
const sections = Array.isArray(data.sections) ? data.sections : []
const topQuestions = Array.isArray(data.questions) ? data.questions : [] const topQuestions = Array.isArray(data.questions) ? data.questions : []
const buildQuestionBlock = (q: unknown): unknown | null => { const buildQuestionBlock = (q: unknown): unknown | null => {
@@ -1176,26 +1185,56 @@ const buildTiptapDocFromAiResponse = (data: unknown): unknown => {
} }
} }
for (const section of sections) { // 构建 groupBlock(大题分组),含 instruction
if (!isRecord(section)) continue const buildGroupBlock = (g: unknown): unknown | null => {
const title = typeof section.title === "string" ? section.title : "" if (!isRecord(g)) return null
const questions = Array.isArray(section.questions) ? section.questions : [] const title = typeof g.title === "string" ? g.title : ""
const instruction = typeof g.instruction === "string" ? g.instruction : ""
const questions = Array.isArray(g.questions) ? g.questions : []
const children = questions const children = questions
.map(buildQuestionBlock) .map(buildQuestionBlock)
.filter((b): b is Record<string, unknown> => b !== null) .filter((b): b is Record<string, unknown> => b !== null)
// groupBlock 要求 content: "block+",即使无子题也要给一个空段落
const groupContent = const groupContent =
children.length > 0 children.length > 0
? children ? children
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }] : [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
content.push({ return {
type: "groupBlock", type: "groupBlock",
attrs: { title }, attrs: { title, instruction },
content: groupContent, content: groupContent,
}
}
// 构建 sectionBlock(分卷),内含多个 groupBlock
const volumes = Array.isArray(data.volumes) ? data.volumes : []
const topGroups = Array.isArray(data.groups) ? data.groups : []
for (const volume of volumes) {
if (!isRecord(volume)) continue
const title = typeof volume.title === "string" ? volume.title : ""
const innerGroups = Array.isArray(volume.groups) ? volume.groups : []
const groupBlocks = innerGroups
.map(buildGroupBlock)
.filter((b): b is Record<string, unknown> => b !== null)
const sectionContent =
groupBlocks.length > 0
? groupBlocks
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
content.push({
type: "sectionBlock",
attrs: { title, level: 1 },
content: sectionContent,
}) })
} }
if (sections.length === 0) { // 顶层 groups(无分卷时)
for (const g of topGroups) {
const block = buildGroupBlock(g)
if (block) content.push(block)
}
// 顶层 questions(无分卷无大题时)
if (volumes.length === 0 && topGroups.length === 0) {
for (const q of topQuestions) { for (const q of topQuestions) {
const block = buildQuestionBlock(q) const block = buildQuestionBlock(q)
if (block) content.push(block) if (block) content.push(block)

View File

@@ -323,27 +323,60 @@ export function ExamRichForm() {
function ExamPreview({ structure }: { structure: EditorDoc }) { function ExamPreview({ structure }: { structure: EditorDoc }) {
const counter = { value: 0 } const counter = { value: 0 }
const renderNode = ( const renderNode = (
node: EditorDoc["structure"][number], node: EditorDoc["structure"][number],
depth: number = 0 depth: number = 0
): React.ReactNode => { ): React.ReactNode => {
if (node.type === "group") { // 分卷(第Ⅰ卷)—— 顶层结构,显示标题 + 自动统计
if (node.type === "section") {
const level = node.level ?? 1
return ( return (
<div key={node.id} className="mb-6"> <div key={node.id} className="mb-6">
<h3 <div className="mb-3 flex items-baseline gap-3 border-b-2 border-foreground pb-2">
className={cn( <h2 className={cn("font-bold", level === 1 ? "text-lg" : "text-base")}>
"font-bold border-b pb-1 mb-3", {node.title || (level === 1 ? "分卷" : "部分")}
depth === 0 ? "text-base text-foreground" : "text-sm text-foreground/80" </h2>
{node.questionCount !== undefined && node.totalScore !== undefined && (
<span className="text-xs text-muted-foreground">
( {node.questionCount} ,{node.totalScore} )
</span>
)} )}
> </div>
{node.title || "大题"} <div className="space-y-4">
</h3>
<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>
) )
} }
// 大题分组(一、选择题)—— 显示标题 + 说明 + 自动统计
if (node.type === "group") {
return (
<div key={node.id} className="mb-5">
<div className="mb-2 flex items-baseline gap-2 border-b border-foreground/30 pb-1">
<h3 className="text-base font-bold text-foreground">
{node.title || "大题"}
</h3>
{node.questionCount !== undefined && node.totalScore !== undefined && (
<span className="text-xs text-muted-foreground">
( {node.questionCount} ,{node.totalScore} )
</span>
)}
</div>
{node.instruction && (
<p className="mb-2 text-xs text-muted-foreground italic">
{node.instruction}
</p>
)}
<div className="space-y-3 pl-2">
{(node.children ?? []).map((child) => renderNode(child, depth + 1))}
</div>
</div>
)
}
// 题目
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
@@ -387,7 +420,7 @@ function ExamPreview({ structure }: { structure: EditorDoc }) {
))} ))}
</div> </div>
)} )}
{/* 复合题子题 */} {/* 复合题子题(阅读理解小题) */}
{isComposite && question.content.subQuestions && question.content.subQuestions.length > 0 && ( {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"> <div className="mt-3 ml-4 space-y-3 border-l-2 border-primary/30 pl-4">
{question.content.subQuestions.map((sub, idx) => ( {question.content.subQuestions.map((sub, idx) => (

View File

@@ -125,9 +125,94 @@ const buildQuestion = (qb: JSONContent): EditorQuestion => {
return { id, type, score, content } return { id, type, score, content }
} }
/** 统计结构节点的题目数和总分(递归) */
const computeStats = (node: EditorStructureNode): { count: number; score: number } => {
if (node.type === "question") {
return { count: 1, score: node.score ?? 0 }
}
let count = 0
let score = 0
for (const child of node.children ?? []) {
const stats = computeStats(child)
count += stats.count
score += stats.score
}
return { count, score }
}
/**
* 递归构建结构树:支持 sectionBlock(分卷) / groupBlock(大题) / questionBlock(题目) 嵌套。
* 返回结构节点和提取出的所有题目(扁平列表)。
*/
const buildStructureNode = (
block: JSONContent,
questions: EditorQuestion[]
): EditorStructureNode | null => {
if (block.type === "sectionBlock") {
const title =
typeof block.attrs?.title === "string" ? block.attrs.title : ""
const level =
typeof block.attrs?.level === "number" ? block.attrs.level : 1
const children: EditorStructureNode[] = []
for (const inner of block.content ?? []) {
const child = buildStructureNode(inner, questions)
if (child) children.push(child)
}
const node: EditorStructureNode = {
id: createId(),
type: "section",
title,
level,
children,
}
const stats = computeStats(node)
node.questionCount = stats.count
node.totalScore = stats.score
return node
}
if (block.type === "groupBlock") {
const title =
typeof block.attrs?.title === "string" ? block.attrs.title : ""
const instruction =
typeof block.attrs?.instruction === "string"
? block.attrs.instruction
: ""
const children: EditorStructureNode[] = []
for (const inner of block.content ?? []) {
const child = buildStructureNode(inner, questions)
if (child) children.push(child)
}
const node: EditorStructureNode = {
id: createId(),
type: "group",
title,
instruction,
children,
}
const stats = computeStats(node)
node.questionCount = stats.count
node.totalScore = stats.score
return node
}
if (block.type === "questionBlock") {
const q = buildQuestion(block)
questions.push(q)
return {
id: createId(),
type: "question",
questionId: q.id,
score: q.score,
}
}
return null
}
/** /**
* 将 Tiptap 编辑器文档(JSONContent)转换为试卷结构(EditorDoc)。 * 将 Tiptap 编辑器文档(JSONContent)转换为试卷结构(EditorDoc)。
* 遍历顶层块:groupBlock → 分组,questionBlock → 题目 * 支持 sectionBlock(分卷) / groupBlock(大题) / questionBlock(题目) 任意嵌套
*/ */
export const editorDocToStructure = ( export const editorDocToStructure = (
doc: JSONContent, doc: JSONContent,
@@ -138,39 +223,8 @@ export const editorDocToStructure = (
const topBlocks = doc.content ?? [] const topBlocks = doc.content ?? []
for (const block of topBlocks) { for (const block of topBlocks) {
if (block.type === "groupBlock") { const node = buildStructureNode(block, questions)
const groupTitle = if (node) structure.push(node)
typeof block.attrs?.title === "string" ? block.attrs.title : ""
const children: EditorStructureNode[] = []
const innerBlocks = block.content ?? []
for (const qb of innerBlocks) {
if (qb.type === "questionBlock") {
const q = buildQuestion(qb)
questions.push(q)
children.push({
id: createId(),
type: "question",
questionId: q.id,
score: q.score,
})
}
}
structure.push({
id: createId(),
type: "group",
title: groupTitle,
children,
})
} else if (block.type === "questionBlock") {
const q = buildQuestion(block)
questions.push(q)
structure.push({
id: createId(),
type: "question",
questionId: q.id,
score: q.score,
})
}
} }
return { title, questions, structure } return { title, questions, structure }

View File

@@ -23,12 +23,23 @@ export interface EditorQuestion {
content: RichQuestionContent content: RichQuestionContent
} }
/** 结构节点类型:section=分卷, group=大题, question=题目 */
export type EditorStructureNodeType = "section" | "group" | "question"
export interface EditorStructureNode { export interface EditorStructureNode {
id: string id: string
type: "group" | "question" type: EditorStructureNodeType
title?: string title?: string
/** 大题说明(如"每小题3分") */
instruction?: string
/** 分卷层级(1=卷, 2=部分) */
level?: number
questionId?: string questionId?: string
score?: number score?: number
/** 自动统计:题目数(不含结构节点) */
questionCount?: number
/** 自动统计:总分(子题累加) */
totalScore?: number
children?: EditorStructureNode[] children?: EditorStructureNode[]
} }

View File

@@ -25,6 +25,7 @@ import {
GroupBlock, GroupBlock,
ImageNode, ImageNode,
QuestionBlock, QuestionBlock,
SectionBlock,
type QuestionBlockType, type QuestionBlockType,
} from "./extensions" } from "./extensions"
import { SelectionToolbar } from "./selection-toolbar" import { SelectionToolbar } from "./selection-toolbar"
@@ -42,9 +43,13 @@ export interface ExamRichEditorHandle {
/** 将选区包裹为题目块 */ /** 将选区包裹为题目块 */
wrapInQuestion: (type: QuestionBlockType, score?: number) => void wrapInQuestion: (type: QuestionBlockType, score?: number) => void
/** 插入大题分组 */ /** 插入大题分组 */
insertGroup: (title?: string) => void insertGroup: (title?: string, instruction?: string) => void
/** 将选区包裹为大题分组 */ /** 将选区包裹为大题分组 */
wrapInGroup: (title?: string) => void wrapInGroup: (title?: string, instruction?: string) => void
/** 插入试卷分卷 */
insertSection: (title?: string, level?: number) => void
/** 将选区包裹为试卷分卷 */
wrapInSection: (title?: string, level?: number) => void
/** 清空编辑器 */ /** 清空编辑器 */
clear: () => void clear: () => void
/** 获取 Editor 实例(高级用法) */ /** 获取 Editor 实例(高级用法) */
@@ -130,6 +135,7 @@ export const ExamRichEditor = forwardRef<ExamRichEditorHandle, ExamRichEditorPro
ImageNode, ImageNode,
QuestionBlock, QuestionBlock,
GroupBlock, GroupBlock,
SectionBlock,
], ],
[placeholder] [placeholder]
) )
@@ -173,11 +179,17 @@ export const ExamRichEditor = forwardRef<ExamRichEditorHandle, ExamRichEditorPro
wrapInQuestion: (type, score = 2) => { wrapInQuestion: (type, score = 2) => {
editor?.chain().focus().wrapInQuestion({ type, score }).run() editor?.chain().focus().wrapInQuestion({ type, score }).run()
}, },
insertGroup: (title) => { insertGroup: (title, instruction) => {
editor?.chain().focus().insertGroup(title).run() editor?.chain().focus().insertGroup(title, instruction).run()
}, },
wrapInGroup: (title) => { wrapInGroup: (title, instruction) => {
editor?.chain().focus().wrapInGroup(title).run() editor?.chain().focus().wrapInGroup(title, instruction).run()
},
insertSection: (title, level) => {
editor?.chain().focus().insertSection(title, level).run()
},
wrapInSection: (title, level) => {
editor?.chain().focus().wrapInSection(title, level).run()
}, },
clear: () => { clear: () => {
editor?.commands.clearContent(true) editor?.commands.clearContent(true)

View File

@@ -5,29 +5,75 @@ declare module "@tiptap/core" {
interface Commands<ReturnType> { interface Commands<ReturnType> {
groupBlock: { groupBlock: {
/** 插入大题分组(如"一、选择题") */ /** 插入大题分组(如"一、选择题") */
insertGroup: (title?: string) => ReturnType insertGroup: (title?: string, instruction?: string) => ReturnType
/** 将当前选区内容包裹为大题分组 */ /** 将当前选区内容包裹为大题分组 */
wrapInGroup: (title?: string) => ReturnType wrapInGroup: (title?: string, instruction?: string) => ReturnType
} }
} }
} }
const GroupView = ({ node, updateAttributes }: NodeViewProps) => ( /** 递归统计节点内的题目数和总分(含嵌套 questionBlock 的子题) */
<NodeViewWrapper className="my-4 rounded-md border-l-4 border-l-primary bg-primary/5 p-3"> const countQuestionsAndScore = (node: { content?: Array<{ type?: string; attrs?: Record<string, unknown>; content?: unknown[] }> }): { count: number; score: number } => {
<input let count = 0
type="text" let score = 0
value={(node.attrs.title as string) || ""} const blocks = node.content ?? []
onChange={(e) => updateAttributes({ title: e.target.value })} for (const block of blocks) {
placeholder="大题标题(如:一、选择题)" if (block.type === "questionBlock") {
className="w-full bg-transparent text-base font-semibold focus:outline-none" const inner = Array.isArray(block.content) ? block.content : []
/> const hasSubQuestions = inner.some((n) => typeof n === "object" && n !== null && "type" in n && (n as { type?: string }).type === "questionBlock")
<NodeViewContent className="mt-2 block" /> if (hasSubQuestions) {
</NodeViewWrapper> const subStats = countQuestionsAndScore(block as never)
) count += subStats.count
score += subStats.score
} else {
count += 1
const s = typeof block.attrs?.score === "number" ? block.attrs.score : 0
score += s
}
} else if (block.type === "sectionBlock" || block.type === "groupBlock") {
const subStats = countQuestionsAndScore(block as never)
count += subStats.count
score += subStats.score
}
}
return { count, score }
}
const GroupView = ({ node, updateAttributes }: NodeViewProps) => {
const title = (node.attrs.title as string) || ""
const instruction = (node.attrs.instruction as string) || ""
const stats = countQuestionsAndScore(node.toJSON())
return (
<NodeViewWrapper className="my-4 rounded-md border-l-4 border-l-primary bg-primary/5 p-3">
<div className="mb-1 flex flex-wrap items-center gap-2">
<input
type="text"
value={title}
onChange={(e) => updateAttributes({ title: e.target.value })}
placeholder="大题标题(如:一、选择题)"
className="flex-1 bg-transparent text-base font-semibold focus:outline-none"
/>
<span className="rounded bg-primary/10 px-2 py-0.5 text-xs text-primary">
{stats.count} · {stats.score}
</span>
</div>
<input
type="text"
value={instruction}
onChange={(e) => updateAttributes({ instruction: e.target.value })}
placeholder="说明(如:每小题3分,共24分)—— 可留空,总分自动统计"
className="mb-2 w-full bg-transparent text-xs text-muted-foreground focus:outline-none"
/>
<NodeViewContent className="mt-1 block" />
</NodeViewWrapper>
)
}
/** /**
* 分组节点 —— 大题分组,包含标题输入区 + 子内容区 * 大题分组节点 —— 如"一、选择题""二、填空题"
* 用于"一、选择题""二、填空题"等大题结构 * 不是题目,题数和总分由内部子题自动累加
* instruction 字段用于"每小题3分"等说明,不影响统计。
*/ */
export const GroupBlock = Node.create({ export const GroupBlock = Node.create({
name: "groupBlock", name: "groupBlock",
@@ -36,7 +82,10 @@ export const GroupBlock = Node.create({
defining: true, defining: true,
isolating: true, isolating: true,
addAttributes() { addAttributes() {
return { title: { default: "" } } return {
title: { default: "" },
instruction: { default: "" },
}
}, },
parseHTML: () => [{ tag: "div[data-group-block]" }], parseHTML: () => [{ tag: "div[data-group-block]" }],
renderHTML: ({ HTMLAttributes }) => [ renderHTML: ({ HTMLAttributes }) => [
@@ -50,11 +99,11 @@ export const GroupBlock = Node.create({
addCommands() { addCommands() {
return { return {
insertGroup: insertGroup:
(title) => (title, instruction) =>
({ commands }) => ({ commands }) =>
commands.insertContent({ commands.insertContent({
type: "groupBlock", type: "groupBlock",
attrs: { title: title || "" }, attrs: { title: title || "", instruction: instruction || "" },
content: [ content: [
{ {
type: "paragraph", type: "paragraph",
@@ -63,9 +112,12 @@ export const GroupBlock = Node.create({
], ],
}), }),
wrapInGroup: wrapInGroup:
(title) => (title, instruction) =>
({ commands }) => ({ commands }) =>
commands.wrapIn("groupBlock", { title: title || "" }), commands.wrapIn("groupBlock", {
title: title || "",
instruction: instruction || "",
}),
} }
}, },
}) })

View File

@@ -3,3 +3,4 @@ export { BlankNode } from "./blank-node"
export { ImageNode } from "./image-node" export { ImageNode } from "./image-node"
export { QuestionBlock, type QuestionBlockType, type QuestionBlockAttrs } from "./question-block" export { QuestionBlock, type QuestionBlockType, type QuestionBlockAttrs } from "./question-block"
export { GroupBlock } from "./group-block" export { GroupBlock } from "./group-block"
export { SectionBlock } from "./section-block"

View File

@@ -0,0 +1,129 @@
import { Node, mergeAttributes } from "@tiptap/core"
import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent, type NodeViewProps } from "@tiptap/react"
declare module "@tiptap/core" {
interface Commands<ReturnType> {
sectionBlock: {
/** 插入试卷分卷(如"第Ⅰ卷 选择题") */
insertSection: (title?: string, level?: number) => ReturnType
/** 将当前选区内容包裹为试卷分卷 */
wrapInSection: (title?: string, level?: number) => ReturnType
}
}
}
/** 递归统计节点内的题目数和总分(含嵌套 section/group/question) */
const countQuestionsAndScore = (node: { content?: Array<{ type?: string; attrs?: Record<string, unknown>; content?: unknown[] }> }): { count: number; score: number } => {
let count = 0
let score = 0
const blocks = node.content ?? []
for (const block of blocks) {
if (block.type === "questionBlock") {
// 复合题:统计嵌套子题;否则计为 1 题
const inner = Array.isArray(block.content) ? block.content : []
const hasSubQuestions = inner.some((n) => typeof n === "object" && n !== null && "type" in n && (n as { type?: string }).type === "questionBlock")
if (hasSubQuestions) {
const subStats = countQuestionsAndScore(block as never)
count += subStats.count
score += subStats.score
} else {
count += 1
const s = typeof block.attrs?.score === "number" ? block.attrs.score : 0
score += s
}
} else if (block.type === "sectionBlock" || block.type === "groupBlock") {
const subStats = countQuestionsAndScore(block as never)
count += subStats.count
score += subStats.score
}
}
return { count, score }
}
const SectionView = ({ node, updateAttributes }: NodeViewProps) => {
const title = (node.attrs.title as string) || ""
const level = typeof node.attrs.level === "number" ? node.attrs.level : 1
const stats = countQuestionsAndScore(node.toJSON())
const titleSize = level === 1 ? "text-lg" : "text-base"
return (
<NodeViewWrapper className="my-4 rounded-md border-2 border-primary/40 bg-primary/5 p-4">
<div className="mb-2 flex flex-wrap items-center gap-2 border-b border-primary/20 pb-2">
<input
type="text"
value={title}
onChange={(e) => updateAttributes({ title: e.target.value })}
placeholder={`分卷标题(如:第Ⅰ卷 选择题)`}
className={`flex-1 bg-transparent font-bold focus:outline-none ${titleSize}`}
/>
<select
value={String(level)}
onChange={(e) => updateAttributes({ level: Number(e.target.value) })}
className="rounded border bg-background px-1.5 py-0.5 text-xs"
title="层级"
>
<option value="1"></option>
<option value="2"></option>
<option value="3"></option>
</select>
<span className="rounded bg-primary/10 px-2 py-0.5 text-xs text-primary">
{stats.count} · {stats.score}
</span>
</div>
<NodeViewContent className="mt-2 block" />
</NodeViewWrapper>
)
}
/**
* 试卷分卷节点 —— 顶层结构,如"第Ⅰ卷 选择题(共24分)"。
* 不是题目,题数和总分由内部子题自动累加。
* 可嵌套 groupBlock(大题)和 questionBlock(题目)。
*/
export const SectionBlock = Node.create({
name: "sectionBlock",
group: "block",
content: "block+",
defining: true,
isolating: true,
addAttributes() {
return {
title: { default: "" },
level: { default: 1 },
}
},
parseHTML: () => [{ tag: "div[data-section-block]" }],
renderHTML: ({ HTMLAttributes }) => [
"div",
mergeAttributes(HTMLAttributes, { "data-section-block": "true" }),
0,
],
addNodeView() {
return ReactNodeViewRenderer(SectionView)
},
addCommands() {
return {
insertSection:
(title, level) =>
({ commands }) =>
commands.insertContent({
type: "sectionBlock",
attrs: { title: title || "", level: level ?? 1 },
content: [
{
type: "paragraph",
content: [{ type: "text", text: " " }],
},
],
}),
wrapInSection:
(title, level) =>
({ commands }) =>
commands.wrapIn("sectionBlock", {
title: title || "",
level: level ?? 1,
}),
}
},
})

View File

@@ -8,6 +8,7 @@ import {
CircleSlash, CircleSlash,
FileText, FileText,
Heading, Heading,
Layers,
Underline, Underline,
} from "lucide-react" } from "lucide-react"
@@ -137,9 +138,18 @@ export function SelectionToolbar({
const insertGroup = () => { const insertGroup = () => {
const chain = editor.chain().focus() const chain = editor.chain().focus()
if (hasTextSelection) { if (hasTextSelection) {
chain.wrapInGroup("一、选择题").run() chain.wrapInGroup("一、选择题", "").run()
} else { } else {
chain.insertGroup("一、选择题").run() chain.insertGroup("一、选择题", "").run()
}
}
const insertSection = () => {
const chain = editor.chain().focus()
if (hasTextSelection) {
chain.wrapInSection("第Ⅰ卷 选择题", 1).run()
} else {
chain.insertSection("第Ⅰ卷 选择题", 1).run()
} }
} }
@@ -189,6 +199,7 @@ export function SelectionToolbar({
transform: "translateX(-50%)", transform: "translateX(-50%)",
}} }}
> >
<ToolbarButton onClick={insertSection} icon={Layers} label="分卷" />
<ToolbarButton onClick={insertGroup} icon={Heading} label="大题" /> <ToolbarButton onClick={insertGroup} icon={Heading} label="大题" />
<ToolbarButton <ToolbarButton
onClick={() => insertQuestion("single_choice")} onClick={() => insertQuestion("single_choice")}

View File

@@ -166,7 +166,8 @@
"emptyEditor": "Enter or paste exam content in the editor on the left", "emptyEditor": "Enter or paste exam content in the editor on the left",
"emptyPreview": "Preview will appear here", "emptyPreview": "Preview will appear here",
"markQuestion": "Mark Question", "markQuestion": "Mark Question",
"markGroup": "Mark Group", "markGroup": "Mark Section",
"markSection": "Mark Volume",
"markDotted": "Dotted Char", "markDotted": "Dotted Char",
"markBlank": "Blank", "markBlank": "Blank",
"insertImage": "Insert Image", "insertImage": "Insert Image",

View File

@@ -166,7 +166,8 @@
"emptyEditor": "请在左侧编辑区输入或粘贴试卷内容", "emptyEditor": "请在左侧编辑区输入或粘贴试卷内容",
"emptyPreview": "预览将在此处显示", "emptyPreview": "预览将在此处显示",
"markQuestion": "标记题目", "markQuestion": "标记题目",
"markGroup": "标记分组", "markGroup": "标记大题",
"markSection": "标记分卷",
"markDotted": "加点字", "markDotted": "加点字",
"markBlank": "填空", "markBlank": "填空",
"insertImage": "插入图片", "insertImage": "插入图片",