Files
NextEdu/src/app/(dashboard)/teacher/exams/[id]/build/page.tsx
SpecialX 21c5eba96c feat(ai): 新增 AI 模块并集成至备课/错题集/试卷/改题四大业务场景
- 新增 src/modules/ai 独立模块,遵循三层架构(actions → services → shared/lib/ai)
- 通过 AiClientProvider + useAiClient 实现 React Context 依赖注入,业务组件零直接 import
- 6 个 Server Actions 均调用 requirePermission() 权限校验,返回 ActionState<T>
- withAiTracking 统一埋点,覆盖 chat/similar_question/grading_assist/lesson_content/question_variant/weakness_analysis
- 集成场景:作业批改 AiGradingAssist、错题集 AiErrorBookAnalysis、备课 AiLessonContentGenerator、试卷 AiQuestionVariantGenerator
- 全量 i18n(en/zh-CN ai.json),Error Boundary + Skeleton 边界处理
- 同步架构图 004/005,新增审计报告 ai-module-audit-report.md
2026-06-23 00:52:39 +08:00

129 lines
4.4 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.
import type { JSX } from "react"
import { notFound } from "next/navigation"
import { ExamAssembly } from "@/modules/exams/components/exam-assembly"
import { getExamById } from "@/modules/exams/data-access"
import { getQuestions } from "@/modules/questions/data-access"
import { normalizeStructure } from "@/modules/exams/utils/normalize-structure"
import type { Question } from "@/modules/questions/types"
import type { ExamNode } from "@/modules/exams/components/assembly/selected-question-list"
import { createId } from "@paralleldrive/cuid2"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import {
AiClientProvider,
type AiClientService,
} from "@/modules/ai/context/ai-client-provider"
import {
aiChatAction,
suggestSimilarQuestionsAction,
suggestGradingAction,
generateLessonContentAction,
generateQuestionVariantAction,
analyzeWeaknessAction,
} from "@/modules/ai/actions"
export const dynamic = "force-dynamic"
/**
* 构建 AI 客户端服务Server Action 引用集合)
*
* 通过 React Context 注入,客户端组件不直接 import actions
* 遵循依赖注入模式,便于测试时替换为 mock。
*/
function createAiClientService(): AiClientService {
return {
chat: aiChatAction,
suggestSimilarQuestions: suggestSimilarQuestionsAction,
suggestGrading: suggestGradingAction,
generateLessonContent: generateLessonContentAction,
generateQuestionVariant: generateQuestionVariantAction,
analyzeWeakness: analyzeWeaknessAction,
}
}
export default async function BuildExamPage({ params }: { params: Promise<{ id: string }> }): Promise<JSX.Element> {
const { id } = await params
const ctx = await requirePermission(Permissions.EXAM_READ)
const exam = await getExamById(id, ctx.dataScope)
if (!exam) return notFound()
// Fetch initial questions for the bank (pagination handled by client)
// Run both queries in parallel since the second depends on exam.questions IDs
const initialSelected = (exam.questions || []).map(q => ({
id: q.id,
score: q.score || 0
}))
const selectedQuestionIds = initialSelected.map((s) => s.id)
const [bankResult, selectedResult] = await Promise.all([
getQuestions({ pageSize: 20 }),
selectedQuestionIds.length
? getQuestions({ ids: selectedQuestionIds, pageSize: Math.max(10, selectedQuestionIds.length) })
: Promise.resolve({ data: [] as Awaited<ReturnType<typeof getQuestions>>["data"] }),
])
const questionsData = bankResult.data
const selectedQuestionsData = selectedResult.data
type RawQuestion = (typeof questionsData)[number]
const toQuestionOption = (q: RawQuestion): Question => ({
id: q.id,
content: q.content,
type: q.type,
difficulty: q.difficulty ?? 1,
createdAt: new Date(q.createdAt),
updatedAt: new Date(q.updatedAt),
author: q.author
? {
id: q.author.id,
name: q.author.name || "Unknown",
image: q.author.image || null,
}
: null,
knowledgePoints: q.knowledgePoints ?? [],
})
const questionOptionsById = new Map<string, Question>()
for (const q of questionsData) questionOptionsById.set(q.id, toQuestionOption(q))
for (const q of selectedQuestionsData) questionOptionsById.set(q.id, toQuestionOption(q))
const questionOptions = Array.from(questionOptionsById.values())
let initialStructure: ExamNode[] = normalizeStructure(exam.structure)
if (initialStructure.length === 0 && initialSelected.length > 0) {
initialStructure = initialSelected.map((s) => ({
id: createId(),
type: "question",
questionId: s.id,
score: s.score,
}))
}
const aiClientService = createAiClientService()
return (
<AiClientProvider service={aiClientService}>
<div className="flex h-full flex-col space-y-4 p-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Build Exam</h1>
<p className="text-muted-foreground">Assemble questions for your exam.</p>
</div>
<ExamAssembly
examId={exam.id}
title={exam.title}
subject={exam.subject}
grade={exam.grade}
difficulty={exam.difficulty}
totalScore={exam.totalScore}
durationMin={exam.durationMin}
initialSelected={initialSelected}
initialStructure={initialStructure}
questionOptions={questionOptions}
/>
</div>
</AiClientProvider>
)
}