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:
353
src/modules/error-book/components/error-book-detail-dialog.tsx
Normal file
353
src/modules/error-book/components/error-book-detail-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
src/modules/error-book/components/error-book-list.tsx
Normal file
47
src/modules/error-book/components/error-book-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user