feat(exams,homework): add rich text exam editor and scan-based grading
- Add Tiptap-based rich text editor with custom extensions (dotted-mark, blank-node, image-node, group-block, question-block) for exam creation - Add AI auto-marking action to convert pasted exam text to structured editor doc - Add resizable split-panel layout for editor + live preview - Add student scan upload (photo of paper answers) with drag-drop and reorder - Add scan image viewer with zoom/rotate/fullscreen for teachers - Add scan grading view with side-by-side questions and scan images - Add /teacher/exams/new and /teacher/homework/submissions/[id]/scan-grading routes - Fix getScansAction to support both teacher (HOMEWORK_GRADE) and student (HOMEWORK_SUBMIT) permission scopes - Add i18n keys for rich editor, scan upload, and scan grading (zh-CN/en) - Sync architecture diagrams (004/005) with new modules, routes, and deps
This commit is contained in:
@@ -878,4 +878,291 @@ export async function getExamsByGradeIdAction(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 富文本编辑器:AI 自动标记 + 保存草稿
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AutoMarkSchema = z.object({
|
||||
sourceText: z.string().min(1, "试卷文本不能为空"),
|
||||
aiProviderId: z.string().optional(),
|
||||
})
|
||||
|
||||
export interface AutoMarkResult {
|
||||
/** Tiptap JSONContent 文档,可直接载入编辑器 */
|
||||
doc: unknown
|
||||
/** 解析出的标题(如有) */
|
||||
title: string
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 自动标记 —— 将粘贴的试卷文本交给 AI,返回带题目块/分组/填空/加点字标记的 Tiptap JSONContent 文档。
|
||||
* 教师可在编辑器中基于此结果继续微调。
|
||||
*/
|
||||
export async function autoMarkExamAction(
|
||||
prevState: ActionState<AutoMarkResult> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<AutoMarkResult>> {
|
||||
try {
|
||||
await requirePermission(Permissions.EXAM_AI_GENERATE)
|
||||
|
||||
const parsed = AutoMarkSchema.safeParse({
|
||||
sourceText: getStringValue(formData, "sourceText"),
|
||||
aiProviderId: getStringValue(formData, "aiProviderId") || undefined,
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return invalidFormState<AutoMarkResult>(parsed.error, { useFirstMessage: true })
|
||||
}
|
||||
|
||||
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:",
|
||||
"{",
|
||||
' "title": "试卷标题(可选)",',
|
||||
' "sections": [',
|
||||
' { "title": "一、选择题", "questions": [',
|
||||
' { "type": "single_choice", "score": 2, "content": { "text": "题干文本", "options": [{"id":"A","text":"选项A"}], "blanks": [], "dottedTexts": [], "subQuestions": [] } }',
|
||||
" ] }",
|
||||
" ]",
|
||||
"}",
|
||||
"如果没有 sections,可直接返回 { \"questions\": [...] }",
|
||||
"不要输出 ... 或 [...] 等占位符。",
|
||||
].join("\n")
|
||||
|
||||
const { createAiChatCompletion } = await import("@/shared/lib/ai")
|
||||
const { env } = await import("@/env.mjs")
|
||||
const { parseAiResponse } = await import("./ai-pipeline/parse")
|
||||
|
||||
const aiResult = await createAiChatCompletion({
|
||||
model: String(env.AI_MODEL ?? "gpt-4o-mini"),
|
||||
providerId: aiProviderId,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: sourceText },
|
||||
],
|
||||
temperature: 0,
|
||||
maxTokens: 8000,
|
||||
})
|
||||
|
||||
const parsedJson = await parseAiResponse(aiResult.content, aiProviderId)
|
||||
const doc = buildTiptapDocFromAiResponse(parsedJson)
|
||||
const title = extractTitleFromAiResponse(parsedJson)
|
||||
|
||||
return successState({ doc, title }, "AI 自动标记完成")
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<AutoMarkResult>(error.message)
|
||||
}
|
||||
console.error("[autoMarkExamAction]", error instanceof Error ? error.message : String(error))
|
||||
return handleActionError(error)
|
||||
}
|
||||
}
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null
|
||||
|
||||
const extractTitleFromAiResponse = (data: unknown): string => {
|
||||
if (!isRecord(data)) return ""
|
||||
return typeof data.title === "string" ? data.title : ""
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 AI 返回的结构化 JSON 转换为 Tiptap JSONContent 文档。
|
||||
* 支持 sections(分组)和顶层 questions 两种形式。
|
||||
*/
|
||||
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 => {
|
||||
if (!isRecord(q)) return null
|
||||
const type = typeof q.type === "string" ? q.type : "text"
|
||||
const score = typeof q.score === "number" ? q.score : 0
|
||||
const contentNode = isRecord(q.content) ? q.content : {}
|
||||
const text = typeof contentNode.text === "string" ? contentNode.text : ""
|
||||
|
||||
const inner: unknown[] = []
|
||||
// 题干段落(按行拆分)
|
||||
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 }] })
|
||||
}
|
||||
|
||||
// 选项列表
|
||||
const options = Array.isArray(contentNode.options) ? contentNode.options : []
|
||||
if (options.length > 0) {
|
||||
inner.push({
|
||||
type: "orderedList",
|
||||
content: options.map((opt) => {
|
||||
const o = isRecord(opt) ? opt : {}
|
||||
const id = typeof o.id === "string" ? o.id : ""
|
||||
const optText = typeof o.text === "string" ? o.text : ""
|
||||
return {
|
||||
type: "listItem",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: `${id}. ${optText}` }],
|
||||
},
|
||||
],
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
type: "questionBlock",
|
||||
attrs: { questionId: "", type, score },
|
||||
content: inner,
|
||||
}
|
||||
}
|
||||
|
||||
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 : []
|
||||
const children = questions
|
||||
.map(buildQuestionBlock)
|
||||
.filter((b): b is Record<string, unknown> => b !== null)
|
||||
content.push({
|
||||
type: "groupBlock",
|
||||
attrs: { title },
|
||||
content: children,
|
||||
})
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
for (const q of topQuestions) {
|
||||
const block = buildQuestionBlock(q)
|
||||
if (block) content.push(block)
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "doc", content }
|
||||
}
|
||||
|
||||
const RichExamCreateSchema = z.object({
|
||||
title: z.string().min(1, "标题不能为空"),
|
||||
subject: z.string().min(1),
|
||||
grade: z.string().min(1),
|
||||
difficulty: z.coerce.number().int().min(1).max(5),
|
||||
totalScore: z.coerce.number().int().min(1),
|
||||
durationMin: z.coerce.number().int().min(1),
|
||||
scheduledAt: z.string().optional().nullable(),
|
||||
/** Tiptap JSONContent 文档(JSON 字符串) */
|
||||
editorDoc: z.string().min(1, "试卷内容不能为空"),
|
||||
})
|
||||
|
||||
/**
|
||||
* 从富文本编辑器保存试卷草稿。
|
||||
* 将 Tiptap JSONContent 转换为 questions + structure 后持久化。
|
||||
*/
|
||||
export async function createExamFromRichEditorAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.EXAM_CREATE)
|
||||
|
||||
const parsed = RichExamCreateSchema.safeParse({
|
||||
title: getStringValue(formData, "title"),
|
||||
subject: getStringValue(formData, "subject"),
|
||||
grade: getStringValue(formData, "grade"),
|
||||
difficulty: getStringValue(formData, "difficulty"),
|
||||
totalScore: getStringValue(formData, "totalScore"),
|
||||
durationMin: getStringValue(formData, "durationMin"),
|
||||
scheduledAt: getStringValue(formData, "scheduledAt") ?? null,
|
||||
editorDoc: getStringValue(formData, "editorDoc"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return invalidFormState<string>(parsed.error, { useFirstMessage: true })
|
||||
}
|
||||
|
||||
const input = parsed.data
|
||||
const editorDoc = safeJsonParse(input.editorDoc, "试卷内容格式无效")
|
||||
if (!editorDoc) {
|
||||
return failState<string>("试卷内容解析失败")
|
||||
}
|
||||
|
||||
const context = await prepareExamCreateContext({
|
||||
subject: input.subject,
|
||||
grade: input.grade,
|
||||
difficulty: input.difficulty,
|
||||
totalScore: input.totalScore,
|
||||
durationMin: input.durationMin,
|
||||
scheduledAt: input.scheduledAt,
|
||||
})
|
||||
|
||||
// 将 Tiptap doc 转换为 EditorDoc 结构
|
||||
const { editorDocToStructure } = await import("./editor/editor-to-structure")
|
||||
const structure = editorDocToStructure(editorDoc as never, input.title)
|
||||
|
||||
// 转换为 AI 生成格式以复用 persistAiGeneratedExamDraft
|
||||
const generated = structure.questions.map((q) => ({
|
||||
id: q.id,
|
||||
type: q.type as "single_choice" | "multiple_choice" | "text" | "judgment",
|
||||
difficulty: input.difficulty,
|
||||
score: q.score,
|
||||
content: q.content as never,
|
||||
}))
|
||||
|
||||
const aiStructure = structure.structure.map((node) => {
|
||||
if (node.type === "group") {
|
||||
return {
|
||||
id: node.id,
|
||||
type: "group" as const,
|
||||
title: node.title ?? "",
|
||||
children: (node.children ?? []).map((c) => ({
|
||||
id: c.id,
|
||||
type: "question" as const,
|
||||
questionId: c.questionId ?? "",
|
||||
score: c.score ?? 0,
|
||||
})),
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: node.id,
|
||||
type: "question" as const,
|
||||
questionId: node.questionId ?? "",
|
||||
score: node.score ?? 0,
|
||||
}
|
||||
})
|
||||
|
||||
await persistAiGeneratedExamDraft({
|
||||
examId: context.examId,
|
||||
title: input.title,
|
||||
creatorId: ctx.userId,
|
||||
subjectId: input.subject,
|
||||
gradeId: input.grade,
|
||||
scheduledAt: context.scheduled,
|
||||
description: context.buildDescription(),
|
||||
structure: aiStructure,
|
||||
generated,
|
||||
examModeConfig: parseExamModeConfig(formData),
|
||||
})
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
return successState(context.examId, "试卷草稿已创建")
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<string>(error.message)
|
||||
}
|
||||
console.error("[createExamFromRichEditorAction]", error instanceof Error ? error.message : String(error))
|
||||
return handleActionError(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user