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:
@@ -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,11 +1071,49 @@ 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) {
|
||||
// 如果有加点字,在文本中标记对应片段
|
||||
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 }] })
|
||||
}
|
||||
}
|
||||
|
||||
// 选项列表
|
||||
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 {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,7 @@ import { toast } from "sonner"
|
||||
import { Sparkles, Save, FileText } from "lucide-react"
|
||||
|
||||
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 { Label } from "@/shared/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -35,11 +33,11 @@ import {
|
||||
} from "../editor"
|
||||
|
||||
const DIFFICULTY_OPTIONS = [
|
||||
{ value: "1", label: "Level 1 (Easy)" },
|
||||
{ value: "2", label: "Level 2" },
|
||||
{ value: "3", label: "Level 3 (Medium)" },
|
||||
{ value: "4", label: "Level 4" },
|
||||
{ value: "5", label: "Level 5 (Hard)" },
|
||||
{ value: "1", label: "1级 (简单)" },
|
||||
{ value: "2", label: "2级" },
|
||||
{ value: "3", label: "3级 (中等)" },
|
||||
{ value: "4", label: "4级" },
|
||||
{ value: "5", label: "5级 (困难)" },
|
||||
]
|
||||
|
||||
interface ExamRichFormValues {
|
||||
@@ -74,7 +72,6 @@ export function ExamRichForm() {
|
||||
scheduledAt: "",
|
||||
})
|
||||
|
||||
const [sourceText, setSourceText] = useState("")
|
||||
const [editorDoc, setEditorDoc] = useState<EditorJSONContent | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -109,14 +106,19 @@ export function ExamRichForm() {
|
||||
void fetchMetadata()
|
||||
}, [t])
|
||||
|
||||
/**
|
||||
* AI 自动标记 —— 从编辑器当前内容获取文本,交给 AI 解析后重新载入。
|
||||
* 用户可直接在编辑器中粘贴试卷文本,然后点击此按钮让 AI 自动标记题目结构。
|
||||
*/
|
||||
const handleAutoMark = () => {
|
||||
if (!sourceText.trim()) {
|
||||
toast.error(t("exam.richEditor.pasteSourceFirst"))
|
||||
const currentText = editorRef.current?.getText() ?? ""
|
||||
if (!currentText.trim()) {
|
||||
toast.error(t("exam.richEditor.emptyEditor"))
|
||||
return
|
||||
}
|
||||
startMarking(async () => {
|
||||
const formData = new FormData()
|
||||
formData.append("sourceText", sourceText)
|
||||
formData.append("sourceText", currentText)
|
||||
const result = await autoMarkExamAction(null, formData)
|
||||
if (result.success && result.data) {
|
||||
const doc = result.data.doc as EditorJSONContent
|
||||
@@ -179,31 +181,22 @@ export function ExamRichForm() {
|
||||
: null
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<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>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* 顶部工具栏:基本信息(单行) + AI 自动标记 + 保存 */}
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-md border bg-card p-3">
|
||||
<Input
|
||||
id="title"
|
||||
placeholder="例如:2024-2025学年度下期期末学业质量监测"
|
||||
placeholder={t("exam.richEditor.titlePlaceholder")}
|
||||
value={values.title}
|
||||
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
|
||||
value={values.subject}
|
||||
onValueChange={(val) => setValues((v) => ({ ...v, subject: val }))}
|
||||
disabled={loadingSubjects}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loadingSubjects ? t("exam.richEditor.loadingScans") : t("exam.form.subject")} />
|
||||
<SelectTrigger className="h-9 w-[120px]">
|
||||
<SelectValue placeholder={loadingSubjects ? "..." : t("exam.form.subject")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{subjects.map((s) => (
|
||||
@@ -213,16 +206,13 @@ export function ExamRichForm() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("exam.form.grade")}</Label>
|
||||
<Select
|
||||
value={values.grade}
|
||||
onValueChange={(val) => setValues((v) => ({ ...v, grade: val }))}
|
||||
disabled={loadingGrades}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loadingGrades ? t("exam.richEditor.loadingScans") : t("exam.form.grade")} />
|
||||
<SelectTrigger className="h-9 w-[120px]">
|
||||
<SelectValue placeholder={loadingGrades ? "..." : t("exam.form.grade")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{grades.map((g) => (
|
||||
@@ -232,16 +222,11 @@ export function ExamRichForm() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</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
|
||||
value={values.difficulty}
|
||||
onValueChange={(val) => setValues((v) => ({ ...v, difficulty: val }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="h-9 w-[100px]">
|
||||
<SelectValue placeholder={t("exam.form.difficulty")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -252,60 +237,45 @@ export function ExamRichForm() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="totalScore">{t("exam.form.totalScore")}</Label>
|
||||
<Input
|
||||
id="totalScore"
|
||||
type="number"
|
||||
min={1}
|
||||
value={String(values.totalScore)}
|
||||
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
|
||||
id="durationMin"
|
||||
type="number"
|
||||
min={10}
|
||||
value={String(values.durationMin)}
|
||||
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>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>{t("exam.richEditor.editorArea")}</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAutoMark}
|
||||
disabled={isMarking || !sourceText.trim()}
|
||||
disabled={isMarking}
|
||||
className="gap-2"
|
||||
title={t("exam.richEditor.aiMarkHint")}
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{isMarking ? t("exam.richEditor.aiMarking") : t("exam.richEditor.aiAutoMark")}
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="sourceText">{t("exam.richEditor.sourceText")}</Label>
|
||||
<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"
|
||||
/>
|
||||
<Button type="submit" disabled={isPending} size="sm" className="gap-2">
|
||||
<Save className="h-4 w-4" />
|
||||
{isPending ? t("exam.richEditor.saving") : t("exam.richEditor.saveDraft")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-[600px] rounded-md border">
|
||||
{/* 编辑器 + 预览(可拖拽分栏) */}
|
||||
<div className="h-[calc(100vh-180px)] rounded-md border">
|
||||
<ResizablePanel
|
||||
initialLeft={60}
|
||||
minLeft={30}
|
||||
@@ -347,22 +317,6 @@ export function ExamRichForm() {
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,8 +39,12 @@ export interface ExamRichEditorHandle {
|
||||
getText: () => string
|
||||
/** 插入题目块 */
|
||||
insertQuestion: (type: QuestionBlockType, score?: number) => void
|
||||
/** 将选区包裹为题目块 */
|
||||
wrapInQuestion: (type: QuestionBlockType, score?: number) => void
|
||||
/** 插入大题分组 */
|
||||
insertGroup: (title?: string) => void
|
||||
/** 将选区包裹为大题分组 */
|
||||
wrapInGroup: (title?: string) => void
|
||||
/** 清空编辑器 */
|
||||
clear: () => void
|
||||
/** 获取 Editor 实例(高级用法) */
|
||||
@@ -166,9 +170,15 @@ export const ExamRichEditor = forwardRef<ExamRichEditorHandle, ExamRichEditorPro
|
||||
insertQuestion: (type, score = 2) => {
|
||||
editor?.chain().focus().insertQuestion({ type, score }).run()
|
||||
},
|
||||
wrapInQuestion: (type, score = 2) => {
|
||||
editor?.chain().focus().wrapInQuestion({ type, score }).run()
|
||||
},
|
||||
insertGroup: (title) => {
|
||||
editor?.chain().focus().insertGroup(title).run()
|
||||
},
|
||||
wrapInGroup: (title) => {
|
||||
editor?.chain().focus().wrapInGroup(title).run()
|
||||
},
|
||||
clear: () => {
|
||||
editor?.commands.clearContent(true)
|
||||
},
|
||||
|
||||
@@ -6,6 +6,8 @@ declare module "@tiptap/core" {
|
||||
groupBlock: {
|
||||
/** 插入大题分组(如"一、选择题") */
|
||||
insertGroup: (title?: string) => ReturnType
|
||||
/** 将当前选区内容包裹为大题分组 */
|
||||
wrapInGroup: (title?: string) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,7 +55,17 @@ export const GroupBlock = Node.create({
|
||||
commands.insertContent({
|
||||
type: "groupBlock",
|
||||
attrs: { title: title || "" },
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: " " }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
wrapInGroup:
|
||||
(title) =>
|
||||
({ commands }) =>
|
||||
commands.wrapIn("groupBlock", { title: title || "" }),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -19,6 +19,8 @@ declare module "@tiptap/core" {
|
||||
questionBlock: {
|
||||
/** 插入题目块(含题型/分值/题干) */
|
||||
insertQuestion: (attrs?: Partial<QuestionBlockAttrs>) => ReturnType
|
||||
/** 将当前选区内容包裹为题目块 */
|
||||
wrapInQuestion: (attrs?: Partial<QuestionBlockAttrs>) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,6 +93,20 @@ export const QuestionBlock = Node.create({
|
||||
score: attrs?.score ?? 0,
|
||||
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 ?? "",
|
||||
}),
|
||||
}
|
||||
},
|
||||
|
||||
@@ -120,12 +120,27 @@ export function SelectionToolbar({
|
||||
|
||||
if (!editor || !hasSelection || !coords) return null
|
||||
|
||||
const { from, to, empty } = editor.state.selection
|
||||
const hasTextSelection = !empty && from !== to
|
||||
|
||||
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 = () => {
|
||||
editor.chain().focus().insertGroup("一、选择题").run()
|
||||
const chain = editor.chain().focus()
|
||||
if (hasTextSelection) {
|
||||
chain.wrapInGroup("一、选择题").run()
|
||||
} else {
|
||||
chain.insertGroup("一、选择题").run()
|
||||
}
|
||||
}
|
||||
|
||||
const toggleDotted = () => {
|
||||
|
||||
@@ -159,8 +159,9 @@
|
||||
"aiMarking": "AI marking...",
|
||||
"aiMarkSuccess": "AI auto-marking complete",
|
||||
"aiMarkFailed": "AI auto-marking failed",
|
||||
"aiMarkHint": "After pasting exam text, click this button to let AI auto-detect question structure",
|
||||
"editorArea": "Editor",
|
||||
"aiMarkHint": "After pasting exam text in the editor, click this button to let AI auto-detect question structure",
|
||||
"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",
|
||||
"emptyEditor": "Enter or paste exam content in the editor on the left",
|
||||
"emptyPreview": "Preview will appear here",
|
||||
|
||||
@@ -159,8 +159,9 @@
|
||||
"aiMarking": "AI 标记中...",
|
||||
"aiMarkSuccess": "AI 自动标记完成",
|
||||
"aiMarkFailed": "AI 自动标记失败",
|
||||
"aiMarkHint": "粘贴试卷文本后,点击此按钮让 AI 自动识别题目结构",
|
||||
"editorArea": "编辑区",
|
||||
"aiMarkHint": "在编辑器中粘贴试卷文本后,点击此按钮让 AI 自动识别题目结构",
|
||||
"titlePlaceholder": "试卷标题(如:2024-2025学年度下期期末学业质量监测)",
|
||||
"editorArea": "编辑区(粘贴试卷文本,选中文本标记题目/分组/加点字/填空)",
|
||||
"previewArea": "预览区",
|
||||
"emptyEditor": "请在左侧编辑区输入或粘贴试卷内容",
|
||||
"emptyPreview": "预览将在此处显示",
|
||||
|
||||
Reference in New Issue
Block a user