diff --git a/src/modules/exams/actions.ts b/src/modules/exams/actions.ts index 69e3391..94f92d2 100644 --- a/src/modules/exams/actions.ts +++ b/src/modules/exams/actions.ts @@ -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 => 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 => 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) diff --git a/src/modules/exams/components/exam-rich-form.tsx b/src/modules/exams/components/exam-rich-form.tsx index fccbd29..33a7259 100644 --- a/src/modules/exams/components/exam-rich-form.tsx +++ b/src/modules/exams/components/exam-rich-form.tsx @@ -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 (
-

+

+ {node.title || (level === 1 ? "分卷" : "部分")} +

+ {node.questionCount !== undefined && node.totalScore !== undefined && ( + + (共 {node.questionCount} 题,{node.totalScore} 分) + )} - > - {node.title || "大题"} - -
+
+
{(node.children ?? []).map((child) => renderNode(child, depth + 1))}
) } + + // 大题分组(一、选择题)—— 显示标题 + 说明 + 自动统计 + if (node.type === "group") { + return ( +
+
+

+ {node.title || "大题"} +

+ {node.questionCount !== undefined && node.totalScore !== undefined && ( + + (共 {node.questionCount} 题,{node.totalScore} 分) + + )} +
+ {node.instruction && ( +

+ {node.instruction} +

+ )} +
+ {(node.children ?? []).map((child) => renderNode(child, depth + 1))} +
+
+ ) + } + + // 题目 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 }) { ))} )} - {/* 复合题子题 */} + {/* 复合题子题(阅读理解小题) */} {isComposite && question.content.subQuestions && question.content.subQuestions.length > 0 && (
{question.content.subQuestions.map((sub, idx) => ( diff --git a/src/modules/exams/editor/editor-to-structure.ts b/src/modules/exams/editor/editor-to-structure.ts index dfb5f43..002f3a3 100644 --- a/src/modules/exams/editor/editor-to-structure.ts +++ b/src/modules/exams/editor/editor-to-structure.ts @@ -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 } diff --git a/src/modules/exams/editor/exam-rich-editor-types.ts b/src/modules/exams/editor/exam-rich-editor-types.ts index 3ed361f..05a5de0 100644 --- a/src/modules/exams/editor/exam-rich-editor-types.ts +++ b/src/modules/exams/editor/exam-rich-editor-types.ts @@ -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[] } diff --git a/src/modules/exams/editor/exam-rich-editor.tsx b/src/modules/exams/editor/exam-rich-editor.tsx index 8e824bf..862e5b3 100644 --- a/src/modules/exams/editor/exam-rich-editor.tsx +++ b/src/modules/exams/editor/exam-rich-editor.tsx @@ -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 { 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) diff --git a/src/modules/exams/editor/extensions/group-block.tsx b/src/modules/exams/editor/extensions/group-block.tsx index c40af9f..942562b 100644 --- a/src/modules/exams/editor/extensions/group-block.tsx +++ b/src/modules/exams/editor/extensions/group-block.tsx @@ -5,29 +5,75 @@ declare module "@tiptap/core" { interface Commands { 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) => ( - - updateAttributes({ title: e.target.value })} - placeholder="大题标题(如:一、选择题)" - className="w-full bg-transparent text-base font-semibold focus:outline-none" - /> - - -) +/** 递归统计节点内的题目数和总分(含嵌套 questionBlock 的子题) */ +const countQuestionsAndScore = (node: { content?: Array<{ type?: string; attrs?: Record; 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 ( + +
+ updateAttributes({ title: e.target.value })} + placeholder="大题标题(如:一、选择题)" + className="flex-1 bg-transparent text-base font-semibold focus:outline-none" + /> + + 共 {stats.count} 题 · {stats.score} 分 + +
+ updateAttributes({ instruction: e.target.value })} + placeholder="说明(如:每小题3分,共24分)—— 可留空,总分自动统计" + className="mb-2 w-full bg-transparent text-xs text-muted-foreground focus:outline-none" + /> + +
+ ) +} /** - * 分组块节点 —— 大题分组,包含标题输入区 + 子内容区。 - * 用于"一、选择题""二、填空题"等大题结构。 + * 大题分组节点 —— 如"一、选择题""二、填空题"。 + * 不是题目,题数和总分由内部子题自动累加。 + * 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 || "", + }), } }, }) diff --git a/src/modules/exams/editor/extensions/index.ts b/src/modules/exams/editor/extensions/index.ts index 8683337..abfa858 100644 --- a/src/modules/exams/editor/extensions/index.ts +++ b/src/modules/exams/editor/extensions/index.ts @@ -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" diff --git a/src/modules/exams/editor/extensions/section-block.tsx b/src/modules/exams/editor/extensions/section-block.tsx new file mode 100644 index 0000000..654cdc0 --- /dev/null +++ b/src/modules/exams/editor/extensions/section-block.tsx @@ -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 { + 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; 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 ( + +
+ updateAttributes({ title: e.target.value })} + placeholder={`分卷标题(如:第Ⅰ卷 选择题)`} + className={`flex-1 bg-transparent font-bold focus:outline-none ${titleSize}`} + /> + + + 共 {stats.count} 题 · {stats.score} 分 + +
+ +
+ ) +} + +/** + * 试卷分卷节点 —— 顶层结构,如"第Ⅰ卷 选择题(共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, + }), + } + }, +}) diff --git a/src/modules/exams/editor/selection-toolbar.tsx b/src/modules/exams/editor/selection-toolbar.tsx index c4ccc86..f48c566 100644 --- a/src/modules/exams/editor/selection-toolbar.tsx +++ b/src/modules/exams/editor/selection-toolbar.tsx @@ -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%)", }} > + insertQuestion("single_choice")} diff --git a/src/shared/i18n/messages/en/exam-homework.json b/src/shared/i18n/messages/en/exam-homework.json index 1fe2a43..bd7e8c0 100644 --- a/src/shared/i18n/messages/en/exam-homework.json +++ b/src/shared/i18n/messages/en/exam-homework.json @@ -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", diff --git a/src/shared/i18n/messages/zh-CN/exam-homework.json b/src/shared/i18n/messages/zh-CN/exam-homework.json index cddd9dd..dac7ede 100644 --- a/src/shared/i18n/messages/zh-CN/exam-homework.json +++ b/src/shared/i18n/messages/zh-CN/exam-homework.json @@ -166,7 +166,8 @@ "emptyEditor": "请在左侧编辑区输入或粘贴试卷内容", "emptyPreview": "预览将在此处显示", "markQuestion": "标记题目", - "markGroup": "标记分组", + "markGroup": "标记大题", + "markSection": "标记分卷", "markDotted": "加点字", "markBlank": "填空", "insertImage": "插入图片",