- Update attendance components and data-access for record management - Update audit log views, filters, and data-access - Update auth login and register forms - Update classes actions, components, and data-access (admin, schedule, stats) - Update course-plans actions, form, list, progress, and schema - Update exams actions, AI pipeline, preview components, and hooks - Update files components (icon, list, preview, upload) and data-access - Update homework assignment form, review view, auto-save hook, and stats-service - Update layout sidebar, header, and navigation config - Update proctoring actions, anti-cheat monitor, and data-access - Update questions actions, components (dialog, actions, columns, filters), and data-access - Update scheduling actions, auto-scheduler, components, and schema - Update textbooks constants and text-selection hook - Update users class-registration, import-dialog, data-access, and user-service
854 lines
27 KiB
TypeScript
854 lines
27 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,
|
||
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"
|
||
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)
|
||
}
|
||
}
|
||
|
||
|