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 { sourceText, aiProviderId } = parsed.data
const systemPrompt = [ const systemPrompt = [
"你是一个试卷结构解析引擎。", "你是一个试卷结构解析引擎,专门解析中国中小学试卷。",
"将给定的试卷文本解析为结构化 JSON,用于在富文本编辑器中渲染为可编辑的题目块。", "将给定的试卷文本解析为结构化 JSON,用于在富文本编辑器中渲染为可编辑的题目块。",
"识别以下元素:", "",
"1. 大题分组(如\"一、选择题\"\"二、填空题\"),输出到 sections", "## 识别规则",
"2. 每道题目,标注 type(single_choice/multiple_choice/judgment/text/composite)和 score", "",
"3. 选项列表(A. B. C. D. 等),输出到 content.options", "1. **大题分组**:识别\"一、选择题\"\"二、填空题\"\"三、阅读理解\"等大题标题,输出到 sections。每个 section 含 title 和 questions",
"4. 填空位置(如\"_______\"或横线空),在 content.blanks 中标记", "2. **题型识别**:",
"5. 加点字(拼音注音题中加点的字),在 content.dottedTexts 中列出加点的原文片段", " - 选择题(单选/多选):题干 + A/B/C/D 选项 → type: \"single_choice\" 或 \"multiple_choice\"",
"6. 子题(如\"1. 2. 3.\"小题),输出到 content.subQuestions", " - 判断题:题干 + 对/错 → type: \"judgment\"",
"输出 JSON,不要输出 markdown 代码块。", " - 填空题:题干含横线空(_______或( )) → type: \"text\",在 blanks 中标记空数",
"输出 schema:", " - 简答/作文题:题干 + \"不少于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": [', ' "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") ].join("\n")
const { createAiChatCompletion } = await import("@/shared/lib/ai") const { createAiChatCompletion } = await import("@/shared/lib/ai")
@@ -976,6 +1006,52 @@ const extractTitleFromAiResponse = (data: unknown): string => {
return typeof data.title === "string" ? data.title : "" 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 文档。 * 将 AI 返回的结构化 JSON 转换为 Tiptap JSONContent 文档。
* 支持 sections(分组)和顶层 questions 两种形式。 * 支持 sections(分组)和顶层 questions 两种形式。
@@ -995,11 +1071,49 @@ const buildTiptapDocFromAiResponse = (data: unknown): unknown => {
const text = typeof contentNode.text === "string" ? contentNode.text : "" const text = typeof contentNode.text === "string" ? contentNode.text : ""
const inner: unknown[] = [] 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) const lines = text.split("\n").filter((l) => l.trim().length > 0)
for (const line of lines) { for (const line of lines) {
// 如果有加点字,在文本中标记对应片段
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 }] }) inner.push({ type: "paragraph", content: [{ type: "text", text: line }] })
} }
}
// 选项列表 // 选项列表
const options = Array.isArray(contentNode.options) ? contentNode.options : [] const options = Array.isArray(contentNode.options) ? contentNode.options : []
@@ -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 { return {
type: "questionBlock", type: "questionBlock",
attrs: { questionId: "", type, score }, attrs: { questionId: "", type, score },
content: inner, content: innerContent,
} }
} }
@@ -1037,10 +1172,15 @@ const buildTiptapDocFromAiResponse = (data: unknown): unknown => {
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 =
children.length > 0
? children
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
content.push({ content.push({
type: "groupBlock", type: "groupBlock",
attrs: { title }, attrs: { title },
content: children, content: groupContent,
}) })
} }

View File

@@ -7,9 +7,7 @@ import { toast } from "sonner"
import { Sparkles, Save, FileText } from "lucide-react" import { Sparkles, Save, FileText } from "lucide-react"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Input } from "@/shared/components/ui/input" import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -35,11 +33,11 @@ import {
} from "../editor" } from "../editor"
const DIFFICULTY_OPTIONS = [ const DIFFICULTY_OPTIONS = [
{ value: "1", label: "Level 1 (Easy)" }, { value: "1", label: "1级 (简单)" },
{ value: "2", label: "Level 2" }, { value: "2", label: "2" },
{ value: "3", label: "Level 3 (Medium)" }, { value: "3", label: "3级 (中等)" },
{ value: "4", label: "Level 4" }, { value: "4", label: "4" },
{ value: "5", label: "Level 5 (Hard)" }, { value: "5", label: "5级 (困难)" },
] ]
interface ExamRichFormValues { interface ExamRichFormValues {
@@ -74,7 +72,6 @@ export function ExamRichForm() {
scheduledAt: "", scheduledAt: "",
}) })
const [sourceText, setSourceText] = useState("")
const [editorDoc, setEditorDoc] = useState<EditorJSONContent | null>(null) const [editorDoc, setEditorDoc] = useState<EditorJSONContent | null>(null)
useEffect(() => { useEffect(() => {
@@ -109,14 +106,19 @@ export function ExamRichForm() {
void fetchMetadata() void fetchMetadata()
}, [t]) }, [t])
/**
* AI 自动标记 —— 从编辑器当前内容获取文本,交给 AI 解析后重新载入。
* 用户可直接在编辑器中粘贴试卷文本,然后点击此按钮让 AI 自动标记题目结构。
*/
const handleAutoMark = () => { const handleAutoMark = () => {
if (!sourceText.trim()) { const currentText = editorRef.current?.getText() ?? ""
toast.error(t("exam.richEditor.pasteSourceFirst")) if (!currentText.trim()) {
toast.error(t("exam.richEditor.emptyEditor"))
return return
} }
startMarking(async () => { startMarking(async () => {
const formData = new FormData() const formData = new FormData()
formData.append("sourceText", sourceText) formData.append("sourceText", currentText)
const result = await autoMarkExamAction(null, formData) const result = await autoMarkExamAction(null, formData)
if (result.success && result.data) { if (result.success && result.data) {
const doc = result.data.doc as EditorJSONContent const doc = result.data.doc as EditorJSONContent
@@ -179,31 +181,22 @@ export function ExamRichForm() {
: null : null
return ( return (
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-4">
<Card> {/* 顶部工具栏:基本信息(单行) + AI 自动标记 + 保存 */}
<CardHeader> <div className="flex flex-wrap items-center gap-3 rounded-md border bg-card p-3">
<CardTitle>{t("exam.richEditor.basicInfo")}</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="title">{t("exam.form.title")}</Label>
<Input <Input
id="title" placeholder={t("exam.richEditor.titlePlaceholder")}
placeholder="例如:2024-2025学年度下期期末学业质量监测"
value={values.title} value={values.title}
onChange={(e) => setValues((v) => ({ ...v, title: e.target.value }))} onChange={(e) => setValues((v) => ({ ...v, title: e.target.value }))}
className="h-9 min-w-[200px] flex-1"
/> />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label>{t("exam.form.subject")}</Label>
<Select <Select
value={values.subject} value={values.subject}
onValueChange={(val) => setValues((v) => ({ ...v, subject: val }))} onValueChange={(val) => setValues((v) => ({ ...v, subject: val }))}
disabled={loadingSubjects} disabled={loadingSubjects}
> >
<SelectTrigger> <SelectTrigger className="h-9 w-[120px]">
<SelectValue placeholder={loadingSubjects ? t("exam.richEditor.loadingScans") : t("exam.form.subject")} /> <SelectValue placeholder={loadingSubjects ? "..." : t("exam.form.subject")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{subjects.map((s) => ( {subjects.map((s) => (
@@ -213,16 +206,13 @@ export function ExamRichForm() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div>
<div className="grid gap-2">
<Label>{t("exam.form.grade")}</Label>
<Select <Select
value={values.grade} value={values.grade}
onValueChange={(val) => setValues((v) => ({ ...v, grade: val }))} onValueChange={(val) => setValues((v) => ({ ...v, grade: val }))}
disabled={loadingGrades} disabled={loadingGrades}
> >
<SelectTrigger> <SelectTrigger className="h-9 w-[120px]">
<SelectValue placeholder={loadingGrades ? t("exam.richEditor.loadingScans") : t("exam.form.grade")} /> <SelectValue placeholder={loadingGrades ? "..." : t("exam.form.grade")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{grades.map((g) => ( {grades.map((g) => (
@@ -232,16 +222,11 @@ export function ExamRichForm() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label>{t("exam.form.difficulty")}</Label>
<Select <Select
value={values.difficulty} value={values.difficulty}
onValueChange={(val) => setValues((v) => ({ ...v, difficulty: val }))} onValueChange={(val) => setValues((v) => ({ ...v, difficulty: val }))}
> >
<SelectTrigger> <SelectTrigger className="h-9 w-[100px]">
<SelectValue placeholder={t("exam.form.difficulty")} /> <SelectValue placeholder={t("exam.form.difficulty")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -252,60 +237,45 @@ export function ExamRichForm() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div>
<div className="grid gap-2">
<Label htmlFor="totalScore">{t("exam.form.totalScore")}</Label>
<Input <Input
id="totalScore"
type="number" type="number"
min={1}
value={String(values.totalScore)} value={String(values.totalScore)}
onChange={(e) => setValues((v) => ({ ...v, totalScore: Number(e.target.value) || 0 }))} onChange={(e) => setValues((v) => ({ ...v, totalScore: Number(e.target.value) || 0 }))}
className="h-9 w-[90px]"
title={t("exam.form.totalScore")}
/> />
</div>
<div className="grid gap-2">
<Label htmlFor="durationMin">{t("exam.form.durationMin")}</Label>
<Input <Input
id="durationMin"
type="number" type="number"
min={10}
value={String(values.durationMin)} value={String(values.durationMin)}
onChange={(e) => setValues((v) => ({ ...v, durationMin: Number(e.target.value) || 0 }))} onChange={(e) => setValues((v) => ({ ...v, durationMin: Number(e.target.value) || 0 }))}
className="h-9 w-[90px]"
title={t("exam.form.durationMin")}
/> />
</div>
</div>
</CardContent>
</Card>
<Card> <div className="ml-auto flex items-center gap-2">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>{t("exam.richEditor.editorArea")}</span>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
onClick={handleAutoMark} onClick={handleAutoMark}
disabled={isMarking || !sourceText.trim()} disabled={isMarking}
className="gap-2" className="gap-2"
title={t("exam.richEditor.aiMarkHint")} title={t("exam.richEditor.aiMarkHint")}
> >
<Sparkles className="h-4 w-4" /> <Sparkles className="h-4 w-4" />
{isMarking ? t("exam.richEditor.aiMarking") : t("exam.richEditor.aiAutoMark")} {isMarking ? t("exam.richEditor.aiMarking") : t("exam.richEditor.aiAutoMark")}
</Button> </Button>
</CardTitle> <Button type="submit" disabled={isPending} size="sm" className="gap-2">
</CardHeader> <Save className="h-4 w-4" />
<CardContent className="space-y-4"> {isPending ? t("exam.richEditor.saving") : t("exam.richEditor.saveDraft")}
<div> </Button>
<Label htmlFor="sourceText">{t("exam.richEditor.sourceText")}</Label> </div>
<textarea
id="sourceText"
value={sourceText}
onChange={(e) => setSourceText(e.target.value)}
placeholder={t("exam.richEditor.sourceTextPlaceholder")}
className="mt-1.5 min-h-[120px] w-full rounded-md border bg-background p-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div> </div>
<div className="h-[600px] rounded-md border"> {/* 编辑器 + 预览(可拖拽分栏) */}
<div className="h-[calc(100vh-180px)] rounded-md border">
<ResizablePanel <ResizablePanel
initialLeft={60} initialLeft={60}
minLeft={30} minLeft={30}
@@ -347,22 +317,6 @@ export function ExamRichForm() {
} }
/> />
</div> </div>
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
>
{t("exam.actions.cancel")}
</Button>
<Button type="submit" disabled={isPending} className="gap-2">
<Save className="h-4 w-4" />
{isPending ? t("exam.richEditor.saving") : t("exam.richEditor.saveDraft")}
</Button>
</div>
</form> </form>
) )
} }

View File

@@ -39,8 +39,12 @@ export interface ExamRichEditorHandle {
getText: () => string getText: () => string
/** 插入题目块 */ /** 插入题目块 */
insertQuestion: (type: QuestionBlockType, score?: number) => void insertQuestion: (type: QuestionBlockType, score?: number) => void
/** 将选区包裹为题目块 */
wrapInQuestion: (type: QuestionBlockType, score?: number) => void
/** 插入大题分组 */ /** 插入大题分组 */
insertGroup: (title?: string) => void insertGroup: (title?: string) => void
/** 将选区包裹为大题分组 */
wrapInGroup: (title?: string) => void
/** 清空编辑器 */ /** 清空编辑器 */
clear: () => void clear: () => void
/** 获取 Editor 实例(高级用法) */ /** 获取 Editor 实例(高级用法) */
@@ -166,9 +170,15 @@ export const ExamRichEditor = forwardRef<ExamRichEditorHandle, ExamRichEditorPro
insertQuestion: (type, score = 2) => { insertQuestion: (type, score = 2) => {
editor?.chain().focus().insertQuestion({ type, score }).run() editor?.chain().focus().insertQuestion({ type, score }).run()
}, },
wrapInQuestion: (type, score = 2) => {
editor?.chain().focus().wrapInQuestion({ type, score }).run()
},
insertGroup: (title) => { insertGroup: (title) => {
editor?.chain().focus().insertGroup(title).run() editor?.chain().focus().insertGroup(title).run()
}, },
wrapInGroup: (title) => {
editor?.chain().focus().wrapInGroup(title).run()
},
clear: () => { clear: () => {
editor?.commands.clearContent(true) editor?.commands.clearContent(true)
}, },

View File

@@ -6,6 +6,8 @@ declare module "@tiptap/core" {
groupBlock: { groupBlock: {
/** 插入大题分组(如"一、选择题") */ /** 插入大题分组(如"一、选择题") */
insertGroup: (title?: string) => ReturnType insertGroup: (title?: string) => ReturnType
/** 将当前选区内容包裹为大题分组 */
wrapInGroup: (title?: string) => ReturnType
} }
} }
} }
@@ -53,7 +55,17 @@ export const GroupBlock = Node.create({
commands.insertContent({ commands.insertContent({
type: "groupBlock", type: "groupBlock",
attrs: { title: title || "" }, attrs: { title: title || "" },
content: [
{
type: "paragraph",
content: [{ type: "text", text: " " }],
},
],
}), }),
wrapInGroup:
(title) =>
({ commands }) =>
commands.wrapIn("groupBlock", { title: title || "" }),
} }
}, },
}) })

View File

@@ -19,6 +19,8 @@ declare module "@tiptap/core" {
questionBlock: { questionBlock: {
/** 插入题目块(含题型/分值/题干) */ /** 插入题目块(含题型/分值/题干) */
insertQuestion: (attrs?: Partial<QuestionBlockAttrs>) => ReturnType insertQuestion: (attrs?: Partial<QuestionBlockAttrs>) => ReturnType
/** 将当前选区内容包裹为题目块 */
wrapInQuestion: (attrs?: Partial<QuestionBlockAttrs>) => ReturnType
} }
} }
} }
@@ -91,6 +93,20 @@ export const QuestionBlock = Node.create({
score: attrs?.score ?? 0, score: attrs?.score ?? 0,
questionId: attrs?.questionId ?? "", questionId: attrs?.questionId ?? "",
}, },
content: [
{
type: "paragraph",
content: [{ type: "text", text: " " }],
},
],
}),
wrapInQuestion:
(attrs) =>
({ commands }) =>
commands.wrapIn("questionBlock", {
type: attrs?.type ?? "single_choice",
score: attrs?.score ?? 0,
questionId: attrs?.questionId ?? "",
}), }),
} }
}, },

View File

@@ -120,12 +120,27 @@ export function SelectionToolbar({
if (!editor || !hasSelection || !coords) return null if (!editor || !hasSelection || !coords) return null
const { from, to, empty } = editor.state.selection
const hasTextSelection = !empty && from !== to
const insertQuestion = (type: QuestionBlockType) => { const insertQuestion = (type: QuestionBlockType) => {
editor.chain().focus().insertQuestion({ type, score: type === "composite" ? 5 : 2 }).run() const chain = editor.chain().focus()
if (hasTextSelection) {
// 选中文本时:包裹为题目块
chain.wrapInQuestion({ type, score: type === "composite" ? 5 : 2 }).run()
} else {
// 未选中文本时:插入空题目块
chain.insertQuestion({ type, score: type === "composite" ? 5 : 2 }).run()
}
} }
const insertGroup = () => { const insertGroup = () => {
editor.chain().focus().insertGroup("一、选择题").run() const chain = editor.chain().focus()
if (hasTextSelection) {
chain.wrapInGroup("一、选择题").run()
} else {
chain.insertGroup("一、选择题").run()
}
} }
const toggleDotted = () => { const toggleDotted = () => {

View File

@@ -159,8 +159,9 @@
"aiMarking": "AI marking...", "aiMarking": "AI marking...",
"aiMarkSuccess": "AI auto-marking complete", "aiMarkSuccess": "AI auto-marking complete",
"aiMarkFailed": "AI auto-marking failed", "aiMarkFailed": "AI auto-marking failed",
"aiMarkHint": "After pasting exam text, click this button to let AI auto-detect question structure", "aiMarkHint": "After pasting exam text in the editor, click this button to let AI auto-detect question structure",
"editorArea": "Editor", "titlePlaceholder": "Exam title (e.g. 2024-2025 Final Academic Quality Assessment)",
"editorArea": "Editor (paste exam text, select text to mark questions/groups/dotted/blanks)",
"previewArea": "Preview", "previewArea": "Preview",
"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",

View File

@@ -159,8 +159,9 @@
"aiMarking": "AI 标记中...", "aiMarking": "AI 标记中...",
"aiMarkSuccess": "AI 自动标记完成", "aiMarkSuccess": "AI 自动标记完成",
"aiMarkFailed": "AI 自动标记失败", "aiMarkFailed": "AI 自动标记失败",
"aiMarkHint": "粘贴试卷文本后,点击此按钮让 AI 自动识别题目结构", "aiMarkHint": "在编辑器中粘贴试卷文本后,点击此按钮让 AI 自动识别题目结构",
"editorArea": "编辑区", "titlePlaceholder": "试卷标题(如:2024-2025学年度下期期末学业质量监测)",
"editorArea": "编辑区(粘贴试卷文本,选中文本标记题目/分组/加点字/填空)",
"previewArea": "预览区", "previewArea": "预览区",
"emptyEditor": "请在左侧编辑区输入或粘贴试卷内容", "emptyEditor": "请在左侧编辑区输入或粘贴试卷内容",
"emptyPreview": "预览将在此处显示", "emptyPreview": "预览将在此处显示",