feat(error-book): add analytics stats, charts, and error collection

- Add analytics-stats-cards, chapter-weakness-chart, class-error-bar-chart

- Add knowledge-point-weakness-chart, subject-distribution-chart, subject-tabs

- Add class-filter and grouped-student-error-table components

- Add data-access-collection for error aggregation from multiple sources

- Update error-book-detail-dialog, data-access, and types
This commit is contained in:
SpecialX
2026-06-24 12:02:16 +08:00
parent d7876c5854
commit 61e76f0d67
12 changed files with 1681 additions and 287 deletions

View File

@@ -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 (
<div className={cn("grid gap-3 sm:grid-cols-2 lg:grid-cols-5", className)}>
{cards.map((card) => {
const Icon = card.icon
return (
<Card key={card.label} className="overflow-hidden">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="text-xs text-muted-foreground">{card.label}</div>
<div className={cn("mt-1 text-2xl font-bold", card.color)}>
{card.value}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">{card.sub}</div>
</div>
<div className={cn("rounded-lg p-2", card.bg)}>
<Icon className={cn("h-4 w-4", card.color)} />
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
)
}

View File

@@ -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 (
<Card className={cn("overflow-hidden", className)}>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<ChartContainer config={chartConfig} className="h-[300px] w-full">
<BarChart
data={chartData}
layout="vertical"
margin={{ left: 8, right: 16, top: 8, bottom: 8 }}
>
<CartesianGrid horizontal={false} strokeDasharray="4 4" strokeOpacity={0.4} />
<XAxis type="number" tickLine={false} axisLine={false} />
<YAxis
type="number"
dataKey="name"
tickLine={false}
axisLine={false}
width={120}
tickFormatter={(value: string) =>
value.length > 10 ? `${value.slice(0, 10)}...` : value
}
/>
<ChartTooltip
content={
<ChartTooltipContent
className="w-[280px]"
formatter={(payload: unknown) => {
const p = payload as unknown as {
name: string
errorCount: number
masteredCount: number
masteryRate: number
knowledgePointCount: number
topKps: Array<{ knowledgePointName: string; errorCount: number }>
}
return (
<div className="space-y-1.5">
<div className="font-medium">{p.name}</div>
<div className="text-muted-foreground">
<span className="font-medium text-foreground">{p.errorCount}</span>
<span className="ml-2"><span className="font-medium text-emerald-600">{p.masteredCount}</span></span>
</div>
<div className="text-muted-foreground">
<span className="font-medium text-foreground">{p.masteryRate}%</span>
<span className="ml-2"><span className="font-medium text-foreground">{p.knowledgePointCount}</span></span>
</div>
{p.topKps && p.topKps.length > 0 ? (
<div className="border-t pt-1.5 mt-1.5">
<div className="text-xs text-muted-foreground mb-1"></div>
{p.topKps.map((kp) => (
<div key={kp.knowledgePointName} className="flex justify-between text-xs">
<span>{kp.knowledgePointName}</span>
<span className="font-medium text-rose-600">{kp.errorCount}</span>
</div>
))}
</div>
) : null}
</div>
)
}}
/>
}
/>
<Bar dataKey="errorCount" radius={[0, 4, 4, 0]}>
{chartData.map((entry, idx) => (
<Cell
key={idx}
fill={entry.masteryRate < 30 ? "var(--color-rose-500)" : entry.masteryRate < 60 ? "var(--color-amber-500)" : "var(--color-emerald-500)"}
/>
))}
</Bar>
</BarChart>
</ChartContainer>
{/* 章节详情列表 */}
<div className="space-y-2">
{data.slice(0, 5).map((chapter) => (
<div
key={chapter.chapterId}
className="flex items-start justify-between gap-3 rounded-lg border p-3"
>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium text-sm">{chapter.chapterTitle}</span>
<Badge variant="outline" className="shrink-0 text-xs">
{chapter.knowledgePointCount}
</Badge>
</div>
{chapter.topKnowledgePoints.length > 0 ? (
<div className="flex flex-wrap gap-1">
{chapter.topKnowledgePoints.map((kp) => (
<Badge key={kp.knowledgePointId} variant="secondary" className="text-xs">
{kp.knowledgePointName} · {kp.errorCount}
</Badge>
))}
</div>
) : null}
</div>
<div className="flex shrink-0 flex-col items-end gap-0.5 text-xs">
<span className="font-bold text-rose-600">{chapter.errorCount}</span>
<span className="text-muted-foreground"> {Math.round(chapter.masteryRate * 100)}%</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)
}

View File

@@ -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 (
<Card className={cn("overflow-hidden", className)}>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[280px] w-full">
<BarChart data={chartData} margin={{ left: 8, right: 8, top: 8, bottom: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
<XAxis
dataKey="name"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value: string) =>
value.length > 8 ? `${value.slice(0, 8)}...` : value
}
/>
<YAxis tickLine={false} axisLine={false} width={36} />
<ChartTooltip
content={
<ChartTooltipContent
className="w-[220px]"
formatter={(payload: unknown) => {
const p = payload as unknown as {
name: string
totalErrorItems: number
studentCount: number
averageErrorPerStudent: number
averageMasteryRate: number
dueReviewCount: number
}
return (
<div className="space-y-1.5">
<div className="font-medium">{p.name}</div>
<div className="text-muted-foreground">
<span className="font-medium text-foreground">{p.totalErrorItems}</span>
</div>
<div className="text-muted-foreground">
<span className="font-medium text-foreground">{p.studentCount}</span>
</div>
<div className="text-muted-foreground">
<span className="font-medium text-foreground">{p.averageErrorPerStudent}</span>
</div>
<div className="text-muted-foreground">
<span className="font-medium text-foreground">{p.averageMasteryRate}%</span>
</div>
<div className="text-muted-foreground">
<span className="font-medium text-rose-600">{p.dueReviewCount}</span>
</div>
</div>
)
}}
/>
}
/>
<Bar dataKey="totalErrorItems" radius={[4, 4, 0, 0]}>
{chartData.map((_, idx) => (
<Cell key={idx} fill={CHART_COLORS[idx % CHART_COLORS.length]} />
))}
</Bar>
</BarChart>
</ChartContainer>
</CardContent>
</Card>
)
}

View File

@@ -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 (
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => handleSelect("all")}
className={cn(
"inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors",
currentClassId === "all"
? "border-primary bg-primary text-primary-foreground"
: "bg-card hover:bg-muted"
)}
>
<span className="font-medium"></span>
</button>
{classes.map((cls) => {
const isActive = currentClassId === cls.classId
return (
<button
key={cls.classId}
type="button"
onClick={() => handleSelect(cls.classId)}
className={cn(
"inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors",
isActive
? "border-primary bg-primary text-primary-foreground"
: "bg-card hover:bg-muted"
)}
>
<span className="font-medium">{cls.className}</span>
<Badge
variant={isActive ? "secondary" : "outline"}
className="text-xs"
>
{cls.totalErrorItems}
</Badge>
{cls.dueReviewCount > 0 ? (
<span
className={cn(
"text-xs",
isActive ? "text-primary-foreground/80" : "text-rose-600"
)}
>
{cls.dueReviewCount}
</span>
) : null}
</button>
)
})}
</div>
)
}

View File

@@ -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<ErrorBookItemDetail, "reviews"> & { 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<string[]>(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 }:
</section>
) : null}
{/* 变式练习入口 */}
<section>
<Button
onClick={handleStartVariantPractice}
disabled={isPending}
className="w-full"
variant="default"
>
<Target className="h-4 w-4" />
{isPending ? tPractice("starter.creating") : tPractice("types.error_variant")}
</Button>
<p className="mt-1 text-xs text-muted-foreground text-center">
{tPractice("starter.description")}
</p>
</section>
{/* 复习区 */}
{item.status !== "mastered" && item.status !== "archived" ? (
<section>

View File

@@ -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<string, string>
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<Set<string>>(new Set())
// 按班级分组
const groups: ClassGroup[] = []
const groupMap = new Map<string | null, ClassGroup>()
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 (
<div className="space-y-3">
{groups.map((group) => {
const groupKey = group.classId ?? "unclassified"
const isExpanded = expandedClasses.has(groupKey)
const studentsWithErrors = group.students.filter((s) => s.totalCount > 0)
return (
<div key={groupKey} className="overflow-hidden rounded-lg border">
{/* 班级头部(可点击展开) */}
<button
type="button"
onClick={() => toggleClass(group.classId)}
className="flex w-full items-center justify-between gap-3 bg-muted/50 p-3 text-left transition-colors hover:bg-muted"
>
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<Users className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{group.className}</span>
<Badge variant="outline" className="text-xs">
{group.students.length}
</Badge>
{studentsWithErrors.length < group.students.length ? (
<Badge variant="secondary" className="text-xs">
{studentsWithErrors.length}
</Badge>
) : null}
</div>
<div className="flex items-center gap-4 text-sm">
<div className="text-right">
<div className="text-xs text-muted-foreground"></div>
<div className="font-bold text-rose-600">{group.totalErrors}</div>
</div>
<div className="text-right">
<div className="text-xs text-muted-foreground"></div>
<div className="font-medium">
{Math.round(group.averageMasteryRate * 100)}%
</div>
</div>
</div>
</button>
{/* 学生表格(展开时显示) */}
{isExpanded ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr className="text-left text-xs text-muted-foreground">
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 text-right font-medium"></th>
<th className="px-3 py-2 text-right font-medium"></th>
<th className="px-3 py-2 text-right font-medium"></th>
<th className="px-3 py-2 text-right font-medium"></th>
<th className="px-3 py-2 text-right font-medium"></th>
<th className="px-3 py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{group.students.map((student) => {
const name = studentNames.get(student.studentId) ?? "未知"
const hasErrors = student.totalCount > 0
return (
<tr
key={student.studentId}
className={cn(
"border-b last:border-0 transition-colors",
hasErrors ? "hover:bg-muted/50" : "opacity-50"
)}
>
<td className="px-3 py-2">
{hasErrors ? (
<a
href={`${basePath}?studentId=${student.studentId}`}
className="font-medium text-primary hover:underline"
>
{name}
</a>
) : (
<span className="text-muted-foreground">{name}</span>
)}
</td>
<td className="px-3 py-2 text-right font-medium">
{student.totalCount}
</td>
<td className="px-3 py-2 text-right text-blue-600 dark:text-blue-400">
{student.newCount}
</td>
<td className="px-3 py-2 text-right">
{student.learningCount}
</td>
<td className="px-3 py-2 text-right text-emerald-600 dark:text-emerald-400">
{student.masteredCount}
</td>
<td className="px-3 py-2 text-right text-rose-600 dark:text-rose-400">
{student.dueReviewCount}
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<Progress
value={student.masteredRate * 100}
className="h-1.5 w-16"
/>
<span className="text-xs text-muted-foreground">
{Math.round(student.masteredRate * 100)}%
</span>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
) : null}
</div>
)
})}
</div>
)
}

View File

@@ -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 (
<Card className={cn("overflow-hidden", className)}>
<CardHeader>
<CardTitle className="text-base"> Top {data.length}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<ChartContainer config={chartConfig} className="h-[320px] w-full">
<BarChart
data={chartData}
layout="vertical"
margin={{ left: 8, right: 16, top: 8, bottom: 8 }}
>
<CartesianGrid horizontal={false} strokeDasharray="4 4" strokeOpacity={0.4} />
<XAxis type="number" tickLine={false} axisLine={false} />
<YAxis
type="number"
dataKey="name"
tickLine={false}
axisLine={false}
width={100}
tickFormatter={(value: string) =>
value.length > 8 ? `${value.slice(0, 8)}...` : value
}
/>
<ChartTooltip
content={
<ChartTooltipContent
className="w-[240px]"
formatter={(payload: unknown) => {
const p = payload as unknown as {
name: string
errorCount: number
masteredCount: number
masteryRate: number
chapterTitle: string
}
return (
<div className="space-y-1.5">
<div className="font-medium">{p.name}</div>
<div className="text-xs text-muted-foreground">
{p.chapterTitle}
</div>
<div className="text-muted-foreground">
<span className="font-medium text-foreground">{p.errorCount}</span>
<span className="ml-2"><span className="font-medium text-emerald-600">{p.masteredCount}</span></span>
</div>
<div className="text-muted-foreground">
<span className="font-medium text-foreground">{p.masteryRate}%</span>
</div>
</div>
)
}}
/>
}
/>
<Bar dataKey="errorCount" radius={[0, 4, 4, 0]}>
{chartData.map((entry, idx) => (
<Cell
key={idx}
fill={
entry.masteryRate < 30
? "var(--color-rose-500)"
: entry.masteryRate < 60
? "var(--color-amber-500)"
: "var(--color-emerald-500)"
}
/>
))}
</Bar>
</BarChart>
</ChartContainer>
{/* 知识点详情列表(带章节归属) */}
<div className="space-y-2">
{data.slice(0, 8).map((kp) => (
<div
key={kp.knowledgePointId}
className="flex items-center justify-between gap-3 rounded-lg border p-2.5"
>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{kp.knowledgePointName}</div>
{kp.chapterTitle ? (
<div className="truncate text-xs text-muted-foreground">
{kp.chapterTitle}
</div>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
<Badge
variant={
kp.masteryRate < 0.3
? "destructive"
: kp.masteryRate < 0.6
? "secondary"
: "outline"
}
className="text-xs"
>
{Math.round(kp.masteryRate * 100)}%
</Badge>
<span className="text-sm font-bold text-rose-600">{kp.errorCount}</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)
}

View File

@@ -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 (
<Card className={cn("overflow-hidden", className)}>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[280px] w-full">
<BarChart data={chartData} margin={{ left: 8, right: 8, top: 8, bottom: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
<XAxis
dataKey="name"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value: string) =>
value.length > 6 ? `${value.slice(0, 6)}...` : value
}
/>
<YAxis tickLine={false} axisLine={false} width={36} />
<ChartTooltip
content={
<ChartTooltipContent
className="w-[200px]"
formatter={(payload: unknown) => {
const p = payload as unknown as {
name: string
errorCount: number
masteredCount: number
masteryRate: number
}
return (
<div className="space-y-1.5">
<div className="font-medium">{p.name}</div>
<div className="text-muted-foreground">
<span className="font-medium text-foreground">{p.errorCount}</span>
</div>
<div className="text-muted-foreground">
<span className="font-medium text-emerald-600">{p.masteredCount}</span>
</div>
<div className="text-muted-foreground">
<span className="font-medium text-foreground">{p.masteryRate}%</span>
</div>
</div>
)
}}
/>
}
/>
<Bar dataKey="errorCount" radius={[4, 4, 0, 0]}>
{chartData.map((_, idx) => (
<Cell key={idx} fill={SUBJECT_COLORS[idx % SUBJECT_COLORS.length]} />
))}
</Bar>
</BarChart>
</ChartContainer>
</CardContent>
</Card>
)
}

View File

@@ -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[]
/** 当前选中的学科 IDnull 表示全部) */
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 (
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => handleSelect(null)}
className={cn(
"inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors",
currentSubjectId === null
? "border-primary bg-primary text-primary-foreground"
: "bg-card hover:bg-muted"
)}
>
<span className="font-medium"></span>
<Badge
variant={currentSubjectId === null ? "secondary" : "outline"}
className="text-xs"
>
{totalErrors}
</Badge>
</button>
{subjects.map((subject) => {
const isActive = currentSubjectId === subject.subjectId
return (
<button
key={subject.subjectId}
type="button"
onClick={() => handleSelect(subject.subjectId)}
className={cn(
"inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors",
isActive
? "border-primary bg-primary text-primary-foreground"
: "bg-card hover:bg-muted"
)}
>
<span className="font-medium">{subject.subjectName}</span>
<Badge
variant={isActive ? "secondary" : "outline"}
className="text-xs"
>
{subject.totalErrorItems}
</Badge>
{subject.dueReviewCount > 0 ? (
<span
className={cn(
"text-xs",
isActive ? "text-primary-foreground/80" : "text-rose-600"
)}
>
{subject.dueReviewCount}
</span>
) : null}
</button>
)
})}
</div>
)
}

View File

@@ -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<number> {
// 筛选错题:得分为 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<number> {
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<number> {
const data = await getHomeworkSubmissionDataForErrorCollection(submissionId)
if (!data) throw new Error("作业提交记录不存在")
return collectErrorItemsFromAnswers(
studentId,
"homework",
submissionId,
data.subjectId,
data.answers,
)
}

View File

@@ -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<number> {
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<string, string[]>()
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<number> {
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<string, string[]>()
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<Array<{
studentId: string
totalCount: number
newCount: number
learningCount: number
masteredCount: number
dueReviewCount: number
masteredRate: number
lastActivityAt: Date | null
}>> {
studentIds: string[],
subjectId?: string | null
): Promise<StudentErrorBookSummary[]> {
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<string, { classId: string; className: string }>()
for (const row of enrollmentRows) {
// 取第一个班级(学生通常只属于一个班)
if (!studentClassMap.has(row.studentId)) {
studentClassMap.set(row.studentId, { classId: row.classId, className: row.className })
}
}
const map = new Map<string, {
totalCount: number
@@ -736,17 +539,23 @@ export async function getStudentErrorBookSummaries(
map.set(row.studentId, stat)
}
return Array.from(map.entries()).map(([studentId, stat]) => ({
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<Array<{
questionId: string
questionContent: unknown
@@ -756,6 +565,8 @@ export async function getTopWrongQuestionsByStudentIds(
}>> {
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<string, { questionContent: unknown; questionType: string; errorCount: number; masteredCount: number }>()
@@ -816,23 +627,19 @@ export async function getAllStudentIds(): Promise<string[]> {
}
// ---------------------------------------------------------------------------
// 统计:知识点薄弱度 & 学科分布(教师/管理员视图)
// 统计:知识点薄弱度 & 学科分布 & 章节维度(教师/管理员视图)
// ---------------------------------------------------------------------------
/** 查询多个学生的知识点薄弱度统计 */
/** 查询多个学生的知识点薄弱度统计(支持按学科过滤,关联章节信息) */
export async function getKnowledgePointWeakness(
studentIds: string[],
limit = 10
): Promise<Array<{
knowledgePointId: string
knowledgePointName: string
errorCount: number
masteredCount: number
totalCount: number
masteryRate: number
}>> {
limit = 10,
subjectId?: string | null
): Promise<KnowledgePointWeakness[]> {
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<string, { errorCount: number; masteredCount: number }>()
@@ -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<string, string>()
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]) => ({
.map(([kpId, stat]) => {
const info = kpInfoMap.get(kpId)
return {
knowledgePointId: kpId,
knowledgePointName: kpNameMap.get(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<ChapterWeakness[]> {
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<string, { errorCount: number; masteredCount: number }>()
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<string, {
errorCount: number
masteredCount: number
knowledgePointCount: number
topKps: Array<{ knowledgePointId: string; knowledgePointName: string; errorCount: number }>
}>()
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<ClassErrorOverview[]> {
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<string, { className: string; studentIds: Set<string> }>()
for (const row of enrollmentRows) {
const entry = classStudentMap.get(row.classId) ?? { className: row.className, studentIds: new Set<string>() }
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<string, { total: number; mastered: number; due: number }>()
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<SubjectErrorOverview[]> {
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<string, {
totalErrorItems: number
masteredCount: number
dueReviewCount: number
studentSet: Set<string>
}>()
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<string>(),
}
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<Map<string, string>> {
if (studentIds.length === 0) return new Map()

View File

@@ -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