- Add actions-helpers and actions-rich-editor for rich text exam editing - Add ai-pipeline/auto-mark for automatic exam marking - Add exam-boundaries and exam-preview components - Add config directory for exam configuration - Add data-access-cross-module for cross-module data access - Add editor/exam-nodes-to-editor-doc and editor/utils for editor utilities - Add use-exam-preview-rewrite, use-exam-preview-state, use-exam-preview-tasks hooks - Add services directory
723 lines
23 KiB
TypeScript
723 lines
23 KiB
TypeScript
"use server"
|
||
|
||
import { revalidatePath } from "next/cache"
|
||
import { getTranslations } from "next-intl/server"
|
||
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 {
|
||
handleActionError,
|
||
safeJsonParse,
|
||
} from "@/shared/lib/action-utils"
|
||
import { trackExamEvent } from "@/shared/lib/track-event"
|
||
import {
|
||
deleteExamById,
|
||
duplicateExam,
|
||
getExamCreatorId,
|
||
getExamGrades,
|
||
getExamPreview,
|
||
getExamSubjects,
|
||
getExamsByGradeId,
|
||
persistAiGeneratedExamDraft,
|
||
persistExamDraft,
|
||
resolveSubjectGradeNames,
|
||
updateExamWithQuestions,
|
||
} from "./data-access"
|
||
import {
|
||
AiQuestionSchema,
|
||
generateAiPreviewData,
|
||
loadAiDraftQuestionsAndStructure,
|
||
regenerateAiQuestionByInstruction,
|
||
} from "./ai-pipeline"
|
||
import type {
|
||
AiPreviewData,
|
||
AiRewriteQuestionData,
|
||
} from "./ai-pipeline"
|
||
import type { GradeExamsResult } from "./types"
|
||
import {
|
||
failState,
|
||
getStringValue,
|
||
invalidFormState,
|
||
parseExamModeConfig,
|
||
prepareExamCreateContext,
|
||
successState,
|
||
} from "./actions-helpers"
|
||
|
||
// Re-export 从拆分文件迁移的导出,保持外部 import 路径不变
|
||
export type { AutoMarkResult } from "./ai-pipeline/auto-mark"
|
||
export { autoMarkExamAction } from "./ai-pipeline/auto-mark"
|
||
export { createExamFromRichEditorAction, updateExamFromRichEditorAction } from "./actions-rich-editor"
|
||
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 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,
|
||
t: Awaited<ReturnType<typeof getTranslations>>
|
||
):
|
||
| {
|
||
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>(t("enterRewriteInstruction")) }
|
||
}
|
||
if (!questionJson) {
|
||
return { ok: false, state: failState<AiRewriteQuestionData>(t("noSelectedQuestion")) }
|
||
}
|
||
try {
|
||
const parsedQuestion = JSON.parse(questionJson) as unknown
|
||
const validatedQuestion = AiQuestionSchema.safeParse(parsedQuestion)
|
||
if (!validatedQuestion.success) {
|
||
return { ok: false, state: failState<AiRewriteQuestionData>(t("questionFormatInvalid")) }
|
||
}
|
||
return {
|
||
ok: true,
|
||
instruction,
|
||
aiProviderId,
|
||
sourceText,
|
||
originalQuestion: validatedQuestion.data,
|
||
}
|
||
} catch {
|
||
return { ok: false, state: failState<AiRewriteQuestionData>(t("questionFormatInvalid")) }
|
||
}
|
||
}
|
||
|
||
export async function createExamAction(
|
||
prevState: ActionState<string> | null,
|
||
formData: FormData
|
||
): Promise<ActionState<string>> {
|
||
try {
|
||
const t = await getTranslations("examHomework.exam.actionMessages")
|
||
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>(t("dbCreateFailed"))
|
||
}
|
||
|
||
revalidatePath("/teacher/exams/all")
|
||
|
||
return successState(context.examId, t("createdSuccess"))
|
||
} 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 t = await getTranslations("examHomework.exam.actionMessages")
|
||
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>(t("analyzeFirst"))
|
||
}
|
||
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>(t("dbCreateFailed"))
|
||
}
|
||
|
||
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, t("createdSuccess"))
|
||
} 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 {
|
||
const t = await getTranslations("examHomework.exam.actionMessages")
|
||
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>(t("pasteSourceFirst"), {
|
||
aiSourceText: [t("pasteSourceFirst")],
|
||
})
|
||
}
|
||
|
||
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 {
|
||
const t = await getTranslations("examHomework.exam.actionMessages")
|
||
await requirePermission(Permissions.EXAM_AI_GENERATE)
|
||
|
||
const parsedInput = parseRegenerateAiQuestionInput(formData, t)
|
||
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>(t("aiQuestionFormatInvalid"))
|
||
}
|
||
} 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 t = await getTranslations("examHomework.exam.actionMessages")
|
||
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: t("invalidUpdateData"),
|
||
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>(t("onlyOwnUpdate"))
|
||
}
|
||
}
|
||
|
||
try {
|
||
await updateExamWithQuestions(examId, {
|
||
questions: questions ?? undefined,
|
||
structure,
|
||
status,
|
||
})
|
||
} catch (error) {
|
||
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||
return failState<string>(t("dbUpdateFailed"))
|
||
}
|
||
|
||
revalidatePath("/teacher/exams/all")
|
||
|
||
// V3-4: 埋点监控
|
||
await trackExamEvent("exam.updated", {
|
||
userId: ctx.userId,
|
||
targetId: examId,
|
||
properties: { hasQuestions: !!questions, hasStructure: !!structure, status },
|
||
})
|
||
|
||
return successState(examId, t("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 t = await getTranslations("examHomework.exam.actionMessages")
|
||
const ctx = await requirePermission(Permissions.EXAM_DELETE)
|
||
|
||
const parsed = ExamDeleteSchema.safeParse({
|
||
examId: formData.get("examId"),
|
||
})
|
||
|
||
if (!parsed.success) {
|
||
return invalidFormState<string>(parsed.error, {
|
||
fallbackMessage: t("invalidDeleteData"),
|
||
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>(t("onlyOwnDelete"))
|
||
}
|
||
}
|
||
|
||
try {
|
||
await deleteExamById(examId)
|
||
} catch (error) {
|
||
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||
return failState<string>(t("dbDeleteFailed"))
|
||
}
|
||
|
||
revalidatePath("/teacher/exams/all")
|
||
|
||
// V3-4: 埋点监控
|
||
await trackExamEvent("exam.deleted", {
|
||
userId: ctx.userId,
|
||
targetId: examId,
|
||
})
|
||
|
||
return successState(examId, t("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 t = await getTranslations("examHomework.exam.actionMessages")
|
||
const ctx = await requirePermission(Permissions.EXAM_DUPLICATE)
|
||
|
||
const parsed = ExamDuplicateSchema.safeParse({
|
||
examId: formData.get("examId"),
|
||
})
|
||
|
||
if (!parsed.success) {
|
||
return invalidFormState<string>(parsed.error, {
|
||
fallbackMessage: t("invalidDuplicateData"),
|
||
useFirstMessage: false,
|
||
})
|
||
}
|
||
|
||
const { examId } = parsed.data
|
||
|
||
let newExamId: string
|
||
try {
|
||
const duplicatedId = await duplicateExam(examId, ctx.userId)
|
||
if (!duplicatedId) {
|
||
return failState<string>(t("notFound"))
|
||
}
|
||
newExamId = duplicatedId
|
||
} catch (error) {
|
||
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||
return failState<string>(t("dbDuplicateFailed"))
|
||
}
|
||
|
||
revalidatePath("/teacher/exams/all")
|
||
|
||
// V3-4: 埋点监控
|
||
await trackExamEvent("exam.duplicated", {
|
||
userId: ctx.userId,
|
||
targetId: newExamId,
|
||
properties: { sourceExamId: examId },
|
||
})
|
||
|
||
return successState(newExamId, t("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 {
|
||
const t = await getTranslations("examHomework.exam.actionMessages")
|
||
await requirePermission(Permissions.EXAM_READ)
|
||
|
||
try {
|
||
const exam = await getExamPreview(examId)
|
||
|
||
if (!exam) {
|
||
return failState<{ structure: unknown; questions: Array<{ id: string }> }>(t("notFound"))
|
||
}
|
||
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 }> }>(t("loadPreviewFailed"))
|
||
}
|
||
} 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 {
|
||
const t = await getTranslations("examHomework.exam.actionMessages")
|
||
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 }[]>(t("loadSubjectsFailed"))
|
||
}
|
||
} 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 {
|
||
const t = await getTranslations("examHomework.exam.actionMessages")
|
||
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 }[]>(t("loadGradesFailed"))
|
||
}
|
||
} 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 t = await getTranslations("examHomework.exam.actionMessages")
|
||
const ctx = await requirePermission(Permissions.EXAM_READ)
|
||
|
||
if (!gradeId || gradeId.trim().length === 0) {
|
||
return failState<GradeExamsResult>(t("invalidGradeId"))
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|