fix(exams): fix rich editor crashes and redesign form layout

- Fix groupBlock/questionBlock insertContent error: empty content violates
  schema "block+", now provide default empty paragraph
- Fix buildTiptapDocFromAiResponse: ensure groupBlock/questionBlock always
  have content (fallback to empty paragraph)
- Add wrapInGroup/wrapInQuestion commands to wrap selected text into
  question/group blocks (vs insert which creates empty blocks)
- Update SelectionToolbar: use wrap when text selected, insert when not
- Redesign exam-rich-form layout:
  - Merge basic info into single-row toolbar (title/subject/grade/difficulty/score/duration)
  - Remove separate "source text" textarea (user pastes directly in editor)
  - AI auto-mark now reads from editor content via getText()
  - Editor + preview takes full height (calc(100vh-180px))
- Enhance AI prompt for Chinese exam papers:
  - Support reading material (阅读理解选段)
  - Support dotted chars (加点字注音)
  - Support sub-questions (阅读理解小题)
  - Better type detection (single/multiple choice, judgment, fill, essay, composite)
- Add splitByDottedTexts helper to mark dotted chars in Tiptap doc
- Add i18n keys for titlePlaceholder, editorArea description
This commit is contained in:
SpecialX
2026-06-24 13:41:39 +08:00
parent d1e4ccbf98
commit 7380f1e6c8
8 changed files with 368 additions and 219 deletions

View File

@@ -916,27 +916,57 @@ export async function autoMarkExamAction(
const { sourceText, aiProviderId } = parsed.data
const systemPrompt = [
"你是一个试卷结构解析引擎。",
"你是一个试卷结构解析引擎,专门解析中国中小学试卷。",
"将给定的试卷文本解析为结构化 JSON,用于在富文本编辑器中渲染为可编辑的题目块。",
"识别以下元素:",
"1. 大题分组(如\"一、选择题\"\"二、填空题\"),输出到 sections",
"2. 每道题目,标注 type(single_choice/multiple_choice/judgment/text/composite)和 score",
"3. 选项列表(A. B. C. D. 等),输出到 content.options",
"4. 填空位置(如\"_______\"或横线空),在 content.blanks 中标记",
"5. 加点字(拼音注音题中加点的字),在 content.dottedTexts 中列出加点的原文片段",
"6. 子题(如\"1. 2. 3.\"小题),输出到 content.subQuestions",
"输出 JSON,不要输出 markdown 代码块。",
"输出 schema:",
"",
"## 识别规则",
"",
"1. **大题分组**:识别\"一、选择题\"\"二、填空题\"\"三、阅读理解\"等大题标题,输出到 sections。每个 section 含 title 和 questions",
"2. **题型识别**:",
" - 选择题(单选/多选):题干 + 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 字段。",
"",
"## 输出 JSON schema",
"",
"```json",
"{",
' "title": "试卷标题(可选)",',
' "title": "试卷标题",',
' "sections": [',
' { "title": "一、选择题", "questions": [',
' { "type": "single_choice", "score": 2, "content": { "text": "题干文本", "options": [{"id":"A","text":"选项A"}], "blanks": [], "dottedTexts": [], "subQuestions": [] } }',
" ] }",
" {",
' "title": "一、选择题",',
' "questions": [',
" {",
' "type": "single_choice",',
' "score": 3,',
' "content": {',
' "text": "下面加点字的注音全部正确的一项是",',
' "options": [{"id":"A","text":"解剖(pō) 蹭饭(cènɡ)"},{"id":"B","text":"栖息(qī) 譬如(pì)"}],',
' "blanks": [],',
' "dottedTexts": ["解","蹭","徉"],',
' "subQuestions": []',
" }",
" }",
" ]",
" }",
" ]",
"}",
"如果没有 sections,可直接返回 { \"questions\": [...] }",
"不要输出 ... 或 [...] 等占位符。",
"```",
"",
"## 注意事项",
"- 不要输出 markdown 代码块标记(```),直接输出 JSON。",
"- 不要输出 ... 或 [...] 等占位符,必须输出完整数据。",
"- 如果没有 sections,可直接返回 { \"questions\": [...] }。",
"- 阅读理解的选段文本放到 content.readingMaterial,不要混入题干 text。",
"- 作文题的\"不少于300字\"等要求保留在 text 中。",
].join("\n")
const { createAiChatCompletion } = await import("@/shared/lib/ai")
@@ -976,6 +1006,52 @@ const extractTitleFromAiResponse = (data: unknown): string => {
return typeof data.title === "string" ? data.title : ""
}
/**
* 将文本按加点字片段切分,返回带 dotted 标记的片段数组。
* 用于在 Tiptap 文档中标记加点字(下加点)。
*/
const splitByDottedTexts = (
text: string,
dottedTexts: string[]
): Array<{ text: string; dotted: boolean }> => {
if (dottedTexts.length === 0) return [{ text, dotted: false }]
const result: Array<{ text: string; dotted: boolean }> = []
let remaining = text
while (remaining.length > 0) {
// 找到最早出现的加点字
let earliestIdx = -1
let earliestText = ""
for (const dt of dottedTexts) {
if (!dt) continue
const idx = remaining.indexOf(dt)
if (idx >= 0 && (earliestIdx === -1 || idx < earliestIdx)) {
earliestIdx = idx
earliestText = dt
}
}
if (earliestIdx === -1) {
// 没有更多加点字,剩余文本作为普通片段
if (remaining.length > 0) {
result.push({ text: remaining, dotted: false })
}
break
}
// 加点字之前的普通文本
if (earliestIdx > 0) {
result.push({ text: remaining.slice(0, earliestIdx), dotted: false })
}
// 加点字片段
result.push({ text: earliestText, dotted: true })
remaining = remaining.slice(earliestIdx + earliestText.length)
}
return result
}
/**
* 将 AI 返回的结构化 JSON 转换为 Tiptap JSONContent 文档。
* 支持 sections(分组)和顶层 questions 两种形式。
@@ -995,10 +1071,48 @@ const buildTiptapDocFromAiResponse = (data: unknown): unknown => {
const text = typeof contentNode.text === "string" ? contentNode.text : ""
const inner: unknown[] = []
// 题干段落(按行拆分)
// 阅读材料(阅读理解题型的选段)
const readingMaterial =
typeof contentNode.readingMaterial === "string"
? contentNode.readingMaterial
: ""
if (readingMaterial) {
const materialLines = readingMaterial
.split("\n")
.filter((l) => l.trim().length > 0)
for (const line of materialLines) {
inner.push({
type: "paragraph",
content: [{ type: "text", text: line, marks: [{ type: "italic" }] }],
})
}
}
// 题干段落(按行拆分),处理加点字标记
const dottedTexts = Array.isArray(contentNode.dottedTexts)
? (contentNode.dottedTexts as unknown[])
.filter((s): s is string => typeof s === "string")
: []
const lines = text.split("\n").filter((l) => l.trim().length > 0)
for (const line of lines) {
inner.push({ type: "paragraph", content: [{ type: "text", text: line }] })
// 如果有加点字,在文本中标记对应片段
if (dottedTexts.length > 0) {
const segments = splitByDottedTexts(line, dottedTexts)
const textNodes = segments.map((seg) =>
seg.dotted
? {
type: "text",
text: seg.text,
marks: [{ type: "dotted" }],
}
: { type: "text", text: seg.text }
)
inner.push({ type: "paragraph", content: textNodes })
} else {
inner.push({ type: "paragraph", content: [{ type: "text", text: line }] })
}
}
// 选项列表
@@ -1023,10 +1137,31 @@ const buildTiptapDocFromAiResponse = (data: unknown): unknown => {
})
}
// 子题(阅读理解的小题)
const subQuestions = Array.isArray(contentNode.subQuestions)
? contentNode.subQuestions
: []
for (const sub of subQuestions) {
if (!isRecord(sub)) continue
const subText = typeof sub.text === "string" ? sub.text : ""
if (subText) {
inner.push({
type: "paragraph",
content: [{ type: "text", text: subText }],
})
}
}
// questionBlock 要求 content: "block+",无内容时给空段落
const innerContent =
inner.length > 0
? inner
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
return {
type: "questionBlock",
attrs: { questionId: "", type, score },
content: inner,
content: innerContent,
}
}
@@ -1037,10 +1172,15 @@ const buildTiptapDocFromAiResponse = (data: unknown): unknown => {
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({
type: "groupBlock",
attrs: { title },
content: children,
content: groupContent,
})
}