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

@@ -8,6 +8,7 @@ import {
CircleSlash,
FileText,
Heading,
Layers,
Underline,
} from "lucide-react"
@@ -137,9 +138,18 @@ export function SelectionToolbar({
const insertGroup = () => {
const chain = editor.chain().focus()
if (hasTextSelection) {
chain.wrapInGroup("一、选择题").run()
chain.wrapInGroup("一、选择题", "").run()
} 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%)",
}}
>
<ToolbarButton onClick={insertSection} icon={Layers} label="分卷" />
<ToolbarButton onClick={insertGroup} icon={Heading} label="大题" />
<ToolbarButton
onClick={() => insertQuestion("single_choice")}