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:
SpecialX
2026-06-23 00:52:39 +08:00
parent ec87cd9efa
commit 21c5eba96c
40 changed files with 4885 additions and 169 deletions

View File

@@ -0,0 +1,353 @@
"use client"
import { useState, useTransition } from "react"
import { Archive, Trash2, FileText, Calendar, History } from "lucide-react"
import { toast } from "sonner"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import { StatusBadge } from "@/shared/components/ui/status-badge"
import { Separator } from "@/shared/components/ui/separator"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { formatDate, formatDateTime } from "@/shared/lib/utils"
import {
archiveErrorBookItemAction,
deleteErrorBookItemAction,
updateErrorBookNoteAction,
} from "../actions"
import {
ERROR_BOOK_SOURCE_LABEL,
ERROR_BOOK_SOURCE_VARIANT,
ERROR_BOOK_STATUS_LABEL,
ERROR_BOOK_STATUS_VARIANT,
REVIEW_RESULT_LABEL,
REVIEW_RESULT_VARIANT,
COMMON_ERROR_TAGS,
type ErrorBookItemDetail,
type ErrorBookItem,
} from "../types"
import { ReviewButtons } from "./review-buttons"
import { AiErrorBookAnalysis } from "@/modules/ai/components/ai-error-book-analysis"
interface ErrorBookDetailDialogProps {
item: ErrorBookItemDetail | (Omit<ErrorBookItemDetail, "reviews"> & { reviews?: ErrorBookItemDetail["reviews"] })
trigger: React.ReactNode
/** 当前学生 ID用于 AI 薄弱点分析) */
studentId?: string
/** 全部错题列表(用于 AI 薄弱点分析,不传则禁用 AI 分析) */
errorItems?: ErrorBookItem[]
}
/**
* 从题目内容中提取纯文本(用于 AI 相似题推荐)
*
* 类型收窄:从 unknown 逐步缩小到具体类型,避免使用 as 断言。
*/
function extractQuestionText(content: unknown): string {
if (!content) return ""
if (typeof content === "string") return content
if (typeof content === "object" && content !== null && "text" in content) {
const textValue = (content as Record<string, unknown>).text
if (typeof textValue === "string") return textValue
}
try {
return JSON.stringify(content)
} catch {
return ""
}
}
/**
* 将错题条目转换为 AI 薄弱点分析所需的输入格式
*/
function mapErrorItemsForAnalysis(items: ErrorBookItem[]): Array<{
questionText: string
questionType: string
knowledgePointIds?: string[]
errorCount: number
masteryLevel: number
}> {
return items.map((it) => ({
questionText: extractQuestionText(it.question?.content),
questionType: it.question?.type ?? "unknown",
knowledgePointIds: it.knowledgePointIds ?? undefined,
errorCount: it.reviewCount > 0 ? it.reviewCount : 1,
masteryLevel: it.masteryLevel,
}))
}
export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }: ErrorBookDetailDialogProps) {
const [open, setOpen] = useState(false)
const [isPending, startTransition] = useTransition()
const [note, setNote] = useState(item.note ?? "")
const [errorTags, setErrorTags] = useState<string[]>(item.errorTags ?? [])
function handleSaveNote() {
startTransition(async () => {
const formData = new FormData()
formData.append(
"json",
JSON.stringify({ itemId: item.id, note, errorTags })
)
const res = await updateErrorBookNoteAction(undefined, formData)
if (res.success) {
toast.success("笔记已保存")
} else {
toast.error(res.message ?? "保存失败")
}
})
}
function handleArchive() {
startTransition(async () => {
const formData = new FormData()
formData.append("itemId", item.id)
const res = await archiveErrorBookItemAction(undefined, formData)
if (res.success) {
toast.success(res.message ?? "已归档")
setOpen(false)
} else {
toast.error(res.message ?? "归档失败")
}
})
}
function handleDelete() {
startTransition(async () => {
const formData = new FormData()
formData.append("itemId", item.id)
const res = await deleteErrorBookItemAction(undefined, formData)
if (res.success) {
toast.success(res.message ?? "已删除")
setOpen(false)
} else {
toast.error(res.message ?? "删除失败")
}
})
}
function toggleTag(tag: string) {
setErrorTags((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
)
}
// AI 分析所需数据
const currentQuestionText = extractQuestionText(item.question?.content)
const currentQuestionType = item.question?.type
const aiErrorItems = errorItems ? mapErrorItemsForAnalysis(errorItems) : []
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 flex-wrap">
<StatusBadge
status={item.status}
variantMap={ERROR_BOOK_STATUS_VARIANT}
labelMap={ERROR_BOOK_STATUS_LABEL}
capitalize={false}
/>
<StatusBadge
status={item.sourceType}
variantMap={ERROR_BOOK_SOURCE_VARIANT}
labelMap={ERROR_BOOK_SOURCE_LABEL}
capitalize={false}
/>
{item.subjectName ? (
<Badge variant="outline">{item.subjectName}</Badge>
) : null}
</DialogTitle>
<DialogDescription className="flex items-center gap-3 text-xs">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{formatDate(item.createdAt)}
</span>
<span>: {item.masteryLevel}/5</span>
<span> {item.reviewCount} </span>
</DialogDescription>
</DialogHeader>
<ScrollArea className="flex-1 -mx-6 px-6">
<div className="space-y-4 pb-4">
{/* 题目内容 */}
<section>
<h4 className="mb-2 text-sm font-medium"></h4>
<div className="rounded-md border bg-muted/30 p-3 text-sm">
{item.question ? (
<pre className="whitespace-pre-wrap break-words font-sans">
{typeof item.question.content === "string"
? item.question.content
: JSON.stringify(item.question.content, null, 2)}
</pre>
) : (
<span className="text-muted-foreground"></span>
)}
</div>
</section>
{/* 作答对比 */}
{(item.studentAnswer !== null && item.studentAnswer !== undefined) || (item.correctAnswer !== null && item.correctAnswer !== undefined) ? (
<section className="grid gap-3 sm:grid-cols-2">
{item.studentAnswer !== null && item.studentAnswer !== undefined ? (
<div>
<h4 className="mb-2 text-sm font-medium text-rose-600 dark:text-rose-400">
</h4>
<div className="rounded-md border border-rose-200 bg-rose-50/50 p-3 text-sm dark:border-rose-900 dark:bg-rose-950/20">
<pre className="whitespace-pre-wrap break-words font-sans">
{typeof item.studentAnswer === "string"
? item.studentAnswer
: JSON.stringify(item.studentAnswer, null, 2)}
</pre>
</div>
</div>
) : null}
{item.correctAnswer !== null && item.correctAnswer !== undefined ? (
<div>
<h4 className="mb-2 text-sm font-medium text-emerald-600 dark:text-emerald-400">
</h4>
<div className="rounded-md border border-emerald-200 bg-emerald-50/50 p-3 text-sm dark:border-emerald-900 dark:bg-emerald-950/20">
<pre className="whitespace-pre-wrap break-words font-sans">
{typeof item.correctAnswer === "string"
? item.correctAnswer
: JSON.stringify(item.correctAnswer, null, 2)}
</pre>
</div>
</div>
) : null}
</section>
) : null}
{/* AI 分析区(相似题推荐 + 薄弱点分析) */}
{studentId && currentQuestionText ? (
<section>
<h4 className="mb-2 text-sm font-medium">AI </h4>
<AiErrorBookAnalysis
studentId={studentId}
subjectId={item.subjectId ?? undefined}
currentQuestionText={currentQuestionText}
currentQuestionType={currentQuestionType}
errorItems={aiErrorItems}
/>
</section>
) : null}
{/* 复习区 */}
{item.status !== "mastered" && item.status !== "archived" ? (
<section>
<h4 className="mb-2 text-sm font-medium"></h4>
<ReviewButtons
itemId={item.id}
onReviewed={() => setOpen(false)}
/>
</section>
) : null}
{/* 笔记编辑 */}
<section>
<h4 className="mb-2 flex items-center gap-1 text-sm font-medium">
<FileText className="h-4 w-4" />
</h4>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="记录你的反思、解题思路、易错点..."
className="w-full min-h-[80px] rounded-md border bg-background p-3 text-sm resize-y focus:outline-none focus:ring-2 focus:ring-ring"
maxLength={2000}
/>
<div className="mt-2">
<p className="mb-1 text-xs text-muted-foreground"></p>
<div className="flex flex-wrap gap-1">
{COMMON_ERROR_TAGS.map((tag) => (
<Badge
key={tag}
variant={errorTags.includes(tag) ? "default" : "outline"}
className="cursor-pointer"
onClick={() => toggleTag(tag)}
>
{tag}
</Badge>
))}
</div>
</div>
<Button
size="sm"
variant="outline"
className="mt-2"
disabled={isPending}
onClick={handleSaveNote}
>
</Button>
</section>
{/* 复习历史 */}
{item.reviews && item.reviews.length > 0 ? (
<section>
<h4 className="mb-2 flex items-center gap-1 text-sm font-medium">
<History className="h-4 w-4" />
</h4>
<div className="space-y-1">
{item.reviews.slice(0, 10).map((r) => (
<div
key={r.id}
className="flex items-center justify-between rounded-md border px-3 py-1.5 text-xs"
>
<StatusBadge
status={r.result}
variantMap={REVIEW_RESULT_VARIANT}
labelMap={REVIEW_RESULT_LABEL}
capitalize={false}
/>
<span className="text-muted-foreground">
{formatDateTime(r.reviewedAt)}
</span>
</div>
))}
</div>
</section>
) : null}
<Separator />
{/* 操作按钮 */}
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
disabled={isPending}
onClick={handleArchive}
>
<Archive className="h-4 w-4" data-icon="inline-start" />
</Button>
<Button
variant="ghost"
size="sm"
disabled={isPending}
onClick={handleDelete}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" data-icon="inline-start" />
</Button>
</div>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,47 @@
import { BookX } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Button } from "@/shared/components/ui/button"
import { ErrorBookItemCard } from "./error-book-item-card"
import { ErrorBookDetailDialog } from "./error-book-detail-dialog"
import type { ErrorBookItem } from "../types"
interface ErrorBookListProps {
items: ErrorBookItem[]
/** 当前学生 ID用于 AI 薄弱点分析) */
studentId?: string
/** 全部错题列表(用于 AI 薄弱点分析,不传则禁用 AI 分析) */
errorItems?: ErrorBookItem[]
}
export function ErrorBookList({ items, studentId, errorItems }: ErrorBookListProps) {
if (items.length === 0) {
return (
<EmptyState
icon={BookX}
title="错题本为空"
description="完成考试或作业后,错题会自动收录到这里。你也可以手动添加错题。"
className="h-[360px] bg-card"
/>
)
}
return (
<div className="grid gap-3 md:grid-cols-2">
{items.map((item) => (
<ErrorBookItemCard key={item.id} item={item}>
<ErrorBookDetailDialog
item={item}
studentId={studentId}
errorItems={errorItems}
trigger={
<Button variant="outline" size="sm">
</Button>
}
/>
</ErrorBookItemCard>
))}
</div>
)
}