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:
@@ -921,52 +921,62 @@ export async function autoMarkExamAction(
|
||||
"",
|
||||
"## 识别规则",
|
||||
"",
|
||||
"1. **大题分组**:识别\"一、选择题\"\"二、填空题\"\"三、阅读理解\"等大题标题,输出到 sections。每个 section 含 title 和 questions。",
|
||||
"2. **题型识别**:",
|
||||
"1. **分卷**:识别\"第Ⅰ卷\"\"第Ⅱ卷\"\"第一部分\"等顶层分卷标记,输出到 volumes。每个 volume 含 title、instruction(可选)和 groups。",
|
||||
"2. **大题分组**:识别\"一、选择题\"\"二、填空题\"\"三、阅读理解\"等大题标题,输出到 groups(若在 volume 内则归入对应 volume,否则归入顶层 groups)。每个 group 含 title、instruction(如\"每小题3分,共24分\")和 questions。",
|
||||
"3. **题型识别**:",
|
||||
" - 选择题(单选/多选):题干 + A/B/C/D 选项 → type: \"single_choice\" 或 \"multiple_choice\"",
|
||||
" - 判断题:题干 + 对/错 → type: \"judgment\"",
|
||||
" - 填空题:题干含横线空(_______或( )) → type: \"text\",在 blanks 中标记空数",
|
||||
" - 简答/作文题:题干 + \"不少于X字\" → type: \"text\"",
|
||||
" - 阅读理解(含选段+多小题)→ type: \"composite\",subQuestions 存小题",
|
||||
"3. **分值**:从\"每小题3分\"\"共24分\"\"(12分)\"等提取,分摊到每题。",
|
||||
"4. **选项**:A. B. C. D. 格式,输出到 content.options,每项含 id 和 text。",
|
||||
"5. **填空**:横线\"_______\"或括号\"( )\"位置,在 content.blanks 中标记(数组长度=空数)。",
|
||||
"6. **加点字**:拼音注音题中加点的字(如\"解\"剖),在 content.dottedTexts 中列出原文片段。",
|
||||
"7. **子题**:阅读理解下的\"1. 2. 3.\"小题,输出到 content.subQuestions,每项含 text。",
|
||||
"8. **阅读材料**:阅读理解的文章选段,放到 content.readingMaterial 字段。",
|
||||
"4. **分值**:从\"每小题3分\"\"共24分\"\"(12分)\"等提取,分摊到每题。",
|
||||
"5. **选项**:A. B. C. D. 格式,输出到 content.options,每项含 id 和 text。",
|
||||
"6. **填空**:横线\"_______\"或括号\"( )\"位置,在 content.blanks 中标记(数组长度=空数)。",
|
||||
"7. **加点字**:拼音注音题中加点的字(如\"解\"剖),在 content.dottedTexts 中列出原文片段。",
|
||||
"8. **子题**:阅读理解下的\"1. 2. 3.\"小题,输出到 content.subQuestions,每项含 text 和 score。",
|
||||
"9. **阅读材料**:阅读理解的文章选段,放到 content.readingMaterial 字段。",
|
||||
"10. **说明文字**:\"每小题3分,共24分\"\"选择正确答案的番号填涂在答题卡上\"等放到 group.instruction,不要混入题干。",
|
||||
"",
|
||||
"## 输出 JSON schema",
|
||||
"",
|
||||
"```json",
|
||||
"{",
|
||||
' "title": "试卷标题",',
|
||||
' "sections": [',
|
||||
' "volumes": [',
|
||||
" {",
|
||||
' "title": "一、选择题",',
|
||||
' "questions": [',
|
||||
' "title": "第Ⅰ卷 选择题",',
|
||||
' "groups": [',
|
||||
" {",
|
||||
' "type": "single_choice",',
|
||||
' "score": 3,',
|
||||
' "content": {',
|
||||
' "text": "下面加点字的注音全部正确的一项是",',
|
||||
' "options": [{"id":"A","text":"解剖(pō) 蹭饭(cènɡ)"},{"id":"B","text":"栖息(qī) 譬如(pì)"}],',
|
||||
' "blanks": [],',
|
||||
' "dottedTexts": ["解","蹭","徉"],',
|
||||
' "subQuestions": []',
|
||||
" }",
|
||||
' "title": "一、选择正确答案的番号填涂在答题卡上",',
|
||||
' "instruction": "每小题3分,共24分",',
|
||||
' "questions": [',
|
||||
" {",
|
||||
' "type": "single_choice",',
|
||||
' "score": 3,',
|
||||
' "content": {',
|
||||
' "text": "下面加点字的注音全部正确的一项是",',
|
||||
' "options": [{"id":"A","text":"解剖(pō) 蹭饭(cènɡ)"},{"id":"B","text":"栖息(qī) 譬如(pì)"}],',
|
||||
' "blanks": [],',
|
||||
' "dottedTexts": ["解","蹭","徉"],',
|
||||
' "subQuestions": []',
|
||||
" }",
|
||||
" }",
|
||||
" ]",
|
||||
" }",
|
||||
" ]",
|
||||
" }",
|
||||
" ]",
|
||||
" ],",
|
||||
' "groups": []',
|
||||
"}",
|
||||
"```",
|
||||
"",
|
||||
"## 注意事项",
|
||||
"- 不要输出 markdown 代码块标记(```),直接输出 JSON。",
|
||||
"- 不要输出 ... 或 [...] 等占位符,必须输出完整数据。",
|
||||
"- 如果没有 sections,可直接返回 { \"questions\": [...] }。",
|
||||
"- 如果没有 volumes,可直接返回 { \"groups\": [...] } 或 { \"questions\": [...] }。",
|
||||
"- 阅读理解的选段文本放到 content.readingMaterial,不要混入题干 text。",
|
||||
"- 作文题的\"不少于300字\"等要求保留在 text 中。",
|
||||
"- \"共24分\"等总分信息不要写进 title,放到 instruction;实际总分由子题自动累加。",
|
||||
].join("\n")
|
||||
|
||||
const { createAiChatCompletion } = await import("@/shared/lib/ai")
|
||||
@@ -1060,7 +1070,6 @@ const buildTiptapDocFromAiResponse = (data: unknown): unknown => {
|
||||
if (!isRecord(data)) return { type: "doc", content: [] }
|
||||
|
||||
const content: unknown[] = []
|
||||
const sections = Array.isArray(data.sections) ? data.sections : []
|
||||
const topQuestions = Array.isArray(data.questions) ? data.questions : []
|
||||
|
||||
const buildQuestionBlock = (q: unknown): unknown | null => {
|
||||
@@ -1176,26 +1185,56 @@ const buildTiptapDocFromAiResponse = (data: unknown): unknown => {
|
||||
}
|
||||
}
|
||||
|
||||
for (const section of sections) {
|
||||
if (!isRecord(section)) continue
|
||||
const title = typeof section.title === "string" ? section.title : ""
|
||||
const questions = Array.isArray(section.questions) ? section.questions : []
|
||||
// 构建 groupBlock(大题分组),含 instruction
|
||||
const buildGroupBlock = (g: unknown): unknown | null => {
|
||||
if (!isRecord(g)) return null
|
||||
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
|
||||
.map(buildQuestionBlock)
|
||||
.filter((b): b is Record<string, unknown> => b !== null)
|
||||
// groupBlock 要求 content: "block+",即使无子题也要给一个空段落
|
||||
const groupContent =
|
||||
children.length > 0
|
||||
? children
|
||||
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
|
||||
content.push({
|
||||
return {
|
||||
type: "groupBlock",
|
||||
attrs: { title },
|
||||
attrs: { title, instruction },
|
||||
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) {
|
||||
const block = buildQuestionBlock(q)
|
||||
if (block) content.push(block)
|
||||
|
||||
@@ -323,27 +323,60 @@ export function ExamRichForm() {
|
||||
|
||||
function ExamPreview({ structure }: { structure: EditorDoc }) {
|
||||
const counter = { value: 0 }
|
||||
|
||||
const renderNode = (
|
||||
node: EditorDoc["structure"][number],
|
||||
depth: number = 0
|
||||
): React.ReactNode => {
|
||||
if (node.type === "group") {
|
||||
// 分卷(第Ⅰ卷)—— 顶层结构,显示标题 + 自动统计
|
||||
if (node.type === "section") {
|
||||
const level = node.level ?? 1
|
||||
return (
|
||||
<div key={node.id} className="mb-6">
|
||||
<h3
|
||||
className={cn(
|
||||
"font-bold border-b pb-1 mb-3",
|
||||
depth === 0 ? "text-base text-foreground" : "text-sm text-foreground/80"
|
||||
<div className="mb-3 flex items-baseline gap-3 border-b-2 border-foreground pb-2">
|
||||
<h2 className={cn("font-bold", level === 1 ? "text-lg" : "text-base")}>
|
||||
{node.title || (level === 1 ? "分卷" : "部分")}
|
||||
</h2>
|
||||
{node.questionCount !== undefined && node.totalScore !== undefined && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
(共 {node.questionCount} 题,{node.totalScore} 分)
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
{node.title || "大题"}
|
||||
</h3>
|
||||
<div className="space-y-4 pl-2">
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{(node.children ?? []).map((child) => renderNode(child, depth + 1))}
|
||||
</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)
|
||||
if (!question) return null
|
||||
counter.value += 1
|
||||
@@ -387,7 +420,7 @@ function ExamPreview({ structure }: { structure: EditorDoc }) {
|
||||
))}
|
||||
</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) => (
|
||||
|
||||
@@ -125,9 +125,94 @@ const buildQuestion = (qb: JSONContent): EditorQuestion => {
|
||||
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)。
|
||||
* 遍历顶层块:groupBlock → 分组,questionBlock → 题目。
|
||||
* 支持 sectionBlock(分卷) / groupBlock(大题) / questionBlock(题目) 任意嵌套。
|
||||
*/
|
||||
export const editorDocToStructure = (
|
||||
doc: JSONContent,
|
||||
@@ -138,39 +223,8 @@ export const editorDocToStructure = (
|
||||
const topBlocks = doc.content ?? []
|
||||
|
||||
for (const block of topBlocks) {
|
||||
if (block.type === "groupBlock") {
|
||||
const groupTitle =
|
||||
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,
|
||||
})
|
||||
}
|
||||
const node = buildStructureNode(block, questions)
|
||||
if (node) structure.push(node)
|
||||
}
|
||||
|
||||
return { title, questions, structure }
|
||||
|
||||
@@ -23,12 +23,23 @@ export interface EditorQuestion {
|
||||
content: RichQuestionContent
|
||||
}
|
||||
|
||||
/** 结构节点类型:section=分卷, group=大题, question=题目 */
|
||||
export type EditorStructureNodeType = "section" | "group" | "question"
|
||||
|
||||
export interface EditorStructureNode {
|
||||
id: string
|
||||
type: "group" | "question"
|
||||
type: EditorStructureNodeType
|
||||
title?: string
|
||||
/** 大题说明(如"每小题3分") */
|
||||
instruction?: string
|
||||
/** 分卷层级(1=卷, 2=部分) */
|
||||
level?: number
|
||||
questionId?: string
|
||||
score?: number
|
||||
/** 自动统计:题目数(不含结构节点) */
|
||||
questionCount?: number
|
||||
/** 自动统计:总分(子题累加) */
|
||||
totalScore?: number
|
||||
children?: EditorStructureNode[]
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
GroupBlock,
|
||||
ImageNode,
|
||||
QuestionBlock,
|
||||
SectionBlock,
|
||||
type QuestionBlockType,
|
||||
} from "./extensions"
|
||||
import { SelectionToolbar } from "./selection-toolbar"
|
||||
@@ -42,9 +43,13 @@ export interface ExamRichEditorHandle {
|
||||
/** 将选区包裹为题目块 */
|
||||
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
|
||||
/** 获取 Editor 实例(高级用法) */
|
||||
@@ -130,6 +135,7 @@ export const ExamRichEditor = forwardRef<ExamRichEditorHandle, ExamRichEditorPro
|
||||
ImageNode,
|
||||
QuestionBlock,
|
||||
GroupBlock,
|
||||
SectionBlock,
|
||||
],
|
||||
[placeholder]
|
||||
)
|
||||
@@ -173,11 +179,17 @@ export const ExamRichEditor = forwardRef<ExamRichEditorHandle, ExamRichEditorPro
|
||||
wrapInQuestion: (type, score = 2) => {
|
||||
editor?.chain().focus().wrapInQuestion({ type, score }).run()
|
||||
},
|
||||
insertGroup: (title) => {
|
||||
editor?.chain().focus().insertGroup(title).run()
|
||||
insertGroup: (title, instruction) => {
|
||||
editor?.chain().focus().insertGroup(title, instruction).run()
|
||||
},
|
||||
wrapInGroup: (title) => {
|
||||
editor?.chain().focus().wrapInGroup(title).run()
|
||||
wrapInGroup: (title, instruction) => {
|
||||
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: () => {
|
||||
editor?.commands.clearContent(true)
|
||||
|
||||
@@ -5,29 +5,75 @@ declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
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) => (
|
||||
<NodeViewWrapper className="my-4 rounded-md border-l-4 border-l-primary bg-primary/5 p-3">
|
||||
<input
|
||||
type="text"
|
||||
value={(node.attrs.title as string) || ""}
|
||||
onChange={(e) => updateAttributes({ title: e.target.value })}
|
||||
placeholder="大题标题(如:一、选择题)"
|
||||
className="w-full bg-transparent text-base font-semibold focus:outline-none"
|
||||
/>
|
||||
<NodeViewContent className="mt-2 block" />
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
/** 递归统计节点内的题目数和总分(含嵌套 questionBlock 的子题) */
|
||||
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") {
|
||||
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 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({
|
||||
name: "groupBlock",
|
||||
@@ -36,7 +82,10 @@ export const GroupBlock = Node.create({
|
||||
defining: true,
|
||||
isolating: true,
|
||||
addAttributes() {
|
||||
return { title: { default: "" } }
|
||||
return {
|
||||
title: { default: "" },
|
||||
instruction: { default: "" },
|
||||
}
|
||||
},
|
||||
parseHTML: () => [{ tag: "div[data-group-block]" }],
|
||||
renderHTML: ({ HTMLAttributes }) => [
|
||||
@@ -50,11 +99,11 @@ export const GroupBlock = Node.create({
|
||||
addCommands() {
|
||||
return {
|
||||
insertGroup:
|
||||
(title) =>
|
||||
(title, instruction) =>
|
||||
({ commands }) =>
|
||||
commands.insertContent({
|
||||
type: "groupBlock",
|
||||
attrs: { title: title || "" },
|
||||
attrs: { title: title || "", instruction: instruction || "" },
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
@@ -63,9 +112,12 @@ export const GroupBlock = Node.create({
|
||||
],
|
||||
}),
|
||||
wrapInGroup:
|
||||
(title) =>
|
||||
(title, instruction) =>
|
||||
({ commands }) =>
|
||||
commands.wrapIn("groupBlock", { title: title || "" }),
|
||||
commands.wrapIn("groupBlock", {
|
||||
title: title || "",
|
||||
instruction: instruction || "",
|
||||
}),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -3,3 +3,4 @@ export { BlankNode } from "./blank-node"
|
||||
export { ImageNode } from "./image-node"
|
||||
export { QuestionBlock, type QuestionBlockType, type QuestionBlockAttrs } from "./question-block"
|
||||
export { GroupBlock } from "./group-block"
|
||||
export { SectionBlock } from "./section-block"
|
||||
|
||||
129
src/modules/exams/editor/extensions/section-block.tsx
Normal file
129
src/modules/exams/editor/extensions/section-block.tsx
Normal 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,
|
||||
}),
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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")}
|
||||
|
||||
@@ -166,7 +166,8 @@
|
||||
"emptyEditor": "Enter or paste exam content in the editor on the left",
|
||||
"emptyPreview": "Preview will appear here",
|
||||
"markQuestion": "Mark Question",
|
||||
"markGroup": "Mark Group",
|
||||
"markGroup": "Mark Section",
|
||||
"markSection": "Mark Volume",
|
||||
"markDotted": "Dotted Char",
|
||||
"markBlank": "Blank",
|
||||
"insertImage": "Insert Image",
|
||||
|
||||
@@ -166,7 +166,8 @@
|
||||
"emptyEditor": "请在左侧编辑区输入或粘贴试卷内容",
|
||||
"emptyPreview": "预览将在此处显示",
|
||||
"markQuestion": "标记题目",
|
||||
"markGroup": "标记分组",
|
||||
"markGroup": "标记大题",
|
||||
"markSection": "标记分卷",
|
||||
"markDotted": "加点字",
|
||||
"markBlank": "填空",
|
||||
"insertImage": "插入图片",
|
||||
|
||||
Reference in New Issue
Block a user