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:
SpecialX
2026-06-24 13:16:33 +08:00
parent 0c64219cb8
commit 6114607c1e
30 changed files with 3548 additions and 26 deletions

View File

@@ -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)
}
}