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"
|
"use client"
|
||||||
|
|
||||||
import { useState, useTransition } from "react"
|
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 { toast } from "sonner"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +38,7 @@ import {
|
|||||||
} from "../types"
|
} from "../types"
|
||||||
import { ReviewButtons } from "./review-buttons"
|
import { ReviewButtons } from "./review-buttons"
|
||||||
import { AiErrorBookAnalysis } from "@/modules/ai/components/ai-error-book-analysis"
|
import { AiErrorBookAnalysis } from "@/modules/ai/components/ai-error-book-analysis"
|
||||||
|
import { createPracticeSessionAction } from "@/modules/adaptive-practice/actions"
|
||||||
|
|
||||||
interface ErrorBookDetailDialogProps {
|
interface ErrorBookDetailDialogProps {
|
||||||
item: ErrorBookItemDetail | (Omit<ErrorBookItemDetail, "reviews"> & { reviews?: ErrorBookItemDetail["reviews"] })
|
item: ErrorBookItemDetail | (Omit<ErrorBookItemDetail, "reviews"> & { reviews?: ErrorBookItemDetail["reviews"] })
|
||||||
@@ -89,6 +92,9 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
|
|||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
const [note, setNote] = useState(item.note ?? "")
|
const [note, setNote] = useState(item.note ?? "")
|
||||||
const [errorTags, setErrorTags] = useState<string[]>(item.errorTags ?? [])
|
const [errorTags, setErrorTags] = useState<string[]>(item.errorTags ?? [])
|
||||||
|
const router = useRouter()
|
||||||
|
const t = useTranslations("error-book")
|
||||||
|
const tPractice = useTranslations("practice")
|
||||||
|
|
||||||
function handleSaveNote() {
|
function handleSaveNote() {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
@@ -99,9 +105,9 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
|
|||||||
)
|
)
|
||||||
const res = await updateErrorBookNoteAction(undefined, formData)
|
const res = await updateErrorBookNoteAction(undefined, formData)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success("笔记已保存")
|
toast.success(t("messages.noteSaved"))
|
||||||
} else {
|
} 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)
|
formData.append("itemId", item.id)
|
||||||
const res = await archiveErrorBookItemAction(undefined, formData)
|
const res = await archiveErrorBookItemAction(undefined, formData)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(res.message ?? "已归档")
|
toast.success(t("messages.archived"))
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
} else {
|
} 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)
|
formData.append("itemId", item.id)
|
||||||
const res = await deleteErrorBookItemAction(undefined, formData)
|
const res = await deleteErrorBookItemAction(undefined, formData)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(res.message ?? "已删除")
|
toast.success(t("messages.deleted"))
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
} else {
|
} 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>
|
</section>
|
||||||
) : null}
|
) : 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" ? (
|
{item.status !== "mastered" && item.status !== "archived" ? (
|
||||||
<section>
|
<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 {
|
import {
|
||||||
errorBookItems,
|
errorBookItems,
|
||||||
errorBookReviews,
|
errorBookReviews,
|
||||||
examSubmissions,
|
|
||||||
submissionAnswers,
|
|
||||||
homeworkSubmissions,
|
|
||||||
homeworkAnswers,
|
|
||||||
questions,
|
questions,
|
||||||
questionsToKnowledgePoints,
|
questionsToKnowledgePoints,
|
||||||
knowledgePoints,
|
knowledgePoints,
|
||||||
|
chapters,
|
||||||
subjects,
|
subjects,
|
||||||
examQuestions,
|
|
||||||
homeworkAssignmentQuestions,
|
|
||||||
users,
|
users,
|
||||||
|
classEnrollments,
|
||||||
|
classes,
|
||||||
} from "@/shared/db/schema"
|
} from "@/shared/db/schema"
|
||||||
import { getStudentIdsByClassIds } from "@/modules/classes/data-access"
|
import { getStudentIdsByClassIds } from "@/modules/classes/data-access"
|
||||||
import {
|
import {
|
||||||
@@ -37,6 +34,11 @@ import type {
|
|||||||
ErrorBookStats,
|
ErrorBookStats,
|
||||||
ErrorBookStatusValue,
|
ErrorBookStatusValue,
|
||||||
GetErrorBookItemsParams,
|
GetErrorBookItemsParams,
|
||||||
|
KnowledgePointWeakness,
|
||||||
|
ChapterWeakness,
|
||||||
|
ClassErrorOverview,
|
||||||
|
SubjectErrorOverview,
|
||||||
|
StudentErrorBookSummary,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import type { ErrorBookReviewResult } from "./schema"
|
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(
|
export {
|
||||||
submissionId: string,
|
collectFromExamSubmission,
|
||||||
studentId: string
|
collectFromHomeworkSubmission,
|
||||||
): Promise<number> {
|
} from "./data-access-collection"
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 跨模块查询接口:供教师/家长视图使用
|
// 跨模块查询接口:供教师/家长视图使用
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** 查询多个学生的错题统计(教师视图) */
|
/** 构建学生错题查询的 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(
|
export async function getStudentErrorBookSummaries(
|
||||||
studentIds: string[]
|
studentIds: string[],
|
||||||
): Promise<Array<{
|
subjectId?: string | null
|
||||||
studentId: string
|
): Promise<StudentErrorBookSummary[]> {
|
||||||
totalCount: number
|
|
||||||
newCount: number
|
|
||||||
learningCount: number
|
|
||||||
masteredCount: number
|
|
||||||
dueReviewCount: number
|
|
||||||
masteredRate: number
|
|
||||||
lastActivityAt: Date | null
|
|
||||||
}>> {
|
|
||||||
if (studentIds.length === 0) return []
|
if (studentIds.length === 0) return []
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
studentId: errorBookItems.studentId,
|
studentId: errorBookItems.studentId,
|
||||||
@@ -697,7 +481,26 @@ export async function getStudentErrorBookSummaries(
|
|||||||
updatedAt: errorBookItems.updatedAt,
|
updatedAt: errorBookItems.updatedAt,
|
||||||
})
|
})
|
||||||
.from(errorBookItems)
|
.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, {
|
const map = new Map<string, {
|
||||||
totalCount: number
|
totalCount: number
|
||||||
@@ -736,17 +539,23 @@ export async function getStudentErrorBookSummaries(
|
|||||||
map.set(row.studentId, stat)
|
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,
|
studentId,
|
||||||
...stat,
|
...stat,
|
||||||
masteredRate: stat.totalCount > 0 ? stat.masteredCount / stat.totalCount : 0,
|
masteredRate: stat.totalCount > 0 ? stat.masteredCount / stat.totalCount : 0,
|
||||||
}))
|
classId: classInfo?.classId ?? null,
|
||||||
|
className: classInfo?.className ?? null,
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 查询班级内错题最多的题目(教师视图:高频错题) */
|
/** 查询班级内错题最多的题目(教师视图:高频错题,支持按学科过滤) */
|
||||||
export async function getTopWrongQuestionsByStudentIds(
|
export async function getTopWrongQuestionsByStudentIds(
|
||||||
studentIds: string[],
|
studentIds: string[],
|
||||||
limit = 10
|
limit = 10,
|
||||||
|
subjectId?: string | null
|
||||||
): Promise<Array<{
|
): Promise<Array<{
|
||||||
questionId: string
|
questionId: string
|
||||||
questionContent: unknown
|
questionContent: unknown
|
||||||
@@ -756,6 +565,8 @@ export async function getTopWrongQuestionsByStudentIds(
|
|||||||
}>> {
|
}>> {
|
||||||
if (studentIds.length === 0) return []
|
if (studentIds.length === 0) return []
|
||||||
|
|
||||||
|
const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
questionId: errorBookItems.questionId,
|
questionId: errorBookItems.questionId,
|
||||||
@@ -765,7 +576,7 @@ export async function getTopWrongQuestionsByStudentIds(
|
|||||||
})
|
})
|
||||||
.from(errorBookItems)
|
.from(errorBookItems)
|
||||||
.innerJoin(questions, eq(questions.id, errorBookItems.questionId))
|
.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 }>()
|
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(
|
export async function getKnowledgePointWeakness(
|
||||||
studentIds: string[],
|
studentIds: string[],
|
||||||
limit = 10
|
limit = 10,
|
||||||
): Promise<Array<{
|
subjectId?: string | null
|
||||||
knowledgePointId: string
|
): Promise<KnowledgePointWeakness[]> {
|
||||||
knowledgePointName: string
|
|
||||||
errorCount: number
|
|
||||||
masteredCount: number
|
|
||||||
totalCount: number
|
|
||||||
masteryRate: number
|
|
||||||
}>> {
|
|
||||||
if (studentIds.length === 0) return []
|
if (studentIds.length === 0) return []
|
||||||
|
|
||||||
|
const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)
|
||||||
|
|
||||||
// 查询这些学生的所有错题条目(含知识点)
|
// 查询这些学生的所有错题条目(含知识点)
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -841,7 +648,7 @@ export async function getKnowledgePointWeakness(
|
|||||||
knowledgePointIds: errorBookItems.knowledgePointIds,
|
knowledgePointIds: errorBookItems.knowledgePointIds,
|
||||||
})
|
})
|
||||||
.from(errorBookItems)
|
.from(errorBookItems)
|
||||||
.where(inArray(errorBookItems.studentId, studentIds))
|
.where(whereClause)
|
||||||
|
|
||||||
// 展开知识点并统计
|
// 展开知识点并统计
|
||||||
const kpMap = new Map<string, { errorCount: number; masteredCount: number }>()
|
const kpMap = new Map<string, { errorCount: number; masteredCount: number }>()
|
||||||
@@ -858,23 +665,45 @@ export async function getKnowledgePointWeakness(
|
|||||||
|
|
||||||
if (kpMap.size === 0) return []
|
if (kpMap.size === 0) return []
|
||||||
|
|
||||||
// 查询知识点名称
|
// 查询知识点名称及所属章节
|
||||||
const kpIds = Array.from(kpMap.keys())
|
const kpIds = Array.from(kpMap.keys())
|
||||||
const kpRows = await db
|
const kpRows = await db
|
||||||
.select({ id: knowledgePoints.id, name: knowledgePoints.name })
|
.select({
|
||||||
|
id: knowledgePoints.id,
|
||||||
|
name: knowledgePoints.name,
|
||||||
|
chapterId: knowledgePoints.chapterId,
|
||||||
|
})
|
||||||
.from(knowledgePoints)
|
.from(knowledgePoints)
|
||||||
.where(inArray(knowledgePoints.id, kpIds))
|
.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())
|
return Array.from(kpMap.entries())
|
||||||
.map(([kpId, stat]) => ({
|
.map(([kpId, stat]) => {
|
||||||
|
const info = kpInfoMap.get(kpId)
|
||||||
|
return {
|
||||||
knowledgePointId: kpId,
|
knowledgePointId: kpId,
|
||||||
knowledgePointName: kpNameMap.get(kpId) ?? "未知知识点",
|
knowledgePointName: info?.name ?? "未知知识点",
|
||||||
errorCount: stat.errorCount,
|
errorCount: stat.errorCount,
|
||||||
masteredCount: stat.masteredCount,
|
masteredCount: stat.masteredCount,
|
||||||
totalCount: stat.errorCount,
|
totalCount: stat.errorCount,
|
||||||
masteryRate: stat.errorCount > 0 ? stat.masteredCount / stat.errorCount : 0,
|
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) => {
|
.sort((a, b) => {
|
||||||
// 按错误数降序,掌握率升序(最薄弱的在前)
|
// 按错误数降序,掌握率升序(最薄弱的在前)
|
||||||
if (b.errorCount !== a.errorCount) return b.errorCount - a.errorCount
|
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>> {
|
export async function getStudentNameMap(studentIds: string[]): Promise<Map<string, string>> {
|
||||||
if (studentIds.length === 0) return new Map()
|
if (studentIds.length === 0) return new Map()
|
||||||
|
|||||||
@@ -152,6 +152,10 @@ export interface KnowledgePointWeakness {
|
|||||||
totalCount: number
|
totalCount: number
|
||||||
/** 掌握率 0-1 */
|
/** 掌握率 0-1 */
|
||||||
masteryRate: number
|
masteryRate: number
|
||||||
|
/** 所属章节 ID(增强:关联章节维度) */
|
||||||
|
chapterId: string | null
|
||||||
|
/** 所属章节标题 */
|
||||||
|
chapterTitle: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 学科错题分布 */
|
/** 学科错题分布 */
|
||||||
@@ -173,6 +177,10 @@ export interface StudentErrorBookSummary {
|
|||||||
dueReviewCount: number
|
dueReviewCount: number
|
||||||
masteredRate: number
|
masteredRate: number
|
||||||
lastActivityAt: Date | null
|
lastActivityAt: Date | null
|
||||||
|
/** 所属班级 ID(增强:支持按班级分组展示) */
|
||||||
|
classId: string | null
|
||||||
|
/** 所属班级名称 */
|
||||||
|
className: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 班级错题统计(教师视图) */
|
/** 班级错题统计(教师视图) */
|
||||||
@@ -187,6 +195,47 @@ export interface ClassErrorBookStats {
|
|||||||
topStudents: StudentErrorBookSummary[]
|
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 {
|
export interface ErrorBookTrendPoint {
|
||||||
date: string
|
date: string
|
||||||
|
|||||||
Reference in New Issue
Block a user