Distinguish structural levels from questions:
- sectionBlock (new): top-level volume like "第Ⅰ卷 选择题(共24分)"
- groupBlock (enhanced): section like "一、选择题" with instruction field
- questionBlock (composite): reserved for reading comprehension
Key changes:
- New section-block.tsx extension with level attr (1=卷, 2=部分) and
auto-computed question count + total score in NodeView
- group-block.tsx: add instruction field ("每小题3分"), auto stats display
- editor-to-structure.ts: recursive buildStructureNode supports arbitrary
nesting (section > group > question), computeStats accumulates scores
- exam-rich-form.tsx ExamPreview: render section/group/question with
distinct styles and stats badges
- selection-toolbar.tsx: add "分卷" button (Layers icon)
- exam-rich-editor.tsx: register SectionBlock, expose insertSection/
wrapInSection via ref
- actions.ts: AI prompt now outputs volumes[] + groups[] structure with
instruction; buildTiptapDocFromAiResponse generates nested sectionBlock
- i18n: add markSection keys (zh-CN/en)
Structural nodes are NOT questions: their question count and total score
are automatically computed from child questions, not manually set.
1359 lines
45 KiB
TypeScript
1359 lines
45 KiB
TypeScript
"use server"
|
||
|
||
import { revalidatePath } from "next/cache"
|
||
import type { ActionState } from "@/shared/types/action-state"
|
||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||
import { Permissions } from "@/shared/types/permissions"
|
||
import { z } from "zod"
|
||
import { createId } from "@paralleldrive/cuid2"
|
||
import {
|
||
handleActionError,
|
||
safeJsonParse,
|
||
} from "@/shared/lib/action-utils"
|
||
import { trackExamEvent } from "@/shared/lib/track-event"
|
||
import {
|
||
buildExamDescription,
|
||
deleteExamById,
|
||
duplicateExam,
|
||
getExamCreatorId,
|
||
getExamGrades,
|
||
getExamPreview,
|
||
getExamSubjects,
|
||
getExamsByGradeId,
|
||
persistAiGeneratedExamDraft,
|
||
persistExamDraft,
|
||
resolveSubjectGradeNames,
|
||
updateExamWithQuestions,
|
||
type ExamModeConfig,
|
||
} from "./data-access"
|
||
import {
|
||
AiGeneratedStructureSchema,
|
||
AiInsertQuestionSchema,
|
||
AiQuestionSchema,
|
||
generateAiCreateDraftFromSource,
|
||
generateAiPreviewData,
|
||
regenerateAiQuestionByInstruction,
|
||
} from "./ai-pipeline"
|
||
import type {
|
||
AiGeneratedQuestion,
|
||
AiGeneratedStructureNode,
|
||
AiPreviewData,
|
||
AiRewriteQuestionData,
|
||
} from "./ai-pipeline"
|
||
import type { GradeExamsResult } from "./types"
|
||
export type { AiPreviewData, AiRewriteQuestionData } from "./ai-pipeline"
|
||
|
||
const ExamCreateSchema = 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(),
|
||
questions: z
|
||
.array(
|
||
z.object({
|
||
id: z.string(),
|
||
score: z.coerce.number().int().min(0),
|
||
})
|
||
)
|
||
.optional(),
|
||
})
|
||
|
||
const getStringValue = (formData: FormData, key: string) => {
|
||
const value = formData.get(key)
|
||
return typeof value === "string" ? value : undefined
|
||
}
|
||
|
||
const getBoolValue = (formData: FormData, key: string, fallback = false): boolean => {
|
||
const value = formData.get(key)
|
||
if (typeof value !== "string") return fallback
|
||
return value === "true"
|
||
}
|
||
|
||
const parseExamModeConfig = (formData: FormData): ExamModeConfig => {
|
||
const rawMode = getStringValue(formData, "examMode")
|
||
const examMode: ExamModeConfig["examMode"] =
|
||
rawMode === "timed" || rawMode === "proctored" ? rawMode : "homework"
|
||
const rawDuration = getStringValue(formData, "durationMinutes")
|
||
const durationMinutes = rawDuration && Number.isFinite(Number(rawDuration))
|
||
? Number(rawDuration)
|
||
: null
|
||
const rawGrace = getStringValue(formData, "lateStartGraceMinutes") ?? "0"
|
||
const parsedGrace = Number(rawGrace)
|
||
const lateStartGraceMinutes = Number.isFinite(parsedGrace) ? parsedGrace : 0
|
||
return {
|
||
examMode,
|
||
durationMinutes,
|
||
shuffleQuestions: getBoolValue(formData, "shuffleQuestions", false),
|
||
allowLateStart: getBoolValue(formData, "allowLateStart", false),
|
||
lateStartGraceMinutes,
|
||
antiCheatEnabled: getBoolValue(formData, "antiCheatEnabled", false),
|
||
}
|
||
}
|
||
|
||
const failState = <T>(message: string, errors?: Record<string, string[]>): ActionState<T> => ({
|
||
success: false,
|
||
message,
|
||
errors,
|
||
})
|
||
|
||
const successState = <T>(data: T, message?: string): ActionState<T> => ({
|
||
success: true,
|
||
message,
|
||
data,
|
||
})
|
||
|
||
const invalidFormState = <T>(
|
||
error: z.ZodError,
|
||
options?: { fallbackMessage?: string; useFirstMessage?: boolean }
|
||
): ActionState<T> => {
|
||
const errors = error.flatten().fieldErrors
|
||
const fallbackMessage = options?.fallbackMessage ?? "Invalid form data"
|
||
const useFirstMessage = options?.useFirstMessage ?? true
|
||
const messages = Object.values(errors).flatMap((items) => items ?? [])
|
||
const firstMessage = messages.find((msg): msg is string => typeof msg === "string" && msg.length > 0)
|
||
return failState<T>(useFirstMessage ? (firstMessage ?? fallbackMessage) : fallbackMessage, errors)
|
||
}
|
||
|
||
const prepareExamCreateContext = async (input: {
|
||
subject: string
|
||
grade: string
|
||
difficulty: number
|
||
totalScore: number
|
||
durationMin: number
|
||
scheduledAt?: string | null
|
||
}) => {
|
||
const examId = createId()
|
||
const scheduled = input.scheduledAt || undefined
|
||
const resolvedNames = await resolveSubjectGradeNames({
|
||
subjectId: input.subject,
|
||
gradeId: input.grade,
|
||
})
|
||
const subjectName = resolvedNames.subjectName ?? input.subject
|
||
const gradeName = resolvedNames.gradeName ?? input.grade
|
||
const buildDescription = (options?: { questionCount?: number }) => buildExamDescription({
|
||
subject: subjectName,
|
||
grade: gradeName,
|
||
difficulty: input.difficulty,
|
||
totalScore: input.totalScore,
|
||
durationMin: input.durationMin,
|
||
scheduledAt: scheduled,
|
||
questionCount: options?.questionCount,
|
||
})
|
||
return { examId, scheduled, subjectName, gradeName, buildDescription }
|
||
}
|
||
|
||
const loadAiDraftQuestionsAndStructure = async (input: {
|
||
rawAiQuestions: string | null
|
||
rawStructure: string | null
|
||
title: string
|
||
subject: string
|
||
grade: string
|
||
difficulty: number
|
||
totalScore: number
|
||
durationMin: number
|
||
aiSourceText?: string
|
||
aiQuestionCount?: number
|
||
aiProviderId?: string
|
||
}): Promise<
|
||
| { ok: true; generated: AiGeneratedQuestion[]; structure: AiGeneratedStructureNode[] }
|
||
| { ok: false; message: string }
|
||
> => {
|
||
if (input.rawAiQuestions) {
|
||
let parsedQuestions: unknown = null
|
||
try {
|
||
parsedQuestions = JSON.parse(input.rawAiQuestions)
|
||
} catch {
|
||
return { ok: false, message: "Invalid AI preview payload" }
|
||
}
|
||
const validated = z.array(AiInsertQuestionSchema).safeParse(parsedQuestions)
|
||
if (!validated.success || validated.data.length === 0) {
|
||
return { ok: false, message: "Invalid AI preview payload" }
|
||
}
|
||
const generated: AiGeneratedQuestion[] = validated.data.map((q) => ({
|
||
id: q.id,
|
||
type: q.type,
|
||
difficulty: q.difficulty,
|
||
score: q.score,
|
||
content: {
|
||
text: q.content.text,
|
||
...(q.content.options
|
||
? {
|
||
options: q.content.options.map((opt) => ({
|
||
id: opt.id,
|
||
text: opt.text,
|
||
isCorrect: opt.isCorrect ?? false,
|
||
})),
|
||
}
|
||
: {}),
|
||
...(q.content.subQuestions ? { subQuestions: q.content.subQuestions } : {}),
|
||
},
|
||
}))
|
||
let structure: AiGeneratedStructureNode[] = []
|
||
if (input.rawStructure) {
|
||
try {
|
||
const parsedStructure = JSON.parse(input.rawStructure)
|
||
const validatedStructure = AiGeneratedStructureSchema.safeParse(parsedStructure)
|
||
if (validatedStructure.success) {
|
||
structure = validatedStructure.data
|
||
} else {
|
||
return { ok: false, message: "Invalid preview structure" }
|
||
}
|
||
} catch {
|
||
return { ok: false, message: "Invalid preview structure" }
|
||
}
|
||
}
|
||
if (structure.length === 0) {
|
||
structure = generated.map((q) => ({
|
||
id: createId(),
|
||
type: "question",
|
||
questionId: q.id,
|
||
score: q.score,
|
||
}))
|
||
}
|
||
return { ok: true, generated, structure }
|
||
}
|
||
|
||
const sourceText = input.aiSourceText?.trim()
|
||
if (!sourceText) {
|
||
return { ok: false, message: "Please analyze and preview before creating" }
|
||
}
|
||
const aiDraft = await generateAiCreateDraftFromSource({
|
||
title: input.title,
|
||
subject: input.subject,
|
||
grade: input.grade,
|
||
difficulty: input.difficulty,
|
||
totalScore: input.totalScore,
|
||
durationMin: input.durationMin,
|
||
questionCount: input.aiQuestionCount,
|
||
sourceText,
|
||
aiProviderId: input.aiProviderId,
|
||
})
|
||
if (!aiDraft.ok) {
|
||
return { ok: false, message: aiDraft.message }
|
||
}
|
||
return { ok: true, generated: aiDraft.generated, structure: aiDraft.structure }
|
||
}
|
||
|
||
const prepareAiPreviewRequest = async (input: {
|
||
title?: string
|
||
subject?: string
|
||
grade?: string
|
||
difficulty?: number
|
||
totalScore?: number
|
||
durationMin?: number
|
||
aiSourceText: string
|
||
aiQuestionCount?: number
|
||
aiProviderId?: string
|
||
}) => {
|
||
const resolvedNames = await resolveSubjectGradeNames({
|
||
subjectId: input.subject,
|
||
gradeId: input.grade,
|
||
})
|
||
const title = input.title && input.title.trim().length > 0 ? input.title : "AI Exam"
|
||
const subjectName = input.subject ? resolvedNames.subjectName ?? input.subject : undefined
|
||
const gradeName = input.grade ? resolvedNames.gradeName ?? input.grade : undefined
|
||
return {
|
||
title,
|
||
subject: subjectName,
|
||
grade: gradeName,
|
||
difficulty: input.difficulty ?? 3,
|
||
totalScore: input.totalScore ?? 100,
|
||
durationMin: input.durationMin ?? 90,
|
||
questionCount: input.aiQuestionCount,
|
||
sourceText: input.aiSourceText,
|
||
aiProviderId: input.aiProviderId,
|
||
}
|
||
}
|
||
|
||
const parseRegenerateAiQuestionInput = (
|
||
formData: FormData
|
||
):
|
||
| {
|
||
ok: true
|
||
instruction: string
|
||
aiProviderId?: string
|
||
sourceText?: string
|
||
originalQuestion: z.infer<typeof AiQuestionSchema>
|
||
}
|
||
| { ok: false; state: ActionState<AiRewriteQuestionData> } => {
|
||
const instruction = getStringValue(formData, "instruction")?.trim()
|
||
const aiProviderId = getStringValue(formData, "aiProviderId")?.trim()
|
||
const sourceText = getStringValue(formData, "sourceText")?.trim()
|
||
const questionJson = getStringValue(formData, "questionJson")
|
||
if (!instruction) {
|
||
return { ok: false, state: failState<AiRewriteQuestionData>("Please enter rewrite instruction") }
|
||
}
|
||
if (!questionJson) {
|
||
return { ok: false, state: failState<AiRewriteQuestionData>("No selected question data") }
|
||
}
|
||
try {
|
||
const parsedQuestion = JSON.parse(questionJson) as unknown
|
||
const validatedQuestion = AiQuestionSchema.safeParse(parsedQuestion)
|
||
if (!validatedQuestion.success) {
|
||
return { ok: false, state: failState<AiRewriteQuestionData>("Selected question format invalid") }
|
||
}
|
||
return {
|
||
ok: true,
|
||
instruction,
|
||
aiProviderId,
|
||
sourceText,
|
||
originalQuestion: validatedQuestion.data,
|
||
}
|
||
} catch {
|
||
return { ok: false, state: failState<AiRewriteQuestionData>("Selected question format invalid") }
|
||
}
|
||
}
|
||
|
||
export async function createExamAction(
|
||
prevState: ActionState<string> | null,
|
||
formData: FormData
|
||
): Promise<ActionState<string>> {
|
||
try {
|
||
const ctx = await requirePermission(Permissions.EXAM_CREATE)
|
||
|
||
const rawQuestionsValue = formData.get("questionsJson")
|
||
const rawQuestions = typeof rawQuestionsValue === "string" ? rawQuestionsValue : null
|
||
|
||
const parsed = ExamCreateSchema.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,
|
||
questions: rawQuestions ? safeJsonParse(rawQuestions, "题目数据格式无效") : [],
|
||
})
|
||
|
||
if (!parsed.success) {
|
||
return invalidFormState<string>(parsed.error, { useFirstMessage: false })
|
||
}
|
||
|
||
const input = parsed.data
|
||
const context = await prepareExamCreateContext({
|
||
subject: input.subject,
|
||
grade: input.grade,
|
||
difficulty: input.difficulty,
|
||
totalScore: input.totalScore,
|
||
durationMin: input.durationMin,
|
||
scheduledAt: input.scheduledAt,
|
||
})
|
||
const description = context.buildDescription()
|
||
|
||
try {
|
||
await persistExamDraft({
|
||
examId: context.examId,
|
||
title: input.title,
|
||
creatorId: ctx.userId,
|
||
subjectId: input.subject,
|
||
gradeId: input.grade,
|
||
scheduledAt: context.scheduled,
|
||
description,
|
||
examModeConfig: parseExamModeConfig(formData),
|
||
})
|
||
} catch (error) {
|
||
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||
return failState<string>("Database error: Failed to create exam")
|
||
}
|
||
|
||
revalidatePath("/teacher/exams/all")
|
||
|
||
return successState(context.examId, "Exam created successfully.")
|
||
} catch (error) {
|
||
if (error instanceof PermissionDeniedError) {
|
||
return failState<string>(error.message)
|
||
}
|
||
return handleActionError(error)
|
||
}
|
||
}
|
||
|
||
const AiExamCreateSchema = ExamCreateSchema.extend({
|
||
aiSourceText: z.string().optional(),
|
||
aiQuestionCount: z.coerce.number().int().min(1).max(200).optional(),
|
||
aiProviderId: z.string().min(1).optional(),
|
||
})
|
||
|
||
const AiExamPreviewSchema = z.object({
|
||
title: z.string().optional(),
|
||
subject: z.string().optional(),
|
||
grade: z.string().optional(),
|
||
difficulty: z.coerce.number().int().min(1).max(5).optional(),
|
||
totalScore: z.coerce.number().int().min(1).optional(),
|
||
durationMin: z.coerce.number().int().min(1).optional(),
|
||
aiSourceText: z.string().min(1),
|
||
aiQuestionCount: z.coerce.number().int().min(1).max(200).optional(),
|
||
aiProviderId: z.string().min(1).optional(),
|
||
})
|
||
|
||
export async function createAiExamAction(
|
||
prevState: ActionState<string> | null,
|
||
formData: FormData
|
||
): Promise<ActionState<string>> {
|
||
try {
|
||
const ctx = await requirePermission(Permissions.EXAM_AI_GENERATE)
|
||
|
||
const rawQuestionsValue = formData.get("questionsJson")
|
||
const rawQuestions = typeof rawQuestionsValue === "string" ? rawQuestionsValue : null
|
||
const rawAiQuestionsValue = formData.get("aiQuestionsJson")
|
||
const rawAiQuestions = typeof rawAiQuestionsValue === "string" ? rawAiQuestionsValue : null
|
||
const rawStructureValue = formData.get("structureJson")
|
||
const rawStructure = typeof rawStructureValue === "string" ? rawStructureValue : null
|
||
const aiSourceTextRaw = formData.get("aiSourceText")
|
||
const aiQuestionCountRaw = formData.get("aiQuestionCount")
|
||
const aiProviderIdRaw = formData.get("aiProviderId")
|
||
|
||
const parsed = AiExamCreateSchema.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,
|
||
questions: rawQuestions ? safeJsonParse(rawQuestions, "题目数据格式无效") : [],
|
||
aiSourceText: typeof aiSourceTextRaw === "string" ? aiSourceTextRaw.trim() : undefined,
|
||
aiQuestionCount: typeof aiQuestionCountRaw === "string" && aiQuestionCountRaw.trim().length > 0
|
||
? aiQuestionCountRaw
|
||
: undefined,
|
||
aiProviderId: typeof aiProviderIdRaw === "string" && aiProviderIdRaw.trim().length > 0
|
||
? aiProviderIdRaw
|
||
: undefined,
|
||
})
|
||
|
||
if (!parsed.success) {
|
||
return invalidFormState<string>(parsed.error)
|
||
}
|
||
|
||
const input = parsed.data
|
||
if (!rawAiQuestions && !input.aiSourceText) {
|
||
return failState<string>("Please analyze and preview before creating")
|
||
}
|
||
const context = await prepareExamCreateContext({
|
||
subject: input.subject,
|
||
grade: input.grade,
|
||
difficulty: input.difficulty,
|
||
totalScore: input.totalScore,
|
||
durationMin: input.durationMin,
|
||
scheduledAt: input.scheduledAt,
|
||
})
|
||
|
||
const aiDraftResult = await loadAiDraftQuestionsAndStructure({
|
||
rawAiQuestions,
|
||
rawStructure,
|
||
title: input.title,
|
||
subject: context.subjectName,
|
||
grade: context.gradeName,
|
||
difficulty: input.difficulty,
|
||
totalScore: input.totalScore,
|
||
durationMin: input.durationMin,
|
||
aiSourceText: input.aiSourceText,
|
||
aiQuestionCount: input.aiQuestionCount,
|
||
aiProviderId: input.aiProviderId,
|
||
})
|
||
if (!aiDraftResult.ok) {
|
||
return failState<string>(aiDraftResult.message)
|
||
}
|
||
const { generated, structure } = aiDraftResult
|
||
|
||
const questionCount = generated.length
|
||
const description = context.buildDescription({ questionCount })
|
||
|
||
try {
|
||
await persistAiGeneratedExamDraft({
|
||
examId: context.examId,
|
||
title: input.title,
|
||
creatorId: ctx.userId,
|
||
subjectId: input.subject,
|
||
gradeId: input.grade,
|
||
scheduledAt: context.scheduled,
|
||
description,
|
||
structure,
|
||
generated,
|
||
examModeConfig: parseExamModeConfig(formData),
|
||
})
|
||
} catch (error) {
|
||
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||
return failState<string>("Database error: Failed to create exam")
|
||
}
|
||
|
||
revalidatePath("/teacher/exams/all")
|
||
|
||
// V3-4: 埋点监控(AI 生成考试)
|
||
await trackExamEvent("exam.ai_generated", {
|
||
userId: ctx.userId,
|
||
targetId: context.examId,
|
||
properties: {
|
||
aiSourceText: input.aiSourceText?.length ?? 0,
|
||
aiQuestionCount: input.aiQuestionCount,
|
||
},
|
||
})
|
||
|
||
return successState(context.examId, "Exam created successfully.")
|
||
} catch (error) {
|
||
if (error instanceof PermissionDeniedError) {
|
||
return failState<string>(error.message)
|
||
}
|
||
return handleActionError(error)
|
||
}
|
||
}
|
||
|
||
export async function previewAiExamAction(
|
||
prevState: ActionState<AiPreviewData> | null,
|
||
formData: FormData
|
||
): Promise<ActionState<AiPreviewData>> {
|
||
try {
|
||
await requirePermission(Permissions.EXAM_AI_GENERATE)
|
||
|
||
const aiSourceTextRaw = formData.get("aiSourceText")
|
||
const aiQuestionCountRaw = formData.get("aiQuestionCount")
|
||
const aiProviderIdRaw = formData.get("aiProviderId")
|
||
|
||
const sourceText = typeof aiSourceTextRaw === "string" ? aiSourceTextRaw.trim() : ""
|
||
if (!sourceText) {
|
||
return failState<AiPreviewData>("Please paste the full exam text first", {
|
||
aiSourceText: ["Please paste the full exam text first"],
|
||
})
|
||
}
|
||
|
||
const parsed = AiExamPreviewSchema.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"),
|
||
aiSourceText: sourceText,
|
||
aiQuestionCount: typeof aiQuestionCountRaw === "string" && aiQuestionCountRaw.trim().length > 0
|
||
? aiQuestionCountRaw
|
||
: undefined,
|
||
aiProviderId: typeof aiProviderIdRaw === "string" && aiProviderIdRaw.trim().length > 0
|
||
? aiProviderIdRaw
|
||
: undefined,
|
||
})
|
||
|
||
if (!parsed.success) {
|
||
return invalidFormState<AiPreviewData>(parsed.error)
|
||
}
|
||
|
||
const input = parsed.data
|
||
const previewRequest = await prepareAiPreviewRequest(input)
|
||
const aiDraft = await generateAiPreviewData(previewRequest)
|
||
if (!aiDraft.ok) {
|
||
return failState<AiPreviewData>(aiDraft.message)
|
||
}
|
||
return successState({ ...aiDraft.data, rawOutput: aiDraft.rawOutput })
|
||
} catch (error) {
|
||
if (error instanceof PermissionDeniedError) {
|
||
return failState<AiPreviewData>(error.message)
|
||
}
|
||
return handleActionError(error)
|
||
}
|
||
}
|
||
|
||
export async function regenerateAiQuestionAction(
|
||
prevState: ActionState<AiRewriteQuestionData> | null,
|
||
formData: FormData
|
||
): Promise<ActionState<AiRewriteQuestionData>> {
|
||
try {
|
||
await requirePermission(Permissions.EXAM_AI_GENERATE)
|
||
|
||
const parsedInput = parseRegenerateAiQuestionInput(formData)
|
||
if (!parsedInput.ok) {
|
||
return parsedInput.state
|
||
}
|
||
const { instruction, aiProviderId, sourceText, originalQuestion } = parsedInput
|
||
|
||
const originalDifficulty = originalQuestion.difficulty ?? 3
|
||
const originalScore = originalQuestion.score ?? 0
|
||
|
||
try {
|
||
const result = await regenerateAiQuestionByInstruction({
|
||
instruction,
|
||
originalQuestion,
|
||
sourceText,
|
||
aiProviderId,
|
||
})
|
||
if (!result.ok) {
|
||
return failState<AiRewriteQuestionData>(result.message)
|
||
}
|
||
return successState({
|
||
type: result.data.type,
|
||
difficulty: result.data.difficulty ?? originalDifficulty,
|
||
score: result.data.score ?? originalScore,
|
||
content: result.data.content,
|
||
})
|
||
} catch (error) {
|
||
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||
return failState<AiRewriteQuestionData>("AI question format invalid")
|
||
}
|
||
} catch (error) {
|
||
if (error instanceof PermissionDeniedError) {
|
||
return failState<AiRewriteQuestionData>(error.message)
|
||
}
|
||
return handleActionError(error)
|
||
}
|
||
}
|
||
|
||
const ExamUpdateSchema = z.object({
|
||
examId: z.string().min(1),
|
||
questions: z
|
||
.array(
|
||
z.object({
|
||
id: z.string(),
|
||
score: z.coerce.number().int().min(0),
|
||
})
|
||
)
|
||
.optional(),
|
||
structure: z.unknown().optional(),
|
||
status: z.enum(["draft", "published", "archived"]).optional(),
|
||
})
|
||
|
||
export async function updateExamAction(
|
||
prevState: ActionState<string> | null,
|
||
formData: FormData
|
||
): Promise<ActionState<string>> {
|
||
try {
|
||
const ctx = await requirePermission(Permissions.EXAM_UPDATE)
|
||
|
||
const rawQuestions = formData.get("questionsJson")
|
||
const rawStructure = formData.get("structureJson")
|
||
const rawQuestionsStr = typeof rawQuestions === "string" ? rawQuestions : null
|
||
const rawStructureStr = typeof rawStructure === "string" ? rawStructure : null
|
||
|
||
const parsed = ExamUpdateSchema.safeParse({
|
||
examId: formData.get("examId"),
|
||
questions: rawQuestionsStr ? safeJsonParse(rawQuestionsStr, "题目数据格式无效") : undefined,
|
||
structure: rawStructureStr ? safeJsonParse(rawStructureStr, "试卷结构数据格式无效") : undefined,
|
||
status: formData.get("status") ?? undefined,
|
||
})
|
||
|
||
if (!parsed.success) {
|
||
return invalidFormState<string>(parsed.error, {
|
||
fallbackMessage: "Invalid update data",
|
||
useFirstMessage: false,
|
||
})
|
||
}
|
||
|
||
const { examId, questions, structure, status } = parsed.data
|
||
|
||
// Ownership check: non-admin users can only update their own exams
|
||
if (ctx.dataScope.type !== "all") {
|
||
const creatorId = await getExamCreatorId(examId)
|
||
if (!creatorId || creatorId !== ctx.userId) {
|
||
return failState<string>("You can only update exams you created")
|
||
}
|
||
}
|
||
|
||
try {
|
||
await updateExamWithQuestions(examId, {
|
||
questions: questions ?? undefined,
|
||
structure,
|
||
status,
|
||
})
|
||
} catch (error) {
|
||
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||
return failState<string>("Database error: Failed to update exam")
|
||
}
|
||
|
||
revalidatePath("/teacher/exams/all")
|
||
|
||
// V3-4: 埋点监控
|
||
await trackExamEvent("exam.updated", {
|
||
userId: ctx.userId,
|
||
targetId: examId,
|
||
properties: { hasQuestions: !!questions, hasStructure: !!structure, status },
|
||
})
|
||
|
||
return successState(examId, "Exam updated")
|
||
} catch (error) {
|
||
if (error instanceof PermissionDeniedError) {
|
||
return failState<string>(error.message)
|
||
}
|
||
return handleActionError(error)
|
||
}
|
||
}
|
||
|
||
const ExamDeleteSchema = z.object({
|
||
examId: z.string().min(1),
|
||
})
|
||
|
||
export async function deleteExamAction(
|
||
prevState: ActionState<string> | null,
|
||
formData: FormData
|
||
): Promise<ActionState<string>> {
|
||
try {
|
||
const ctx = await requirePermission(Permissions.EXAM_DELETE)
|
||
|
||
const parsed = ExamDeleteSchema.safeParse({
|
||
examId: formData.get("examId"),
|
||
})
|
||
|
||
if (!parsed.success) {
|
||
return invalidFormState<string>(parsed.error, {
|
||
fallbackMessage: "Invalid delete data",
|
||
useFirstMessage: false,
|
||
})
|
||
}
|
||
|
||
const { examId } = parsed.data
|
||
|
||
// Ownership check: non-admin users can only delete their own exams
|
||
if (ctx.dataScope.type !== "all") {
|
||
const creatorId = await getExamCreatorId(examId)
|
||
if (!creatorId || creatorId !== ctx.userId) {
|
||
return failState<string>("You can only delete exams you created")
|
||
}
|
||
}
|
||
|
||
try {
|
||
await deleteExamById(examId)
|
||
} catch (error) {
|
||
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||
return failState<string>("Database error: Failed to delete exam")
|
||
}
|
||
|
||
revalidatePath("/teacher/exams/all")
|
||
|
||
// V3-4: 埋点监控
|
||
await trackExamEvent("exam.deleted", {
|
||
userId: ctx.userId,
|
||
targetId: examId,
|
||
})
|
||
|
||
return successState(examId, "Exam deleted")
|
||
} catch (error) {
|
||
if (error instanceof PermissionDeniedError) {
|
||
return failState<string>(error.message)
|
||
}
|
||
return handleActionError(error)
|
||
}
|
||
}
|
||
|
||
const ExamDuplicateSchema = z.object({
|
||
examId: z.string().min(1),
|
||
})
|
||
|
||
export async function duplicateExamAction(
|
||
prevState: ActionState<string> | null,
|
||
formData: FormData
|
||
): Promise<ActionState<string>> {
|
||
try {
|
||
const ctx = await requirePermission(Permissions.EXAM_DUPLICATE)
|
||
|
||
const parsed = ExamDuplicateSchema.safeParse({
|
||
examId: formData.get("examId"),
|
||
})
|
||
|
||
if (!parsed.success) {
|
||
return invalidFormState<string>(parsed.error, {
|
||
fallbackMessage: "Invalid duplicate data",
|
||
useFirstMessage: false,
|
||
})
|
||
}
|
||
|
||
const { examId } = parsed.data
|
||
|
||
let newExamId: string
|
||
try {
|
||
const duplicatedId = await duplicateExam(examId, ctx.userId)
|
||
if (!duplicatedId) {
|
||
return failState<string>("Exam not found")
|
||
}
|
||
newExamId = duplicatedId
|
||
} catch (error) {
|
||
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||
return failState<string>("Database error: Failed to duplicate exam")
|
||
}
|
||
|
||
revalidatePath("/teacher/exams/all")
|
||
|
||
// V3-4: 埋点监控
|
||
await trackExamEvent("exam.duplicated", {
|
||
userId: ctx.userId,
|
||
targetId: newExamId,
|
||
properties: { sourceExamId: examId },
|
||
})
|
||
|
||
return successState(newExamId, "Exam duplicated")
|
||
} catch (error) {
|
||
if (error instanceof PermissionDeniedError) {
|
||
return failState<string>(error.message)
|
||
}
|
||
return handleActionError(error)
|
||
}
|
||
}
|
||
|
||
export async function getExamPreviewAction(
|
||
examId: string
|
||
): Promise<ActionState<{ structure: unknown; questions: Array<{ id: string }> }>> {
|
||
try {
|
||
await requirePermission(Permissions.EXAM_READ)
|
||
|
||
try {
|
||
const exam = await getExamPreview(examId)
|
||
|
||
if (!exam) {
|
||
return failState<{ structure: unknown; questions: Array<{ id: string }> }>("Exam not found")
|
||
}
|
||
return successState({
|
||
structure: exam.structure,
|
||
questions: exam.questions,
|
||
})
|
||
} catch (error) {
|
||
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||
return failState<{ structure: unknown; questions: Array<{ id: string }> }>("Failed to load exam preview")
|
||
}
|
||
} catch (error) {
|
||
if (error instanceof PermissionDeniedError) {
|
||
return failState<{ structure: unknown; questions: Array<{ id: string }> }>(error.message)
|
||
}
|
||
return handleActionError(error)
|
||
}
|
||
}
|
||
|
||
export async function getSubjectsAction(): Promise<ActionState<{ id: string; name: string }[]>> {
|
||
try {
|
||
await requirePermission(Permissions.EXAM_READ)
|
||
|
||
try {
|
||
const allSubjects = await getExamSubjects()
|
||
return successState(allSubjects)
|
||
} catch (error) {
|
||
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||
return failState<{ id: string; name: string }[]>("Failed to load subjects")
|
||
}
|
||
} catch (error) {
|
||
if (error instanceof PermissionDeniedError) {
|
||
return failState<{ id: string; name: string }[]>(error.message)
|
||
}
|
||
return handleActionError(error)
|
||
}
|
||
}
|
||
|
||
export async function getGradesAction(): Promise<ActionState<{ id: string; name: string }[]>> {
|
||
try {
|
||
await requirePermission(Permissions.EXAM_READ)
|
||
|
||
try {
|
||
const allGrades = await getExamGrades()
|
||
return successState(allGrades)
|
||
} catch (error) {
|
||
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||
return failState<{ id: string; name: string }[]>("Failed to load grades")
|
||
}
|
||
} catch (error) {
|
||
if (error instanceof PermissionDeniedError) {
|
||
return failState<{ id: string; name: string }[]>(error.message)
|
||
}
|
||
return handleActionError(error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 年级仪表盘 - 维度3:获取年级下所有考试 + 提交统计。
|
||
*/
|
||
export async function getExamsByGradeIdAction(
|
||
gradeId: string
|
||
): Promise<ActionState<GradeExamsResult>> {
|
||
try {
|
||
const ctx = await requirePermission(Permissions.EXAM_READ)
|
||
|
||
if (!gradeId || gradeId.trim().length === 0) {
|
||
return failState<GradeExamsResult>("Invalid grade id")
|
||
}
|
||
|
||
const result = await getExamsByGradeId({
|
||
gradeId,
|
||
scope: ctx.dataScope,
|
||
})
|
||
return successState(result)
|
||
} catch (error) {
|
||
if (error instanceof PermissionDeniedError) {
|
||
return failState<GradeExamsResult>(error.message)
|
||
}
|
||
return handleActionError(error)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 富文本编辑器: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. **分卷**:识别\"第Ⅰ卷\"\"第Ⅱ卷\"\"第一部分\"等顶层分卷标记,输出到 volumes。每个 volume 含 title、instruction(可选)和 groups。",
|
||
"2. **大题分组**:识别\"一、选择题\"\"二、填空题\"\"三、阅读理解\"等大题标题,输出到 groups(若在 volume 内则归入对应 volume,否则归入顶层 groups)。每个 group 含 title、instruction(如\"每小题3分,共24分\")和 questions。",
|
||
"3. **题型识别**:",
|
||
" - 选择题(单选/多选):题干 + A/B/C/D 选项 → type: \"single_choice\" 或 \"multiple_choice\"",
|
||
" - 判断题:题干 + 对/错 → type: \"judgment\"",
|
||
" - 填空题:题干含横线空(_______或( )) → type: \"text\",在 blanks 中标记空数",
|
||
" - 简答/作文题:题干 + \"不少于X字\" → type: \"text\"",
|
||
" - 阅读理解(含选段+多小题)→ type: \"composite\",subQuestions 存小题",
|
||
"4. **分值**:从\"每小题3分\"\"共24分\"\"(12分)\"等提取,分摊到每题。",
|
||
"5. **选项**:A. B. C. D. 格式,输出到 content.options,每项含 id 和 text。",
|
||
"6. **填空**:横线\"_______\"或括号\"( )\"位置,在 content.blanks 中标记(数组长度=空数)。",
|
||
"7. **加点字**:拼音注音题中加点的字(如\"解\"剖),在 content.dottedTexts 中列出原文片段。",
|
||
"8. **子题**:阅读理解下的\"1. 2. 3.\"小题,输出到 content.subQuestions,每项含 text 和 score。",
|
||
"9. **阅读材料**:阅读理解的文章选段,放到 content.readingMaterial 字段。",
|
||
"10. **说明文字**:\"每小题3分,共24分\"\"选择正确答案的番号填涂在答题卡上\"等放到 group.instruction,不要混入题干。",
|
||
"",
|
||
"## 输出 JSON schema",
|
||
"",
|
||
"```json",
|
||
"{",
|
||
' "title": "试卷标题",',
|
||
' "volumes": [',
|
||
" {",
|
||
' "title": "第Ⅰ卷 选择题",',
|
||
' "groups": [',
|
||
" {",
|
||
' "title": "一、选择正确答案的番号填涂在答题卡上",',
|
||
' "instruction": "每小题3分,共24分",',
|
||
' "questions": [',
|
||
" {",
|
||
' "type": "single_choice",',
|
||
' "score": 3,',
|
||
' "content": {',
|
||
' "text": "下面加点字的注音全部正确的一项是",',
|
||
' "options": [{"id":"A","text":"解剖(pō) 蹭饭(cènɡ)"},{"id":"B","text":"栖息(qī) 譬如(pì)"}],',
|
||
' "blanks": [],',
|
||
' "dottedTexts": ["解","蹭","徉"],',
|
||
' "subQuestions": []',
|
||
" }",
|
||
" }",
|
||
" ]",
|
||
" }",
|
||
" ]",
|
||
" }",
|
||
" ],",
|
||
' "groups": []',
|
||
"}",
|
||
"```",
|
||
"",
|
||
"## 注意事项",
|
||
"- 不要输出 markdown 代码块标记(```),直接输出 JSON。",
|
||
"- 不要输出 ... 或 [...] 等占位符,必须输出完整数据。",
|
||
"- 如果没有 volumes,可直接返回 { \"groups\": [...] } 或 { \"questions\": [...] }。",
|
||
"- 阅读理解的选段文本放到 content.readingMaterial,不要混入题干 text。",
|
||
"- 作文题的\"不少于300字\"等要求保留在 text 中。",
|
||
"- \"共24分\"等总分信息不要写进 title,放到 instruction;实际总分由子题自动累加。",
|
||
].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 : ""
|
||
}
|
||
|
||
/**
|
||
* 将文本按加点字片段切分,返回带 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 两种形式。
|
||
*/
|
||
const buildTiptapDocFromAiResponse = (data: unknown): unknown => {
|
||
if (!isRecord(data)) return { type: "doc", content: [] }
|
||
|
||
const content: unknown[] = []
|
||
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 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 : []
|
||
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}` }],
|
||
},
|
||
],
|
||
}
|
||
}),
|
||
})
|
||
}
|
||
|
||
// 子题(阅读理解的小题)—— 生成为嵌套的 questionBlock,便于编辑器层级展示与解析
|
||
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 : ""
|
||
const subScore = typeof sub.score === "number" ? sub.score : 0
|
||
if (subText) {
|
||
// 子题文本按行拆分为段落
|
||
const subLines = subText.split("\n").filter((l) => l.trim().length > 0)
|
||
const subInner =
|
||
subLines.length > 0
|
||
? subLines.map((line) => ({
|
||
type: "paragraph",
|
||
content: [{ type: "text", text: line }],
|
||
}))
|
||
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
|
||
inner.push({
|
||
type: "questionBlock",
|
||
attrs: { questionId: "", type: "text", score: subScore },
|
||
content: subInner,
|
||
})
|
||
}
|
||
}
|
||
|
||
// questionBlock 要求 content: "block+",无内容时给空段落
|
||
const innerContent =
|
||
inner.length > 0
|
||
? inner
|
||
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
|
||
|
||
return {
|
||
type: "questionBlock",
|
||
attrs: { questionId: "", type, score },
|
||
content: innerContent,
|
||
}
|
||
}
|
||
|
||
// 构建 groupBlock(大题分组),含 instruction
|
||
const buildGroupBlock = (g: unknown): unknown | null => {
|
||
if (!isRecord(g)) return null
|
||
const title = typeof g.title === "string" ? g.title : ""
|
||
const instruction = typeof g.instruction === "string" ? g.instruction : ""
|
||
const questions = Array.isArray(g.questions) ? g.questions : []
|
||
const children = questions
|
||
.map(buildQuestionBlock)
|
||
.filter((b): b is Record<string, unknown> => b !== null)
|
||
const groupContent =
|
||
children.length > 0
|
||
? children
|
||
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
|
||
return {
|
||
type: "groupBlock",
|
||
attrs: { title, instruction },
|
||
content: groupContent,
|
||
}
|
||
}
|
||
|
||
// 构建 sectionBlock(分卷),内含多个 groupBlock
|
||
const volumes = Array.isArray(data.volumes) ? data.volumes : []
|
||
const topGroups = Array.isArray(data.groups) ? data.groups : []
|
||
|
||
for (const volume of volumes) {
|
||
if (!isRecord(volume)) continue
|
||
const title = typeof volume.title === "string" ? volume.title : ""
|
||
const innerGroups = Array.isArray(volume.groups) ? volume.groups : []
|
||
const groupBlocks = innerGroups
|
||
.map(buildGroupBlock)
|
||
.filter((b): b is Record<string, unknown> => b !== null)
|
||
const sectionContent =
|
||
groupBlocks.length > 0
|
||
? groupBlocks
|
||
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
|
||
content.push({
|
||
type: "sectionBlock",
|
||
attrs: { title, level: 1 },
|
||
content: sectionContent,
|
||
})
|
||
}
|
||
|
||
// 顶层 groups(无分卷时)
|
||
for (const g of topGroups) {
|
||
const block = buildGroupBlock(g)
|
||
if (block) content.push(block)
|
||
}
|
||
|
||
// 顶层 questions(无分卷无大题时)
|
||
if (volumes.length === 0 && topGroups.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)
|
||
}
|
||
}
|
||
|
||
|