diff --git a/src/modules/error-book/components/analytics-stats-cards.tsx b/src/modules/error-book/components/analytics-stats-cards.tsx
new file mode 100644
index 0000000..df11467
--- /dev/null
+++ b/src/modules/error-book/components/analytics-stats-cards.tsx
@@ -0,0 +1,97 @@
+import { BookOpen, Brain, CheckCircle2, Clock, TrendingUp } from "lucide-react"
+import { Card, CardContent } from "@/shared/components/ui/card"
+import { cn } from "@/shared/lib/utils"
+
+interface AnalyticsStatsCardsProps {
+ totalStudents: number
+ studentsWithErrorBook: number
+ totalErrorItems: number
+ averageMasteryRate: number
+ dueReviewCount: number
+ /** 涉及的知识点数 */
+ knowledgePointCount?: number
+ className?: string
+}
+
+/**
+ * 错题分析统计卡片(教师/管理员视图)
+ * 5 个卡片:覆盖学生/错题总数/平均掌握率/待复习/知识点数
+ */
+export function AnalyticsStatsCards({
+ totalStudents,
+ studentsWithErrorBook,
+ totalErrorItems,
+ averageMasteryRate,
+ dueReviewCount,
+ knowledgePointCount,
+ className,
+}: AnalyticsStatsCardsProps) {
+ const cards = [
+ {
+ label: "覆盖学生",
+ value: studentsWithErrorBook,
+ sub: `/ ${totalStudents} 人`,
+ icon: BookOpen,
+ color: "text-blue-600 dark:text-blue-400",
+ bg: "bg-blue-50 dark:bg-blue-950/30",
+ },
+ {
+ label: "错题总数",
+ value: totalErrorItems,
+ sub: `人均 ${(totalStudents > 0 ? totalErrorItems / totalStudents : 0).toFixed(1)} 题`,
+ icon: TrendingUp,
+ color: "text-rose-600 dark:text-rose-400",
+ bg: "bg-rose-50 dark:bg-rose-950/30",
+ },
+ {
+ label: "平均掌握率",
+ value: `${Math.round(averageMasteryRate * 100)}%`,
+ sub: averageMasteryRate >= 0.6 ? "整体良好" : "需加强",
+ icon: CheckCircle2,
+ color: "text-emerald-600 dark:text-emerald-400",
+ bg: "bg-emerald-50 dark:bg-emerald-950/30",
+ },
+ {
+ label: "待复习",
+ value: dueReviewCount,
+ sub: dueReviewCount > 0 ? "需要关注" : "无到期",
+ icon: Clock,
+ color: "text-amber-600 dark:text-amber-400",
+ bg: "bg-amber-50 dark:bg-amber-950/30",
+ },
+ {
+ label: "涉及知识点",
+ value: knowledgePointCount ?? 0,
+ sub: knowledgePointCount && knowledgePointCount > 5 ? "范围较广" : "集中",
+ icon: Brain,
+ color: "text-purple-600 dark:text-purple-400",
+ bg: "bg-purple-50 dark:bg-purple-950/30",
+ },
+ ]
+
+ return (
+
+ {cards.map((card) => {
+ const Icon = card.icon
+ return (
+
+
+
+
+
{card.label}
+
+ {card.value}
+
+
{card.sub}
+
+
+
+
+
+
+
+ )
+ })}
+
+ )
+}
diff --git a/src/modules/error-book/components/chapter-weakness-chart.tsx b/src/modules/error-book/components/chapter-weakness-chart.tsx
new file mode 100644
index 0000000..5f3d3ea
--- /dev/null
+++ b/src/modules/error-book/components/chapter-weakness-chart.tsx
@@ -0,0 +1,155 @@
+"use client"
+
+import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts"
+import {
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+ type ChartConfig,
+} from "@/shared/components/ui/chart"
+import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
+import { Badge } from "@/shared/components/ui/badge"
+import { cn } from "@/shared/lib/utils"
+
+import type { ChapterWeakness } from "@/modules/error-book/types"
+
+interface ChapterWeaknessChartProps {
+ data: ChapterWeakness[]
+ className?: string
+}
+
+/**
+ * 章节薄弱度图表(哪些课在错)
+ * 横向柱状图,按错题数降序
+ * 每个柱子可展开显示该章节下错得最多的知识点
+ */
+export function ChapterWeaknessChart({ data, className }: ChapterWeaknessChartProps) {
+ if (data.length === 0) return null
+
+ const chartData = data.map((d) => ({
+ name: d.chapterTitle,
+ errorCount: d.errorCount,
+ masteredCount: d.masteredCount,
+ masteryRate: Number((d.masteryRate * 100).toFixed(0)),
+ knowledgePointCount: d.knowledgePointCount,
+ topKps: d.topKnowledgePoints,
+ }))
+
+ const chartConfig: ChartConfig = {
+ errorCount: {
+ label: "错题数",
+ color: "var(--color-chart-2)",
+ },
+ }
+
+ return (
+
+
+ 章节错题分布(哪些课在错)
+
+
+
+
+
+
+
+ value.length > 10 ? `${value.slice(0, 10)}...` : value
+ }
+ />
+ {
+ const p = payload as unknown as {
+ name: string
+ errorCount: number
+ masteredCount: number
+ masteryRate: number
+ knowledgePointCount: number
+ topKps: Array<{ knowledgePointName: string; errorCount: number }>
+ }
+ return (
+
+
{p.name}
+
+ 错题数:{p.errorCount}
+ 已掌握:{p.masteredCount}
+
+
+ 掌握率:{p.masteryRate}%
+ 知识点数:{p.knowledgePointCount}
+
+ {p.topKps && p.topKps.length > 0 ? (
+
+
薄弱知识点:
+ {p.topKps.map((kp) => (
+
+ {kp.knowledgePointName}
+ {kp.errorCount}
+
+ ))}
+
+ ) : null}
+
+ )
+ }}
+ />
+ }
+ />
+
+ {chartData.map((entry, idx) => (
+ |
+ ))}
+
+
+
+
+ {/* 章节详情列表 */}
+
+ {data.slice(0, 5).map((chapter) => (
+
+
+
+ {chapter.chapterTitle}
+
+ {chapter.knowledgePointCount} 个知识点
+
+
+ {chapter.topKnowledgePoints.length > 0 ? (
+
+ {chapter.topKnowledgePoints.map((kp) => (
+
+ {kp.knowledgePointName} · {kp.errorCount}
+
+ ))}
+
+ ) : null}
+
+
+ {chapter.errorCount}
+ 掌握 {Math.round(chapter.masteryRate * 100)}%
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/modules/error-book/components/class-error-bar-chart.tsx b/src/modules/error-book/components/class-error-bar-chart.tsx
new file mode 100644
index 0000000..ae637ac
--- /dev/null
+++ b/src/modules/error-book/components/class-error-bar-chart.tsx
@@ -0,0 +1,118 @@
+"use client"
+
+import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts"
+import {
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+ type ChartConfig,
+} from "@/shared/components/ui/chart"
+import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
+import { cn } from "@/shared/lib/utils"
+
+import type { ClassErrorOverview } from "@/modules/error-book/types"
+
+interface ClassErrorBarChartProps {
+ data: ClassErrorOverview[]
+ className?: string
+}
+
+const CHART_COLORS = [
+ "var(--color-chart-1)",
+ "var(--color-chart-2)",
+ "var(--color-chart-3)",
+ "var(--color-chart-4)",
+ "var(--color-chart-5)",
+]
+
+/**
+ * 班级错题数对比柱状图(教师视图)
+ * 横轴:班级名称,纵轴:错题总数
+ * 颜色按班级区分,tooltip 显示学生数/人均/掌握率
+ */
+export function ClassErrorBarChart({ data, className }: ClassErrorBarChartProps) {
+ if (data.length === 0) return null
+
+ const chartData = data.map((d) => ({
+ name: d.className,
+ totalErrorItems: d.totalErrorItems,
+ studentCount: d.studentCount,
+ averageErrorPerStudent: Number(d.averageErrorPerStudent.toFixed(1)),
+ averageMasteryRate: Number((d.averageMasteryRate * 100).toFixed(0)),
+ dueReviewCount: d.dueReviewCount,
+ }))
+
+ const chartConfig: ChartConfig = {
+ totalErrorItems: {
+ label: "错题总数",
+ color: "var(--color-chart-1)",
+ },
+ }
+
+ return (
+
+
+ 各班级错题数对比
+
+
+
+
+
+
+ value.length > 8 ? `${value.slice(0, 8)}...` : value
+ }
+ />
+
+ {
+ const p = payload as unknown as {
+ name: string
+ totalErrorItems: number
+ studentCount: number
+ averageErrorPerStudent: number
+ averageMasteryRate: number
+ dueReviewCount: number
+ }
+ return (
+
+
{p.name}
+
+ 错题总数:{p.totalErrorItems}
+
+
+ 学生数:{p.studentCount}
+
+
+ 人均错题:{p.averageErrorPerStudent}
+
+
+ 平均掌握率:{p.averageMasteryRate}%
+
+
+ 待复习:{p.dueReviewCount}
+
+
+ )
+ }}
+ />
+ }
+ />
+
+ {chartData.map((_, idx) => (
+ |
+ ))}
+
+
+
+
+
+ )
+}
diff --git a/src/modules/error-book/components/class-filter.tsx b/src/modules/error-book/components/class-filter.tsx
new file mode 100644
index 0000000..49a6c56
--- /dev/null
+++ b/src/modules/error-book/components/class-filter.tsx
@@ -0,0 +1,91 @@
+"use client"
+
+import { useRouter, useSearchParams } from "next/navigation"
+import { cn } from "@/shared/lib/utils"
+import { Badge } from "@/shared/components/ui/badge"
+
+import type { ClassErrorOverview } from "@/modules/error-book/types"
+
+interface ClassFilterProps {
+ classes: ClassErrorOverview[]
+ /** 当前选中的班级 ID("all" 表示全部) */
+ currentClassId: string
+ /** URL 参数名(默认 "classId") */
+ paramName?: string
+}
+
+/**
+ * 班级筛选器(教师视图)
+ * 显示教师所教的所有班级,点击切换查看单个班级
+ */
+export function ClassFilter({
+ classes,
+ currentClassId,
+ paramName = "classId",
+}: ClassFilterProps) {
+ const router = useRouter()
+ const searchParams = useSearchParams()
+
+ const handleSelect = (classId: string) => {
+ const params = new URLSearchParams(searchParams.toString())
+ if (classId === "all") {
+ params.delete(paramName)
+ } else {
+ params.set(paramName, classId)
+ }
+ router.push(`?${params.toString()}`, { scroll: false })
+ }
+
+ if (classes.length === 0) return null
+
+ return (
+
+
+ {classes.map((cls) => {
+ const isActive = currentClassId === cls.classId
+ return (
+
+ )
+ })}
+
+ )
+}
diff --git a/src/modules/error-book/components/error-book-detail-dialog.tsx b/src/modules/error-book/components/error-book-detail-dialog.tsx
index 54dbbec..e9da598 100644
--- a/src/modules/error-book/components/error-book-detail-dialog.tsx
+++ b/src/modules/error-book/components/error-book-detail-dialog.tsx
@@ -1,7 +1,9 @@
"use client"
import { useState, useTransition } from "react"
-import { Archive, Trash2, FileText, Calendar, History } from "lucide-react"
+import { useRouter } from "next/navigation"
+import { useTranslations } from "next-intl"
+import { Archive, Trash2, FileText, Calendar, History, Target } from "lucide-react"
import { toast } from "sonner"
import {
@@ -36,6 +38,7 @@ import {
} from "../types"
import { ReviewButtons } from "./review-buttons"
import { AiErrorBookAnalysis } from "@/modules/ai/components/ai-error-book-analysis"
+import { createPracticeSessionAction } from "@/modules/adaptive-practice/actions"
interface ErrorBookDetailDialogProps {
item: ErrorBookItemDetail | (Omit & { reviews?: ErrorBookItemDetail["reviews"] })
@@ -89,6 +92,9 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
const [isPending, startTransition] = useTransition()
const [note, setNote] = useState(item.note ?? "")
const [errorTags, setErrorTags] = useState(item.errorTags ?? [])
+ const router = useRouter()
+ const t = useTranslations("error-book")
+ const tPractice = useTranslations("practice")
function handleSaveNote() {
startTransition(async () => {
@@ -99,9 +105,9 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
)
const res = await updateErrorBookNoteAction(undefined, formData)
if (res.success) {
- toast.success("笔记已保存")
+ toast.success(t("messages.noteSaved"))
} else {
- toast.error(res.message ?? "保存失败")
+ toast.error(res.message ?? t("messages.saveFailed"))
}
})
}
@@ -112,10 +118,10 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
formData.append("itemId", item.id)
const res = await archiveErrorBookItemAction(undefined, formData)
if (res.success) {
- toast.success(res.message ?? "已归档")
+ toast.success(t("messages.archived"))
setOpen(false)
} else {
- toast.error(res.message ?? "归档失败")
+ toast.error(res.message ?? t("messages.archiveFailed"))
}
})
}
@@ -126,10 +132,42 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
formData.append("itemId", item.id)
const res = await deleteErrorBookItemAction(undefined, formData)
if (res.success) {
- toast.success(res.message ?? "已删除")
+ toast.success(t("messages.deleted"))
setOpen(false)
} else {
- toast.error(res.message ?? "删除失败")
+ toast.error(res.message ?? t("messages.deleteFailed"))
+ }
+ })
+ }
+
+ /**
+ * 发起错题变式练习。
+ *
+ * 从当前错题出发,创建一个 error_variant 类型的练习会话,
+ * 使用该错题关联的原题进行针对性练习。
+ */
+ function handleStartVariantPractice() {
+ startTransition(async () => {
+ const formData = new FormData()
+ formData.append(
+ "json",
+ JSON.stringify({
+ practiceType: "error_variant",
+ subjectId: item.subjectId ?? undefined,
+ sourceMeta: {
+ errorBookItemIds: [item.id],
+ sourceQuestionIds: [item.questionId],
+ },
+ questionCount: 10,
+ }),
+ )
+ const res = await createPracticeSessionAction(undefined, formData)
+ if (res.success && res.data) {
+ toast.success(res.message ?? tPractice("starter.title"))
+ setOpen(false)
+ router.push(`/student/practice/${res.data.sessionId}`)
+ } else {
+ toast.error(res.message ?? t("messages.saveFailed"))
}
})
}
@@ -243,6 +281,22 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
) : null}
+ {/* 变式练习入口 */}
+
+
+
+ {tPractice("starter.description")}
+
+
+
{/* 复习区 */}
{item.status !== "mastered" && item.status !== "archived" ? (
diff --git a/src/modules/error-book/components/grouped-student-error-table.tsx b/src/modules/error-book/components/grouped-student-error-table.tsx
new file mode 100644
index 0000000..537e66b
--- /dev/null
+++ b/src/modules/error-book/components/grouped-student-error-table.tsx
@@ -0,0 +1,209 @@
+"use client"
+
+import { useState } from "react"
+import { ChevronDown, ChevronRight, Users } from "lucide-react"
+
+import { Badge } from "@/shared/components/ui/badge"
+import { Progress } from "@/shared/components/ui/progress"
+import { cn } from "@/shared/lib/utils"
+
+import type { StudentErrorBookSummary } from "@/modules/error-book/types"
+
+interface GroupedStudentErrorTableProps {
+ students: StudentErrorBookSummary[]
+ studentNames: Map
+ basePath: string
+}
+
+interface ClassGroup {
+ classId: string | null
+ className: string
+ students: StudentErrorBookSummary[]
+ totalErrors: number
+ averageMasteryRate: number
+}
+
+/**
+ * 按班级分组的学生错题表格(教师视图)
+ * 每个班级可展开/折叠,显示该班学生的错题详情
+ */
+export function GroupedStudentErrorTable({
+ students,
+ studentNames,
+ basePath,
+}: GroupedStudentErrorTableProps) {
+ const [expandedClasses, setExpandedClasses] = useState>(new Set())
+
+ // 按班级分组
+ const groups: ClassGroup[] = []
+ const groupMap = new Map()
+
+ for (const student of students) {
+ const key = student.classId
+ let group = groupMap.get(key)
+ if (!group) {
+ group = {
+ classId: key,
+ className: student.className ?? "未分班",
+ students: [],
+ totalErrors: 0,
+ averageMasteryRate: 0,
+ }
+ groupMap.set(key, group)
+ groups.push(group)
+ }
+ group.students.push(student)
+ group.totalErrors += student.totalCount
+ }
+
+ // 计算每组的平均掌握率并排序
+ for (const group of groups) {
+ const studentsWithErrors = group.students.filter((s) => s.totalCount > 0)
+ group.averageMasteryRate =
+ studentsWithErrors.length > 0
+ ? studentsWithErrors.reduce((sum, s) => sum + s.masteredRate, 0) / studentsWithErrors.length
+ : 0
+ // 按错题数降序
+ group.students.sort((a, b) => b.totalCount - a.totalCount)
+ }
+ groups.sort((a, b) => b.totalErrors - a.totalErrors)
+
+ const toggleClass = (classId: string | null) => {
+ const key = classId ?? "unclassified"
+ setExpandedClasses((prev) => {
+ const next = new Set(prev)
+ if (next.has(key)) {
+ next.delete(key)
+ } else {
+ next.add(key)
+ }
+ return next
+ })
+ }
+
+ if (groups.length === 0) return null
+
+ return (
+
+ {groups.map((group) => {
+ const groupKey = group.classId ?? "unclassified"
+ const isExpanded = expandedClasses.has(groupKey)
+ const studentsWithErrors = group.students.filter((s) => s.totalCount > 0)
+
+ return (
+
+ {/* 班级头部(可点击展开) */}
+
+
+ {/* 学生表格(展开时显示) */}
+ {isExpanded ? (
+
+
+
+
+ | 学生 |
+ 错题总数 |
+ 待学习 |
+ 学习中 |
+ 已掌握 |
+ 待复习 |
+ 掌握率 |
+
+
+
+ {group.students.map((student) => {
+ const name = studentNames.get(student.studentId) ?? "未知"
+ const hasErrors = student.totalCount > 0
+ return (
+
+ |
+ {hasErrors ? (
+
+ {name}
+
+ ) : (
+ {name}
+ )}
+ |
+
+ {student.totalCount}
+ |
+
+ {student.newCount}
+ |
+
+ {student.learningCount}
+ |
+
+ {student.masteredCount}
+ |
+
+ {student.dueReviewCount}
+ |
+
+
+
+
+ {Math.round(student.masteredRate * 100)}%
+
+
+ |
+
+ )
+ })}
+
+
+
+ ) : null}
+
+ )
+ })}
+
+ )
+}
diff --git a/src/modules/error-book/components/knowledge-point-weakness-chart.tsx b/src/modules/error-book/components/knowledge-point-weakness-chart.tsx
new file mode 100644
index 0000000..cd396ef
--- /dev/null
+++ b/src/modules/error-book/components/knowledge-point-weakness-chart.tsx
@@ -0,0 +1,156 @@
+"use client"
+
+import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts"
+import {
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+ type ChartConfig,
+} from "@/shared/components/ui/chart"
+import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
+import { Badge } from "@/shared/components/ui/badge"
+import { cn } from "@/shared/lib/utils"
+
+import type { KnowledgePointWeakness } from "@/modules/error-book/types"
+
+interface KnowledgePointWeaknessChartProps {
+ data: KnowledgePointWeakness[]
+ className?: string
+}
+
+/**
+ * 知识点薄弱度图表
+ * 横向柱状图,按错题数降序
+ * 颜色按掌握率区分(红/黄/绿)
+ * 显示所属章节
+ */
+export function KnowledgePointWeaknessChart({
+ data,
+ className,
+}: KnowledgePointWeaknessChartProps) {
+ if (data.length === 0) return null
+
+ const chartData = data.map((d) => ({
+ name: d.knowledgePointName,
+ errorCount: d.errorCount,
+ masteredCount: d.masteredCount,
+ masteryRate: Number((d.masteryRate * 100).toFixed(0)),
+ chapterTitle: d.chapterTitle ?? "未分类",
+ }))
+
+ const chartConfig: ChartConfig = {
+ errorCount: {
+ label: "错题数",
+ color: "var(--color-chart-1)",
+ },
+ }
+
+ return (
+
+
+ 薄弱知识点 Top {data.length}
+
+
+
+
+
+
+
+ value.length > 8 ? `${value.slice(0, 8)}...` : value
+ }
+ />
+ {
+ const p = payload as unknown as {
+ name: string
+ errorCount: number
+ masteredCount: number
+ masteryRate: number
+ chapterTitle: string
+ }
+ return (
+
+
{p.name}
+
+ 所属章节:{p.chapterTitle}
+
+
+ 错题数:{p.errorCount}
+ 已掌握:{p.masteredCount}
+
+
+ 掌握率:{p.masteryRate}%
+
+
+ )
+ }}
+ />
+ }
+ />
+
+ {chartData.map((entry, idx) => (
+ |
+ ))}
+
+
+
+
+ {/* 知识点详情列表(带章节归属) */}
+
+ {data.slice(0, 8).map((kp) => (
+
+
+
{kp.knowledgePointName}
+ {kp.chapterTitle ? (
+
+ {kp.chapterTitle}
+
+ ) : null}
+
+
+
+ {Math.round(kp.masteryRate * 100)}%
+
+ {kp.errorCount}
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/modules/error-book/components/subject-distribution-chart.tsx b/src/modules/error-book/components/subject-distribution-chart.tsx
new file mode 100644
index 0000000..a316c3f
--- /dev/null
+++ b/src/modules/error-book/components/subject-distribution-chart.tsx
@@ -0,0 +1,111 @@
+"use client"
+
+import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts"
+import {
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+ type ChartConfig,
+} from "@/shared/components/ui/chart"
+import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
+import { cn } from "@/shared/lib/utils"
+
+import type { SubjectErrorDistribution } from "@/modules/error-book/types"
+
+interface SubjectDistributionChartProps {
+ data: SubjectErrorDistribution[]
+ className?: string
+}
+
+const SUBJECT_COLORS = [
+ "var(--color-chart-1)",
+ "var(--color-chart-2)",
+ "var(--color-chart-3)",
+ "var(--color-chart-4)",
+ "var(--color-chart-5)",
+]
+
+/**
+ * 学科错题分布柱状图(管理员视图)
+ * 横轴:学科名称,纵轴:错题数
+ * tooltip 显示已掌握/掌握率
+ */
+export function SubjectDistributionChart({
+ data,
+ className,
+}: SubjectDistributionChartProps) {
+ if (data.length === 0) return null
+
+ const chartData = data.map((d) => ({
+ name: d.subjectName,
+ errorCount: d.errorCount,
+ masteredCount: d.masteredCount,
+ masteryRate: Number((d.masteryRate * 100).toFixed(0)),
+ }))
+
+ const chartConfig: ChartConfig = {
+ errorCount: {
+ label: "错题数",
+ color: "var(--color-chart-1)",
+ },
+ }
+
+ return (
+
+
+ 各学科错题分布
+
+
+
+
+
+
+ value.length > 6 ? `${value.slice(0, 6)}...` : value
+ }
+ />
+
+ {
+ const p = payload as unknown as {
+ name: string
+ errorCount: number
+ masteredCount: number
+ masteryRate: number
+ }
+ return (
+
+
{p.name}
+
+ 错题数:{p.errorCount}
+
+
+ 已掌握:{p.masteredCount}
+
+
+ 掌握率:{p.masteryRate}%
+
+
+ )
+ }}
+ />
+ }
+ />
+
+ {chartData.map((_, idx) => (
+ |
+ ))}
+
+
+
+
+
+ )
+}
diff --git a/src/modules/error-book/components/subject-tabs.tsx b/src/modules/error-book/components/subject-tabs.tsx
new file mode 100644
index 0000000..c686736
--- /dev/null
+++ b/src/modules/error-book/components/subject-tabs.tsx
@@ -0,0 +1,99 @@
+"use client"
+
+import { useRouter, useSearchParams } from "next/navigation"
+import { cn } from "@/shared/lib/utils"
+import { Badge } from "@/shared/components/ui/badge"
+
+import type { SubjectErrorOverview } from "@/modules/error-book/types"
+
+interface SubjectTabsProps {
+ subjects: SubjectErrorOverview[]
+ /** 当前选中的学科 ID(null 表示全部) */
+ currentSubjectId: string | null
+ /** URL 参数名(默认 "subject") */
+ paramName?: string
+}
+
+/**
+ * 学科切换 Tab(教师/管理员视图)
+ * 显示每个学科的错题数概览,点击切换
+ */
+export function SubjectTabs({
+ subjects,
+ currentSubjectId,
+ paramName = "subject",
+}: SubjectTabsProps) {
+ const router = useRouter()
+ const searchParams = useSearchParams()
+
+ const handleSelect = (subjectId: string | null) => {
+ const params = new URLSearchParams(searchParams.toString())
+ if (subjectId === null) {
+ params.delete(paramName)
+ } else {
+ params.set(paramName, subjectId)
+ }
+ router.push(`?${params.toString()}`, { scroll: false })
+ }
+
+ if (subjects.length === 0) return null
+
+ const totalErrors = subjects.reduce((sum, s) => sum + s.totalErrorItems, 0)
+
+ return (
+
+
+ {subjects.map((subject) => {
+ const isActive = currentSubjectId === subject.subjectId
+ return (
+
+ )
+ })}
+
+ )
+}
diff --git a/src/modules/error-book/data-access-collection.ts b/src/modules/error-book/data-access-collection.ts
new file mode 100644
index 0000000..1a0db17
--- /dev/null
+++ b/src/modules/error-book/data-access-collection.ts
@@ -0,0 +1,170 @@
+import "server-only"
+
+import { and, eq, inArray } from "drizzle-orm"
+import { createId } from "@paralleldrive/cuid2"
+
+import { db } from "@/shared/db"
+import { errorBookItems } from "@/shared/db/schema"
+import { getExamSubmissionDataForErrorCollection } from "@/modules/exams/data-access-error-collection"
+import { getHomeworkSubmissionDataForErrorCollection } from "@/modules/homework/data-access-error-collection"
+import {
+ getKnowledgePointsForQuestions,
+ getQuestionsContentForErrorCollection,
+} from "@/modules/questions/data-access"
+import { extractCorrectAnswer } from "@/shared/lib/question-content"
+
+/**
+ * 错题采集的答案数据(由各模块的跨模块接口返回)
+ */
+type AnswerForCollection = {
+ questionId: string
+ answerContent: unknown
+ score: number | null
+ feedback: string | null
+ maxScore: number
+}
+
+/**
+ * 从答案列表中采集错题并写入 errorBookItems 表。
+ *
+ * 这是 collectFromExamSubmission 和 collectFromHomeworkSubmission 的共享逻辑:
+ * 1. 筛选错题(score < maxScore)
+ * 2. 查询已存在的错题避免重复
+ * 3. 获取题目关联的知识点(通过 questions 模块跨模块接口)
+ * 4. 获取题目内容并提取正确答案(通过 questions 模块 + shared 纯函数)
+ * 5. 批量插入错题
+ *
+ * @param studentId 学生 ID
+ * @param sourceType 错题来源类型 ("exam" | "homework")
+ * @param sourceId 来源提交 ID
+ * @param subjectId 学科 ID(可为 null)
+ * @param answers 答案列表
+ * @returns 新采集的错题数量
+ */
+async function collectErrorItemsFromAnswers(
+ studentId: string,
+ sourceType: "exam" | "homework",
+ sourceId: string,
+ subjectId: string | null,
+ answers: AnswerForCollection[],
+): Promise {
+ // 筛选错题:得分为 0 或低于满分
+ const wrongAnswers = answers.filter((a) => (a.score ?? 0) < a.maxScore)
+
+ if (wrongAnswers.length === 0) return 0
+
+ const wrongQuestionIds = wrongAnswers.map((a) => a.questionId)
+
+ // 并行查询:已存在的错题、知识点关联、题目内容
+ const [existing, kpMap, questionContentMap] = await Promise.all([
+ db
+ .select({ questionId: errorBookItems.questionId })
+ .from(errorBookItems)
+ .where(
+ and(
+ eq(errorBookItems.studentId, studentId),
+ inArray(errorBookItems.questionId, wrongQuestionIds),
+ ),
+ ),
+ getKnowledgePointsForQuestions(wrongQuestionIds),
+ getQuestionsContentForErrorCollection(wrongQuestionIds),
+ ])
+
+ const existingSet = new Set(existing.map((e) => e.questionId))
+
+ const now = new Date()
+ const toInsert = wrongAnswers
+ .filter((a) => !existingSet.has(a.questionId))
+ .map((a) => {
+ // 从题目内容中提取正确答案
+ const questionData = questionContentMap.get(a.questionId)
+ const correctAnswer = questionData
+ ? extractCorrectAnswer(questionData.type, questionData.content)
+ : null
+
+ // 提取知识点 ID 列表
+ const kpLinks = kpMap.get(a.questionId) ?? []
+ const knowledgePointIds = kpLinks.length > 0 ? kpLinks.map((k) => k.knowledgePointId) : null
+
+ return {
+ id: createId(),
+ studentId,
+ questionId: a.questionId,
+ sourceType,
+ sourceId,
+ studentAnswer: a.answerContent,
+ correctAnswer,
+ subjectId,
+ knowledgePointIds,
+ status: "new" as const,
+ masteryLevel: 0,
+ nextReviewAt: now,
+ reviewInterval: 1,
+ reviewCount: 0,
+ correctStreak: 0,
+ note: a.feedback ?? null,
+ errorTags: null,
+ }
+ })
+
+ if (toInsert.length > 0) {
+ await db.insert(errorBookItems).values(toInsert)
+ }
+
+ return toInsert.length
+}
+
+/**
+ * 自动采集:从考试提交中收集错题。
+ *
+ * 通过 exams 模块的跨模块接口获取提交数据,避免直接查询
+ * examSubmissions、submissionAnswers、examQuestions、exams 等表。
+ *
+ * @param submissionId 考试提交 ID
+ * @param studentId 学生 ID(用于校验提交归属)
+ * @returns 新采集的错题数量
+ * @throws 若提交记录不存在或 studentId 不匹配
+ */
+export async function collectFromExamSubmission(
+ submissionId: string,
+ studentId: string,
+): Promise {
+ const data = await getExamSubmissionDataForErrorCollection(submissionId, studentId)
+ if (!data) throw new Error("考试提交记录不存在")
+
+ return collectErrorItemsFromAnswers(
+ studentId,
+ "exam",
+ submissionId,
+ data.subjectId,
+ data.answers,
+ )
+}
+
+/**
+ * 自动采集:从作业提交中收集错题。
+ *
+ * 通过 homework 模块的跨模块接口获取提交数据,避免直接查询
+ * homeworkSubmissions、homeworkAssignments、homeworkAnswers、
+ * homeworkAssignmentQuestions、exams 等表。
+ *
+ * @param submissionId 作业提交 ID
+ * @param studentId 学生 ID
+ * @returns 新采集的错题数量
+ * @throws 若提交记录不存在
+ */
+export async function collectFromHomeworkSubmission(
+ submissionId: string,
+ studentId: string,
+): Promise {
+ const data = await getHomeworkSubmissionDataForErrorCollection(submissionId)
+ if (!data) throw new Error("作业提交记录不存在")
+
+ return collectErrorItemsFromAnswers(
+ studentId,
+ "homework",
+ submissionId,
+ data.subjectId,
+ data.answers,
+ )
+}
diff --git a/src/modules/error-book/data-access.ts b/src/modules/error-book/data-access.ts
index 1c3ec4d..68d906c 100644
--- a/src/modules/error-book/data-access.ts
+++ b/src/modules/error-book/data-access.ts
@@ -8,17 +8,14 @@ import { db } from "@/shared/db"
import {
errorBookItems,
errorBookReviews,
- examSubmissions,
- submissionAnswers,
- homeworkSubmissions,
- homeworkAnswers,
questions,
questionsToKnowledgePoints,
knowledgePoints,
+ chapters,
subjects,
- examQuestions,
- homeworkAssignmentQuestions,
users,
+ classEnrollments,
+ classes,
} from "@/shared/db/schema"
import { getStudentIdsByClassIds } from "@/modules/classes/data-access"
import {
@@ -37,6 +34,11 @@ import type {
ErrorBookStats,
ErrorBookStatusValue,
GetErrorBookItemsParams,
+ KnowledgePointWeakness,
+ ChapterWeakness,
+ ClassErrorOverview,
+ SubjectErrorOverview,
+ StudentErrorBookSummary,
} from "./types"
import type { ErrorBookReviewResult } from "./schema"
@@ -436,259 +438,41 @@ export async function archiveErrorBookItem(itemId: string, studentId: string): P
}
// ---------------------------------------------------------------------------
-// 自动采集:从考试提交中收集错题
+// 自动采集函数已迁移至 data-access-collection.ts
+// 通过跨模块接口(exams/homework/questions data-access)避免直查多表,修复三层架构违规
// ---------------------------------------------------------------------------
-export async function collectFromExamSubmission(
- submissionId: string,
- studentId: string
-): Promise {
- const submission = await db.query.examSubmissions.findFirst({
- where: and(
- eq(examSubmissions.id, submissionId),
- eq(examSubmissions.studentId, studentId)
- ),
- })
-
- if (!submission) throw new Error("考试提交记录不存在")
-
- // 查询该提交的所有作答
- const answers = await db
- .select({
- answerId: submissionAnswers.id,
- questionId: submissionAnswers.questionId,
- answerContent: submissionAnswers.answerContent,
- score: submissionAnswers.score,
- feedback: submissionAnswers.feedback,
- })
- .from(submissionAnswers)
- .where(eq(submissionAnswers.submissionId, submissionId))
-
- // 查询题目满分(用于判断是否答错)
- const questionIds = answers.map((a) => a.questionId)
- const examQuestionScores = await db
- .select({
- questionId: examQuestions.questionId,
- maxScore: examQuestions.score,
- })
- .from(examQuestions)
- .where(
- and(
- eq(examQuestions.examId, submission.examId),
- inArray(examQuestions.questionId, questionIds)
- )
- )
-
- const maxScoreMap = new Map(examQuestionScores.map((q) => [q.questionId, q.maxScore ?? 0]))
-
- // 筛选错题:得分为 0 或低于满分
- const wrongAnswers = answers.filter((a) => {
- const max = maxScoreMap.get(a.questionId) ?? 0
- return (a.score ?? 0) < max
- })
-
- if (wrongAnswers.length === 0) return 0
-
- // 查询已存在的错题,避免重复
- const existing = await db
- .select({ questionId: errorBookItems.questionId })
- .from(errorBookItems)
- .where(
- and(
- eq(errorBookItems.studentId, studentId),
- inArray(
- errorBookItems.questionId,
- wrongAnswers.map((a) => a.questionId)
- )
- )
- )
- const existingSet = new Set(existing.map((e) => e.questionId))
-
- // 查询题目关联的知识点
- const kpRows = await db
- .select({
- questionId: questionsToKnowledgePoints.questionId,
- knowledgePointId: questionsToKnowledgePoints.knowledgePointId,
- })
- .from(questionsToKnowledgePoints)
- .where(
- inArray(
- questionsToKnowledgePoints.questionId,
- wrongAnswers.map((a) => a.questionId)
- )
- )
- const kpMap = new Map()
- for (const kp of kpRows) {
- const list = kpMap.get(kp.questionId) ?? []
- list.push(kp.knowledgePointId)
- kpMap.set(kp.questionId, list)
- }
-
- // 批量插入
- const now = new Date()
- const toInsert = wrongAnswers
- .filter((a) => !existingSet.has(a.questionId))
- .map((a) => ({
- id: createId(),
- studentId,
- questionId: a.questionId,
- sourceType: "exam" as const,
- sourceId: submissionId,
- studentAnswer: a.answerContent,
- correctAnswer: null,
- subjectId: null,
- knowledgePointIds: kpMap.get(a.questionId) ?? null,
- status: "new" as const,
- masteryLevel: 0,
- nextReviewAt: now,
- reviewInterval: 1,
- reviewCount: 0,
- correctStreak: 0,
- note: a.feedback ?? null,
- errorTags: null,
- }))
-
- if (toInsert.length > 0) {
- await db.insert(errorBookItems).values(toInsert)
- }
-
- return toInsert.length
-}
-
-// ---------------------------------------------------------------------------
-// 自动采集:从作业提交中收集错题
-// ---------------------------------------------------------------------------
-
-export async function collectFromHomeworkSubmission(
- submissionId: string,
- studentId: string
-): Promise {
- const submission = await db.query.homeworkSubmissions.findFirst({
- where: eq(homeworkSubmissions.id, submissionId),
- })
-
- if (!submission) throw new Error("作业提交记录不存在")
-
- const answers = await db
- .select({
- answerId: homeworkAnswers.id,
- questionId: homeworkAnswers.questionId,
- answerContent: homeworkAnswers.answerContent,
- score: homeworkAnswers.score,
- feedback: homeworkAnswers.feedback,
- })
- .from(homeworkAnswers)
- .where(eq(homeworkAnswers.submissionId, submissionId))
-
- // 查询题目满分
- const questionIds = answers.map((a) => a.questionId)
- const hwQuestionScores = await db
- .select({
- questionId: homeworkAssignmentQuestions.questionId,
- maxScore: homeworkAssignmentQuestions.score,
- })
- .from(homeworkAssignmentQuestions)
- .where(
- and(
- eq(homeworkAssignmentQuestions.assignmentId, submission.assignmentId),
- inArray(homeworkAssignmentQuestions.questionId, questionIds)
- )
- )
-
- const maxScoreMap = new Map(hwQuestionScores.map((q) => [q.questionId, q.maxScore ?? 0]))
-
- const wrongAnswers = answers.filter((a) => {
- const max = maxScoreMap.get(a.questionId) ?? 0
- return (a.score ?? 0) < max
- })
-
- if (wrongAnswers.length === 0) return 0
-
- // 去重
- const existing = await db
- .select({ questionId: errorBookItems.questionId })
- .from(errorBookItems)
- .where(
- and(
- eq(errorBookItems.studentId, studentId),
- inArray(
- errorBookItems.questionId,
- wrongAnswers.map((a) => a.questionId)
- )
- )
- )
- const existingSet = new Set(existing.map((e) => e.questionId))
-
- // 查询知识点
- const kpRows = await db
- .select({
- questionId: questionsToKnowledgePoints.questionId,
- knowledgePointId: questionsToKnowledgePoints.knowledgePointId,
- })
- .from(questionsToKnowledgePoints)
- .where(
- inArray(
- questionsToKnowledgePoints.questionId,
- wrongAnswers.map((a) => a.questionId)
- )
- )
- const kpMap = new Map()
- for (const kp of kpRows) {
- const list = kpMap.get(kp.questionId) ?? []
- list.push(kp.knowledgePointId)
- kpMap.set(kp.questionId, list)
- }
-
- const now = new Date()
- const toInsert = wrongAnswers
- .filter((a) => !existingSet.has(a.questionId))
- .map((a) => ({
- id: createId(),
- studentId,
- questionId: a.questionId,
- sourceType: "homework" as const,
- sourceId: submissionId,
- studentAnswer: a.answerContent,
- correctAnswer: null,
- subjectId: null,
- knowledgePointIds: kpMap.get(a.questionId) ?? null,
- status: "new" as const,
- masteryLevel: 0,
- nextReviewAt: now,
- reviewInterval: 1,
- reviewCount: 0,
- correctStreak: 0,
- note: a.feedback ?? null,
- errorTags: null,
- }))
-
- if (toInsert.length > 0) {
- await db.insert(errorBookItems).values(toInsert)
- }
-
- return toInsert.length
-}
+export {
+ collectFromExamSubmission,
+ collectFromHomeworkSubmission,
+} from "./data-access-collection"
// ---------------------------------------------------------------------------
// 跨模块查询接口:供教师/家长视图使用
// ---------------------------------------------------------------------------
-/** 查询多个学生的错题统计(教师视图) */
+/** 构建学生错题查询的 where 条件(支持按学科过滤) */
+function buildStudentErrorWhereClause(
+ studentIds: string[],
+ subjectId?: string | null
+): SQL | undefined {
+ const conditions: SQL[] = [inArray(errorBookItems.studentId, studentIds)]
+ if (subjectId) {
+ conditions.push(eq(errorBookItems.subjectId, subjectId))
+ }
+ return and(...conditions)
+}
+
+/** 查询多个学生的错题统计(教师视图,支持按学科过滤) */
export async function getStudentErrorBookSummaries(
- studentIds: string[]
-): Promise> {
+ studentIds: string[],
+ subjectId?: string | null
+): Promise {
if (studentIds.length === 0) return []
const now = new Date()
+ const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)
+
const rows = await db
.select({
studentId: errorBookItems.studentId,
@@ -697,7 +481,26 @@ export async function getStudentErrorBookSummaries(
updatedAt: errorBookItems.updatedAt,
})
.from(errorBookItems)
- .where(inArray(errorBookItems.studentId, studentIds))
+ .where(whereClause)
+
+ // 查询学生所属班级(用于按班级分组展示)
+ const enrollmentRows = await db
+ .select({
+ studentId: classEnrollments.studentId,
+ classId: classes.id,
+ className: classes.name,
+ })
+ .from(classEnrollments)
+ .innerJoin(classes, eq(classEnrollments.classId, classes.id))
+ .where(inArray(classEnrollments.studentId, studentIds))
+
+ const studentClassMap = new Map()
+ for (const row of enrollmentRows) {
+ // 取第一个班级(学生通常只属于一个班)
+ if (!studentClassMap.has(row.studentId)) {
+ studentClassMap.set(row.studentId, { classId: row.classId, className: row.className })
+ }
+ }
const map = new Map ({
- studentId,
- ...stat,
- masteredRate: stat.totalCount > 0 ? stat.masteredCount / stat.totalCount : 0,
- }))
+ return Array.from(map.entries()).map(([studentId, stat]) => {
+ const classInfo = studentClassMap.get(studentId)
+ return {
+ studentId,
+ ...stat,
+ masteredRate: stat.totalCount > 0 ? stat.masteredCount / stat.totalCount : 0,
+ classId: classInfo?.classId ?? null,
+ className: classInfo?.className ?? null,
+ }
+ })
}
-/** 查询班级内错题最多的题目(教师视图:高频错题) */
+/** 查询班级内错题最多的题目(教师视图:高频错题,支持按学科过滤) */
export async function getTopWrongQuestionsByStudentIds(
studentIds: string[],
- limit = 10
+ limit = 10,
+ subjectId?: string | null
): Promise> {
if (studentIds.length === 0) return []
+ const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)
+
const rows = await db
.select({
questionId: errorBookItems.questionId,
@@ -765,7 +576,7 @@ export async function getTopWrongQuestionsByStudentIds(
})
.from(errorBookItems)
.innerJoin(questions, eq(questions.id, errorBookItems.questionId))
- .where(inArray(errorBookItems.studentId, studentIds))
+ .where(whereClause)
const map = new Map()
@@ -816,23 +627,19 @@ export async function getAllStudentIds(): Promise {
}
// ---------------------------------------------------------------------------
-// 统计:知识点薄弱度 & 学科分布(教师/管理员视图)
+// 统计:知识点薄弱度 & 学科分布 & 章节维度(教师/管理员视图)
// ---------------------------------------------------------------------------
-/** 查询多个学生的知识点薄弱度统计 */
+/** 查询多个学生的知识点薄弱度统计(支持按学科过滤,关联章节信息) */
export async function getKnowledgePointWeakness(
studentIds: string[],
- limit = 10
-): Promise> {
+ limit = 10,
+ subjectId?: string | null
+): Promise {
if (studentIds.length === 0) return []
+ const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)
+
// 查询这些学生的所有错题条目(含知识点)
const rows = await db
.select({
@@ -841,7 +648,7 @@ export async function getKnowledgePointWeakness(
knowledgePointIds: errorBookItems.knowledgePointIds,
})
.from(errorBookItems)
- .where(inArray(errorBookItems.studentId, studentIds))
+ .where(whereClause)
// 展开知识点并统计
const kpMap = new Map()
@@ -858,23 +665,45 @@ export async function getKnowledgePointWeakness(
if (kpMap.size === 0) return []
- // 查询知识点名称
+ // 查询知识点名称及所属章节
const kpIds = Array.from(kpMap.keys())
const kpRows = await db
- .select({ id: knowledgePoints.id, name: knowledgePoints.name })
+ .select({
+ id: knowledgePoints.id,
+ name: knowledgePoints.name,
+ chapterId: knowledgePoints.chapterId,
+ })
.from(knowledgePoints)
.where(inArray(knowledgePoints.id, kpIds))
- const kpNameMap = new Map(kpRows.map((k) => [k.id, k.name]))
+ const kpInfoMap = new Map(kpRows.map((k) => [k.id, { name: k.name, chapterId: k.chapterId }]))
+
+ // 查询章节标题
+ const chapterIds = Array.from(new Set(
+ kpRows.map((k) => k.chapterId).filter((c): c is string => c !== null)
+ ))
+ let chapterTitleMap = new Map()
+ if (chapterIds.length > 0) {
+ const chapterRows = await db
+ .select({ id: chapters.id, title: chapters.title })
+ .from(chapters)
+ .where(inArray(chapters.id, chapterIds))
+ chapterTitleMap = new Map(chapterRows.map((c) => [c.id, c.title]))
+ }
return Array.from(kpMap.entries())
- .map(([kpId, stat]) => ({
- knowledgePointId: kpId,
- knowledgePointName: kpNameMap.get(kpId) ?? "未知知识点",
- errorCount: stat.errorCount,
- masteredCount: stat.masteredCount,
- totalCount: stat.errorCount,
- masteryRate: stat.errorCount > 0 ? stat.masteredCount / stat.errorCount : 0,
- }))
+ .map(([kpId, stat]) => {
+ const info = kpInfoMap.get(kpId)
+ return {
+ knowledgePointId: kpId,
+ knowledgePointName: info?.name ?? "未知知识点",
+ errorCount: stat.errorCount,
+ masteredCount: stat.masteredCount,
+ totalCount: stat.errorCount,
+ masteryRate: stat.errorCount > 0 ? stat.masteredCount / stat.errorCount : 0,
+ chapterId: info?.chapterId ?? null,
+ chapterTitle: info?.chapterId ? (chapterTitleMap.get(info.chapterId) ?? null) : null,
+ }
+ })
.sort((a, b) => {
// 按错误数降序,掌握率升序(最薄弱的在前)
if (b.errorCount !== a.errorCount) return b.errorCount - a.errorCount
@@ -933,6 +762,262 @@ export async function getSubjectErrorDistribution(
}))
}
+/**
+ * 查询章节薄弱度统计(哪些课在错)。
+ * 通过 errorBookItems.knowledgePointIds → knowledgePoints.chapterId → chapters 关联。
+ * 支持按学科过滤(通过 errorBookItems.subjectId)。
+ */
+export async function getChapterWeakness(
+ studentIds: string[],
+ limit = 10,
+ subjectId?: string | null
+): Promise {
+ if (studentIds.length === 0) return []
+
+ const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)
+
+ // 查询错题条目(含知识点和状态)
+ const rows = await db
+ .select({
+ itemId: errorBookItems.id,
+ status: errorBookItems.status,
+ knowledgePointIds: errorBookItems.knowledgePointIds,
+ })
+ .from(errorBookItems)
+ .where(whereClause)
+
+ // 展开知识点,建立 kpId → 错题统计
+ const kpErrorMap = new Map()
+ for (const row of rows) {
+ const kps = (row.knowledgePointIds as string[] | null) ?? []
+ for (const kpId of kps) {
+ const stat = kpErrorMap.get(kpId) ?? { errorCount: 0, masteredCount: 0 }
+ stat.errorCount++
+ if (toStatus(row.status) === "mastered") stat.masteredCount++
+ kpErrorMap.set(kpId, stat)
+ }
+ }
+
+ if (kpErrorMap.size === 0) return []
+
+ // 查询知识点 → 章节映射
+ const kpIds = Array.from(kpErrorMap.keys())
+ const kpRows = await db
+ .select({
+ id: knowledgePoints.id,
+ name: knowledgePoints.name,
+ chapterId: knowledgePoints.chapterId,
+ })
+ .from(knowledgePoints)
+ .where(inArray(knowledgePoints.id, kpIds))
+
+ // 按章节聚合
+ const chapterMap = new Map
+ }>()
+
+ for (const kp of kpRows) {
+ if (!kp.chapterId) continue
+ const kpStat = kpErrorMap.get(kp.id)
+ if (!kpStat) continue
+
+ const chapterStat = chapterMap.get(kp.chapterId) ?? {
+ errorCount: 0,
+ masteredCount: 0,
+ knowledgePointCount: 0,
+ topKps: [],
+ }
+ chapterStat.errorCount += kpStat.errorCount
+ chapterStat.masteredCount += kpStat.masteredCount
+ chapterStat.knowledgePointCount++
+ chapterStat.topKps.push({
+ knowledgePointId: kp.id,
+ knowledgePointName: kp.name,
+ errorCount: kpStat.errorCount,
+ })
+ chapterMap.set(kp.chapterId, chapterStat)
+ }
+
+ if (chapterMap.size === 0) return []
+
+ // 查询章节标题
+ const chapterIds = Array.from(chapterMap.keys())
+ const chapterRows = await db
+ .select({ id: chapters.id, title: chapters.title })
+ .from(chapters)
+ .where(inArray(chapters.id, chapterIds))
+ const chapterTitleMap = new Map(chapterRows.map((c) => [c.id, c.title]))
+
+ return Array.from(chapterMap.entries())
+ .map(([chapterId, stat]) => ({
+ chapterId,
+ chapterTitle: chapterTitleMap.get(chapterId) ?? "未知章节",
+ errorCount: stat.errorCount,
+ masteredCount: stat.masteredCount,
+ knowledgePointCount: stat.knowledgePointCount,
+ masteryRate: stat.errorCount > 0 ? stat.masteredCount / stat.errorCount : 0,
+ topKnowledgePoints: stat.topKps
+ .sort((a, b) => b.errorCount - a.errorCount)
+ .slice(0, 3),
+ }))
+ .sort((a, b) => b.errorCount - a.errorCount)
+ .slice(0, limit)
+}
+
+/**
+ * 查询按班级分组的错题概览(教师视图:分班显示)。
+ * 对每个班级统计:学生数、错题总数、人均错题数、平均掌握率、待复习数。
+ * 支持按学科过滤。
+ */
+export async function getClassErrorOverviews(
+ classIds: string[],
+ subjectId?: string | null
+): Promise {
+ if (classIds.length === 0) return []
+
+ // 查询每个班级的学生
+ const enrollmentRows = await db
+ .select({
+ classId: classEnrollments.classId,
+ studentId: classEnrollments.studentId,
+ className: classes.name,
+ })
+ .from(classEnrollments)
+ .innerJoin(classes, eq(classEnrollments.classId, classes.id))
+ .where(inArray(classEnrollments.classId, classIds))
+
+ const classStudentMap = new Map }>()
+ for (const row of enrollmentRows) {
+ const entry = classStudentMap.get(row.classId) ?? { className: row.className, studentIds: new Set() }
+ entry.studentIds.add(row.studentId)
+ classStudentMap.set(row.classId, entry)
+ }
+
+ // 查询所有相关学生的错题
+ const allStudentIds = Array.from(new Set(enrollmentRows.map((r) => r.studentId)))
+ if (allStudentIds.length === 0) return []
+
+ const whereClause = buildStudentErrorWhereClause(allStudentIds, subjectId)
+ const errorRows = await db
+ .select({
+ studentId: errorBookItems.studentId,
+ status: errorBookItems.status,
+ nextReviewAt: errorBookItems.nextReviewAt,
+ })
+ .from(errorBookItems)
+ .where(whereClause)
+
+ const now = new Date()
+ // 按学生聚合
+ const studentStatMap = new Map()
+ for (const row of errorRows) {
+ const stat = studentStatMap.get(row.studentId) ?? { total: 0, mastered: 0, due: 0 }
+ stat.total++
+ const status = toStatus(row.status)
+ if (status === "mastered") stat.mastered++
+ if (status !== "mastered" && status !== "archived") {
+ if (!row.nextReviewAt || row.nextReviewAt <= now) stat.due++
+ }
+ studentStatMap.set(row.studentId, stat)
+ }
+
+ // 按班级聚合
+ return Array.from(classStudentMap.entries()).map(([classId, entry]) => {
+ const studentIds = Array.from(entry.studentIds)
+ let totalError = 0
+ let totalMastered = 0
+ let totalDue = 0
+ for (const sid of studentIds) {
+ const stat = studentStatMap.get(sid)
+ if (stat) {
+ totalError += stat.total
+ totalMastered += stat.mastered
+ totalDue += stat.due
+ }
+ }
+ return {
+ classId,
+ className: entry.className,
+ studentCount: studentIds.length,
+ totalErrorItems: totalError,
+ averageErrorPerStudent: studentIds.length > 0 ? totalError / studentIds.length : 0,
+ averageMasteryRate: totalError > 0 ? totalMastered / totalError : 0,
+ dueReviewCount: totalDue,
+ }
+ }).sort((a, b) => b.totalErrorItems - a.totalErrorItems)
+}
+
+/**
+ * 查询按学科分组的错题概览(用于学科 Tab 展示)。
+ * 返回每个学科的错题总数、涉及学生数、平均掌握率、待复习数。
+ */
+export async function getSubjectErrorOverviews(
+ studentIds: string[]
+): Promise {
+ if (studentIds.length === 0) return []
+
+ const rows = await db
+ .select({
+ subjectId: errorBookItems.subjectId,
+ studentId: errorBookItems.studentId,
+ status: errorBookItems.status,
+ nextReviewAt: errorBookItems.nextReviewAt,
+ })
+ .from(errorBookItems)
+ .where(inArray(errorBookItems.studentId, studentIds))
+
+ const now = new Date()
+ const subjectMap = new Map
+ }>()
+
+ for (const row of rows) {
+ const key = row.subjectId
+ if (!key) continue // 跳过无学科的错题
+ const stat = subjectMap.get(key) ?? {
+ totalErrorItems: 0,
+ masteredCount: 0,
+ dueReviewCount: 0,
+ studentSet: new Set(),
+ }
+ stat.totalErrorItems++
+ stat.studentSet.add(row.studentId)
+ const status = toStatus(row.status)
+ if (status === "mastered") stat.masteredCount++
+ if (status !== "mastered" && status !== "archived") {
+ if (!row.nextReviewAt || row.nextReviewAt <= now) stat.dueReviewCount++
+ }
+ subjectMap.set(key, stat)
+ }
+
+ if (subjectMap.size === 0) return []
+
+ // 查询学科名称
+ const subjectIds = Array.from(subjectMap.keys())
+ const subjectRows = await db
+ .select({ id: subjects.id, name: subjects.name })
+ .from(subjects)
+ .where(inArray(subjects.id, subjectIds))
+ const subjectNameMap = new Map(subjectRows.map((s) => [s.id, s.name]))
+
+ return Array.from(subjectMap.entries())
+ .map(([sid, stat]) => ({
+ subjectId: sid,
+ subjectName: subjectNameMap.get(sid) ?? "未知学科",
+ totalErrorItems: stat.totalErrorItems,
+ studentCount: stat.studentSet.size,
+ averageMasteryRate: stat.totalErrorItems > 0 ? stat.masteredCount / stat.totalErrorItems : 0,
+ dueReviewCount: stat.dueReviewCount,
+ }))
+ .sort((a, b) => b.totalErrorItems - a.totalErrorItems)
+}
+
/** 查询学生姓名映射 */
export async function getStudentNameMap(studentIds: string[]): Promise