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:
97
src/modules/error-book/components/analytics-stats-cards.tsx
Normal file
97
src/modules/error-book/components/analytics-stats-cards.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
155
src/modules/error-book/components/chapter-weakness-chart.tsx
Normal file
155
src/modules/error-book/components/chapter-weakness-chart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
118
src/modules/error-book/components/class-error-bar-chart.tsx
Normal file
118
src/modules/error-book/components/class-error-bar-chart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
91
src/modules/error-book/components/class-filter.tsx
Normal file
91
src/modules/error-book/components/class-filter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
111
src/modules/error-book/components/subject-distribution-chart.tsx
Normal file
111
src/modules/error-book/components/subject-distribution-chart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
99
src/modules/error-book/components/subject-tabs.tsx
Normal file
99
src/modules/error-book/components/subject-tabs.tsx
Normal 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[]
|
||||
/** 当前选中的学科 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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
170
src/modules/error-book/data-access-collection.ts
Normal file
170
src/modules/error-book/data-access-collection.ts
Normal 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,
|
||||
)
|
||||
}
|
||||
@@ -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]) => ({
|
||||
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<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]) => ({
|
||||
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<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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user