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
This commit is contained in:
127
src/app/(dashboard)/student/error-book/page.tsx
Normal file
127
src/app/(dashboard)/student/error-book/page.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { JSX } from "react"
|
||||
import { Suspense } from "react"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
import { getErrorBookItems, getErrorBookStats } from "@/modules/error-book/data-access"
|
||||
import { ErrorBookStatsCards } from "@/modules/error-book/components/error-book-stats-cards"
|
||||
import { ErrorBookFilters } from "@/modules/error-book/components/error-book-filters"
|
||||
import { ErrorBookList } from "@/modules/error-book/components/error-book-list"
|
||||
import { AddErrorBookDialog } from "@/modules/error-book/components/add-error-book-dialog"
|
||||
import type { ErrorBookStatusValue, ErrorBookSourceTypeValue } from "@/modules/error-book/types"
|
||||
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"
|
||||
|
||||
const VALID_STATUS = new Set(["new", "learning", "mastered", "archived"])
|
||||
const VALID_SOURCE = new Set(["exam", "homework", "manual"])
|
||||
|
||||
function parseStatus(v?: string): ErrorBookStatusValue | undefined {
|
||||
return v && VALID_STATUS.has(v) ? (v as ErrorBookStatusValue) : undefined
|
||||
}
|
||||
|
||||
function parseSource(v?: string): ErrorBookSourceTypeValue | undefined {
|
||||
return v && VALID_SOURCE.has(v) ? (v as ErrorBookSourceTypeValue) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 AI 客户端服务(Server Action 引用集合)
|
||||
*
|
||||
* 通过 React Context 注入,客户端组件不直接 import actions,
|
||||
* 遵循依赖注入模式,便于测试时替换为 mock。
|
||||
*/
|
||||
function createAiClientService(): AiClientService {
|
||||
return {
|
||||
chat: aiChatAction,
|
||||
suggestSimilarQuestions: suggestSimilarQuestionsAction,
|
||||
suggestGrading: suggestGradingAction,
|
||||
generateLessonContent: generateLessonContentAction,
|
||||
generateQuestionVariant: generateQuestionVariantAction,
|
||||
analyzeWeakness: analyzeWeaknessAction,
|
||||
}
|
||||
}
|
||||
|
||||
async function ErrorBookResults({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
|
||||
const params = await searchParams
|
||||
const ctx = await requirePermission(Permissions.ERROR_BOOK_READ)
|
||||
|
||||
const q = getParam(params, "q")
|
||||
const status = parseStatus(getParam(params, "status"))
|
||||
const sourceType = parseSource(getParam(params, "source"))
|
||||
const dueOnly = getParam(params, "due") === "due"
|
||||
|
||||
const { data: items } = await getErrorBookItems({
|
||||
studentId: ctx.userId,
|
||||
q: q || undefined,
|
||||
status,
|
||||
sourceType,
|
||||
dueOnly,
|
||||
pageSize: 50,
|
||||
})
|
||||
|
||||
return <ErrorBookList items={items} studentId={ctx.userId} errorItems={items} />
|
||||
}
|
||||
|
||||
function ErrorBookResultsFallback() {
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
<Skeleton key={idx} className="h-[180px] w-full rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function StudentErrorBookPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
const ctx = await requirePermission(Permissions.ERROR_BOOK_READ)
|
||||
const stats = await getErrorBookStats(ctx.userId)
|
||||
const aiClientService = createAiClientService()
|
||||
|
||||
return (
|
||||
<AiClientProvider service={aiClientService}>
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">错题本</h1>
|
||||
<p className="text-muted-foreground">
|
||||
自动收录考试与作业中的错题,科学复习,攻克薄弱点。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AddErrorBookDialog />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorBookStatsCards stats={stats} />
|
||||
|
||||
<div className="space-y-4">
|
||||
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
||||
<ErrorBookFilters />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<ErrorBookResultsFallback />}>
|
||||
<ErrorBookResults searchParams={searchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</AiClientProvider>
|
||||
)
|
||||
}
|
||||
@@ -7,13 +7,45 @@ 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 exam = await getExamById(id)
|
||||
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)
|
||||
@@ -69,24 +101,28 @@ export default async function BuildExamPage({ params }: { params: Promise<{ id:
|
||||
}))
|
||||
}
|
||||
|
||||
const aiClientService = createAiClientService()
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,15 +3,46 @@ import { notFound } from "next/navigation"
|
||||
import { getHomeworkSubmissionDetails } from "@/modules/homework/data-access"
|
||||
import { HomeworkGradingView } from "@/modules/homework/components/homework-grading-view"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import {
|
||||
AiClientProvider,
|
||||
type AiClientService,
|
||||
} from "@/modules/ai/context/ai-client-provider"
|
||||
import {
|
||||
suggestGradingAction,
|
||||
aiChatAction,
|
||||
suggestSimilarQuestionsAction,
|
||||
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 HomeworkSubmissionGradingPage({ params }: { params: Promise<{ submissionId: string }> }): Promise<JSX.Element> {
|
||||
const { submissionId } = await params
|
||||
const submission = await getHomeworkSubmissionDetails(submissionId)
|
||||
|
||||
if (!submission) return notFound()
|
||||
|
||||
const aiClientService = createAiClientService()
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -29,17 +60,19 @@ export default async function HomeworkSubmissionGradingPage({ params }: { params
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HomeworkGradingView
|
||||
submissionId={submission.id}
|
||||
studentName={submission.studentName}
|
||||
assignmentTitle={submission.assignmentTitle}
|
||||
submittedAt={submission.submittedAt}
|
||||
status={submission.status}
|
||||
totalScore={submission.totalScore}
|
||||
answers={submission.answers}
|
||||
prevSubmissionId={submission.prevSubmissionId}
|
||||
nextSubmissionId={submission.nextSubmissionId}
|
||||
/>
|
||||
<AiClientProvider service={aiClientService}>
|
||||
<HomeworkGradingView
|
||||
submissionId={submission.id}
|
||||
studentName={submission.studentName}
|
||||
assignmentTitle={submission.assignmentTitle}
|
||||
submittedAt={submission.submittedAt}
|
||||
status={submission.status}
|
||||
totalScore={submission.totalScore}
|
||||
answers={submission.answers}
|
||||
prevSubmissionId={submission.prevSubmissionId}
|
||||
nextSubmissionId={submission.nextSubmissionId}
|
||||
/>
|
||||
</AiClientProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,44 @@
|
||||
import type { JSX } from "react"
|
||||
import { Suspense } from "react"
|
||||
import { notFound } from "next/navigation"
|
||||
import { getLessonPlanById } from "@/modules/lesson-preparation/data-access"
|
||||
import { LessonPlanEditor } from "@/modules/lesson-preparation/components/lesson-plan-editor"
|
||||
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||
import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
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 EditLessonPlanPage({
|
||||
params,
|
||||
}: {
|
||||
@@ -23,16 +55,53 @@ export default async function EditLessonPlanPage({
|
||||
|
||||
const classes = teacherClasses.map((c) => ({ id: c.id, name: c.name }))
|
||||
|
||||
// 拉取教材/章节标题用于工具栏显示
|
||||
let textbookTitle: string | undefined
|
||||
let chapterTitle: string | undefined
|
||||
if (plan.textbookId) {
|
||||
const textbook = await getTextbookById(plan.textbookId)
|
||||
textbookTitle = textbook?.title
|
||||
if (plan.chapterId) {
|
||||
const chapters = await getChaptersByTextbookId(plan.textbookId)
|
||||
const findChapter = (list: typeof chapters): typeof chapters[number] | undefined => {
|
||||
for (const ch of list) {
|
||||
if (ch.id === plan.chapterId) return ch
|
||||
if (ch.children && ch.children.length > 0) {
|
||||
const found = findChapter(ch.children as typeof chapters)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const chapter = findChapter(chapters)
|
||||
chapterTitle = chapter?.title
|
||||
}
|
||||
}
|
||||
|
||||
const aiClientService = createAiClientService()
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)]">
|
||||
<LessonPlanEditor
|
||||
planId={plan.id}
|
||||
initialTitle={plan.title}
|
||||
initialDoc={plan.content}
|
||||
textbookId={plan.textbookId ?? undefined}
|
||||
chapterId={plan.chapterId ?? undefined}
|
||||
classes={classes}
|
||||
/>
|
||||
</div>
|
||||
<AiClientProvider service={aiClientService}>
|
||||
<div className="h-[calc(100vh-4rem)]">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Skeleton className="h-[80%] w-[80%]" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LessonPlanEditor
|
||||
planId={plan.id}
|
||||
initialTitle={plan.title}
|
||||
initialDoc={plan.content}
|
||||
textbookId={plan.textbookId ?? undefined}
|
||||
chapterId={plan.chapterId ?? undefined}
|
||||
textbookTitle={textbookTitle}
|
||||
chapterTitle={chapterTitle}
|
||||
classes={classes}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</AiClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user