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> { if (studentIds.length === 0) return new Map() diff --git a/src/modules/error-book/types.ts b/src/modules/error-book/types.ts index 35c3009..7b40d56 100644 --- a/src/modules/error-book/types.ts +++ b/src/modules/error-book/types.ts @@ -152,6 +152,10 @@ export interface KnowledgePointWeakness { totalCount: number /** 掌握率 0-1 */ masteryRate: number + /** 所属章节 ID(增强:关联章节维度) */ + chapterId: string | null + /** 所属章节标题 */ + chapterTitle: string | null } /** 学科错题分布 */ @@ -173,6 +177,10 @@ export interface StudentErrorBookSummary { dueReviewCount: number masteredRate: number lastActivityAt: Date | null + /** 所属班级 ID(增强:支持按班级分组展示) */ + classId: string | null + /** 所属班级名称 */ + className: string | null } /** 班级错题统计(教师视图) */ @@ -187,6 +195,47 @@ export interface ClassErrorBookStats { topStudents: StudentErrorBookSummary[] } +/** 章节薄弱度统计(哪些课在错) */ +export interface ChapterWeakness { + chapterId: string + chapterTitle: string + /** 该章节下的错题总数 */ + errorCount: number + /** 该章节下已掌握的错题数 */ + masteredCount: number + /** 该章节下涉及的知识点数 */ + knowledgePointCount: number + /** 掌握率 0-1 */ + masteryRate: number + /** 该章节下错得最多的知识点(前 3 个) */ + topKnowledgePoints: Array<{ + knowledgePointId: string + knowledgePointName: string + errorCount: number + }> +} + +/** 班级错题概览(按班级分组统计) */ +export interface ClassErrorOverview { + classId: string + className: string + studentCount: number + totalErrorItems: number + averageErrorPerStudent: number + averageMasteryRate: number + dueReviewCount: number +} + +/** 学科错题概览(按学科分组统计,用于学科 Tab) */ +export interface SubjectErrorOverview { + subjectId: string + subjectName: string + totalErrorItems: number + studentCount: number + averageMasteryRate: number + dueReviewCount: number +} + /** 错题趋势数据点 */ export interface ErrorBookTrendPoint { date: string