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,28 +921,34 @@ 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": "第Ⅰ卷 选择题",',
|
||||||
|
' "groups": [',
|
||||||
|
" {",
|
||||||
|
' "title": "一、选择正确答案的番号填涂在答题卡上",',
|
||||||
|
' "instruction": "每小题3分,共24分",',
|
||||||
' "questions": [',
|
' "questions": [',
|
||||||
" {",
|
" {",
|
||||||
' "type": "single_choice",',
|
' "type": "single_choice",',
|
||||||
@@ -959,14 +965,18 @@ export async function autoMarkExamAction(
|
|||||||
" }",
|
" }",
|
||||||
" ]",
|
" ]",
|
||||||
" }",
|
" }",
|
||||||
|
" ],",
|
||||||
|
' "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)
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 的子题) */
|
||||||
|
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">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={(node.attrs.title as string) || ""}
|
value={title}
|
||||||
onChange={(e) => updateAttributes({ title: e.target.value })}
|
onChange={(e) => updateAttributes({ title: e.target.value })}
|
||||||
placeholder="大题标题(如:一、选择题)"
|
placeholder="大题标题(如:一、选择题)"
|
||||||
className="w-full bg-transparent text-base font-semibold focus:outline-none"
|
className="flex-1 bg-transparent text-base font-semibold focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<NodeViewContent className="mt-2 block" />
|
<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>
|
</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 || "",
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
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,
|
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")}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -166,7 +166,8 @@
|
|||||||
"emptyEditor": "请在左侧编辑区输入或粘贴试卷内容",
|
"emptyEditor": "请在左侧编辑区输入或粘贴试卷内容",
|
||||||
"emptyPreview": "预览将在此处显示",
|
"emptyPreview": "预览将在此处显示",
|
||||||
"markQuestion": "标记题目",
|
"markQuestion": "标记题目",
|
||||||
"markGroup": "标记分组",
|
"markGroup": "标记大题",
|
||||||
|
"markSection": "标记分卷",
|
||||||
"markDotted": "加点字",
|
"markDotted": "加点字",
|
||||||
"markBlank": "填空",
|
"markBlank": "填空",
|
||||||
"insertImage": "插入图片",
|
"insertImage": "插入图片",
|
||||||
|
|||||||
Reference in New Issue
Block a user