Files
NextEdu/src/modules/exams/actions.ts
SpecialX 1f28efbeb6 feat(exams): add section/group structure nodes with auto stats
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.
2026-06-24 14:07:29 +08:00

1359 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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)
}
}