feat(error-book): update components, actions, data-access, and add analytics

- Add data-access-analytics for error book analytics queries

- Update actions, data-access for improved error book operations

- Update components: add-error-book-dialog, analytics-stats-cards, chapter-weakness-chart, class-error-bar-chart, class-filter, error-book-detail-dialog, error-book-filters, error-book-item-card, error-book-list, error-book-stats-cards, grouped-student-error-table, knowledge-point-weakness-chart, review-buttons, subject-distribution-chart, subject-tabs, top-wrong-questions

- Remove class-error-overview (replaced by new components)
This commit is contained in:
SpecialX
2026-07-03 10:30:41 +08:00
parent 56c3f32e2d
commit 2dd8c2197c
20 changed files with 1090 additions and 1228 deletions

View File

@@ -58,16 +58,14 @@ export async function getErrorBookItemsAction(
} }
} }
const status = params.status && params.status !== "all" const statusParse = z.enum(["new", "learning", "mastered", "archived"]).safeParse(params.status)
? (z.enum(["new", "learning", "mastered", "archived"]).safeParse(params.status).success const status = params.status && params.status !== "all" && statusParse.success
? (params.status as "new" | "learning" | "mastered" | "archived") ? statusParse.data
: undefined)
: undefined : undefined
const sourceType = params.sourceType && params.sourceType !== "all" const sourceTypeParse = z.enum(["exam", "homework", "manual"]).safeParse(params.sourceType)
? (z.enum(["exam", "homework", "manual"]).safeParse(params.sourceType).success const sourceType = params.sourceType && params.sourceType !== "all" && sourceTypeParse.success
? (params.sourceType as "exam" | "homework" | "manual") ? sourceTypeParse.data
: undefined)
: undefined : undefined
const data = await getErrorBookItems({ const data = await getErrorBookItems({

View File

@@ -3,6 +3,7 @@
import { useState, useTransition, useEffect } from "react" import { useState, useTransition, useEffect } from "react"
import { Plus } from "lucide-react" import { Plus } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { useTranslations } from "next-intl"
import { import {
Dialog, Dialog,
@@ -23,53 +24,44 @@ import {
SelectValue, SelectValue,
} from "@/shared/components/ui/select" } from "@/shared/components/ui/select"
import { Textarea } from "@/shared/components/ui/textarea" import { Textarea } from "@/shared/components/ui/textarea"
import { getQuestionsAction } from "@/modules/questions/actions"
import { createErrorBookItemAction } from "../actions" import { createErrorBookItemAction } from "../actions"
import { COMMON_ERROR_TAGS } from "../types" import { COMMON_ERROR_TAGS } from "../types"
export function AddErrorBookDialog() { interface QuestionOption {
id: string
preview: string
}
interface AddErrorBookDialogProps {
/** 预加载的题目选项(若提供则直接使用,不再调用 onLoadQuestions */
questionOptions?: QuestionOption[]
/** 懒加载题目选项的回调(对话框打开时调用) */
onLoadQuestions?: () => Promise<QuestionOption[]>
}
export function AddErrorBookDialog({
questionOptions: questionOptionsProp,
onLoadQuestions,
}: AddErrorBookDialogProps) {
const t = useTranslations("error-book")
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [isPending, startTransition] = useTransition() const [isPending, startTransition] = useTransition()
const [questionId, setQuestionId] = useState("") const [questionId, setQuestionId] = useState("")
const [note, setNote] = useState("") const [note, setNote] = useState("")
const [errorTags, setErrorTags] = useState<string[]>([]) const [errorTags, setErrorTags] = useState<string[]>([])
const [questionOptions, setQuestionOptions] = useState<Array<{ const [loadedOptions, setLoadedOptions] = useState<QuestionOption[]>([])
id: string
preview: string
}>>([])
function extractPreview(content: unknown): string { const questionOptions = questionOptionsProp ?? loadedOptions
if (typeof content === "string") return content.slice(0, 60)
if (Array.isArray(content)) {
const texts: string[] = []
for (const node of content) {
if (typeof node === "string") texts.push(node)
else if (typeof node === "object" && node !== null) {
const n = node as Record<string, unknown>
if (typeof n.text === "string") texts.push(n.text)
}
}
return texts.join("").slice(0, 60)
}
return "题目"
}
useEffect(() => { useEffect(() => {
if (open && questionOptions.length === 0) { if (open && onLoadQuestions && loadedOptions.length === 0 && !questionOptionsProp) {
getQuestionsAction({ pageSize: 100 }) onLoadQuestions()
.then((res) => { .then((options) => {
if (res.success && res.data) { setLoadedOptions(options)
setQuestionOptions(
res.data.data.map((q) => ({
id: q.id,
preview: extractPreview(q.content),
}))
)
}
}) })
.catch(() => {}) .catch(() => {})
} }
}, [open, questionOptions.length]) }, [open, onLoadQuestions, loadedOptions.length, questionOptionsProp])
function toggleTag(tag: string) { function toggleTag(tag: string) {
setErrorTags((prev) => setErrorTags((prev) =>
@@ -79,7 +71,7 @@ export function AddErrorBookDialog() {
function handleSubmit() { function handleSubmit() {
if (!questionId) { if (!questionId) {
toast.error("请选择题目") toast.error(t("messages.selectQuestion"))
return return
} }
startTransition(async () => { startTransition(async () => {
@@ -90,13 +82,13 @@ export function AddErrorBookDialog() {
) )
const res = await createErrorBookItemAction(undefined, formData) const res = await createErrorBookItemAction(undefined, formData)
if (res.success) { if (res.success) {
toast.success(res.message ?? "已添加") toast.success(res.message ?? t("messages.addedShort"))
setOpen(false) setOpen(false)
setQuestionId("") setQuestionId("")
setNote("") setNote("")
setErrorTags([]) setErrorTags([])
} else { } else {
toast.error(res.message ?? "添加失败") toast.error(res.message ?? t("messages.addFailed"))
} }
}) })
} }
@@ -106,23 +98,23 @@ export function AddErrorBookDialog() {
<DialogTrigger asChild> <DialogTrigger asChild>
<Button> <Button>
<Plus className="h-4 w-4" data-icon="inline-start" /> <Plus className="h-4 w-4" data-icon="inline-start" />
{t("actions.add")}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle></DialogTitle> <DialogTitle>{t("addDialog.title")}</DialogTitle>
<DialogDescription> <DialogDescription>
/ {t("addDialog.description")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="question"></Label> <Label htmlFor="question">{t("addDialog.selectQuestion")}</Label>
<Select value={questionId} onValueChange={setQuestionId}> <Select value={questionId} onValueChange={setQuestionId}>
<SelectTrigger id="question"> <SelectTrigger id="question">
<SelectValue placeholder="从题库中选择..." /> <SelectValue placeholder={t("addDialog.selectPlaceholder")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{questionOptions.map((q) => ( {questionOptions.map((q) => (
@@ -135,18 +127,18 @@ export function AddErrorBookDialog() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="note"></Label> <Label htmlFor="note">{t("addDialog.noteLabel")}</Label>
<Textarea <Textarea
id="note" id="note"
value={note} value={note}
onChange={(e) => setNote(e.target.value)} onChange={(e) => setNote(e.target.value)}
placeholder="记录错误原因、解题思路..." placeholder={t("addDialog.notePlaceholder")}
maxLength={2000} maxLength={2000}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label></Label> <Label>{t("addDialog.errorTagsLabel")}</Label>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{COMMON_ERROR_TAGS.map((tag) => ( {COMMON_ERROR_TAGS.map((tag) => (
<Button <Button
@@ -165,10 +157,10 @@ export function AddErrorBookDialog() {
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}> <Button variant="outline" onClick={() => setOpen(false)}>
{t("actions.cancel")}
</Button> </Button>
<Button disabled={isPending || !questionId} onClick={handleSubmit}> <Button disabled={isPending || !questionId} onClick={handleSubmit}>
{isPending ? "添加中..." : "添加"} {isPending ? t("actions.adding") : t("actions.add")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -1,4 +1,7 @@
"use client"
import { BookOpen, Brain, CheckCircle2, Clock, TrendingUp } from "lucide-react" import { BookOpen, Brain, CheckCircle2, Clock, TrendingUp } from "lucide-react"
import { useTranslations } from "next-intl"
import { Card, CardContent } from "@/shared/components/ui/card" import { Card, CardContent } from "@/shared/components/ui/card"
import { cn } from "@/shared/lib/utils" import { cn } from "@/shared/lib/utils"
@@ -26,43 +29,52 @@ export function AnalyticsStatsCards({
knowledgePointCount, knowledgePointCount,
className, className,
}: AnalyticsStatsCardsProps) { }: AnalyticsStatsCardsProps) {
const t = useTranslations("error-book")
const avg = (totalStudents > 0 ? totalErrorItems / totalStudents : 0).toFixed(1)
const cards = [ const cards = [
{ {
label: "覆盖学生", label: t("analyticsStats.coverage"),
value: studentsWithErrorBook, value: studentsWithErrorBook,
sub: `/ ${totalStudents}`, sub: t("analyticsStats.coverageSub", { total: totalStudents }),
icon: BookOpen, icon: BookOpen,
color: "text-blue-600 dark:text-blue-400", color: "text-blue-600 dark:text-blue-400",
bg: "bg-blue-50 dark:bg-blue-950/30", bg: "bg-blue-50 dark:bg-blue-950/30",
}, },
{ {
label: "错题总数", label: t("analyticsStats.totalErrors"),
value: totalErrorItems, value: totalErrorItems,
sub: `人均 ${(totalStudents > 0 ? totalErrorItems / totalStudents : 0).toFixed(1)}`, sub: t("analyticsStats.totalErrorsSub", { avg }),
icon: TrendingUp, icon: TrendingUp,
color: "text-rose-600 dark:text-rose-400", color: "text-rose-600 dark:text-rose-400",
bg: "bg-rose-50 dark:bg-rose-950/30", bg: "bg-rose-50 dark:bg-rose-950/30",
}, },
{ {
label: "平均掌握率", label: t("analyticsStats.avgMastery"),
value: `${Math.round(averageMasteryRate * 100)}%`, value: `${Math.round(averageMasteryRate * 100)}%`,
sub: averageMasteryRate >= 0.6 ? "整体良好" : "需加强", sub: averageMasteryRate >= 0.6
? t("analyticsStats.avgMasteryGood")
: t("analyticsStats.avgMasteryNeedImprove"),
icon: CheckCircle2, icon: CheckCircle2,
color: "text-emerald-600 dark:text-emerald-400", color: "text-emerald-600 dark:text-emerald-400",
bg: "bg-emerald-50 dark:bg-emerald-950/30", bg: "bg-emerald-50 dark:bg-emerald-950/30",
}, },
{ {
label: "待复习", label: t("analyticsStats.dueReview"),
value: dueReviewCount, value: dueReviewCount,
sub: dueReviewCount > 0 ? "需要关注" : "无到期", sub: dueReviewCount > 0
? t("analyticsStats.dueReviewNeedAttention")
: t("analyticsStats.dueReviewNone"),
icon: Clock, icon: Clock,
color: "text-amber-600 dark:text-amber-400", color: "text-amber-600 dark:text-amber-400",
bg: "bg-amber-50 dark:bg-amber-950/30", bg: "bg-amber-50 dark:bg-amber-950/30",
}, },
{ {
label: "涉及知识点", label: t("analyticsStats.knowledgePoints"),
value: knowledgePointCount ?? 0, value: knowledgePointCount ?? 0,
sub: knowledgePointCount && knowledgePointCount > 5 ? "范围较广" : "集中", sub: knowledgePointCount && knowledgePointCount > 5
? t("analyticsStats.knowledgePointsWide")
: t("analyticsStats.knowledgePointsFocused"),
icon: Brain, icon: Brain,
color: "text-purple-600 dark:text-purple-400", color: "text-purple-600 dark:text-purple-400",
bg: "bg-purple-50 dark:bg-purple-950/30", bg: "bg-purple-50 dark:bg-purple-950/30",

View File

@@ -1,6 +1,7 @@
"use client" "use client"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts" import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts"
import { useTranslations } from "next-intl"
import { import {
ChartContainer, ChartContainer,
ChartTooltip, ChartTooltip,
@@ -18,12 +19,36 @@ interface ChapterWeaknessChartProps {
className?: string className?: string
} }
interface ChapterChartPayload {
name: string
errorCount: number
masteredCount: number
masteryRate: number
knowledgePointCount: number
topKps: Array<{ knowledgePointName: string; errorCount: number }>
}
function isChapterChartPayload(v: unknown): v is ChapterChartPayload {
if (typeof v !== "object" || v === null) return false
// 从 unknown 转换:类型守卫内需要属性访问来校验字段类型
const obj = v as Record<string, unknown>
return (
typeof obj.name === "string" &&
typeof obj.errorCount === "number" &&
typeof obj.masteredCount === "number" &&
typeof obj.masteryRate === "number" &&
typeof obj.knowledgePointCount === "number"
)
}
/** /**
* 章节薄弱度图表(哪些课在错) * 章节薄弱度图表(哪些课在错)
* 横向柱状图,按错题数降序 * 横向柱状图,按错题数降序
* 每个柱子可展开显示该章节下错得最多的知识点 * 每个柱子可展开显示该章节下错得最多的知识点
*/ */
export function ChapterWeaknessChart({ data, className }: ChapterWeaknessChartProps) { export function ChapterWeaknessChart({ data, className }: ChapterWeaknessChartProps) {
const t = useTranslations("error-book")
if (data.length === 0) return null if (data.length === 0) return null
const chartData = data.map((d) => ({ const chartData = data.map((d) => ({
@@ -37,7 +62,7 @@ export function ChapterWeaknessChart({ data, className }: ChapterWeaknessChartPr
const chartConfig: ChartConfig = { const chartConfig: ChartConfig = {
errorCount: { errorCount: {
label: "错题数", label: t("chapterChart.errorCount"),
color: "var(--color-chart-2)", color: "var(--color-chart-2)",
}, },
} }
@@ -45,10 +70,14 @@ export function ChapterWeaknessChart({ data, className }: ChapterWeaknessChartPr
return ( return (
<Card className={cn("overflow-hidden", className)}> <Card className={cn("overflow-hidden", className)}>
<CardHeader> <CardHeader>
<CardTitle className="text-base"></CardTitle> <CardTitle className="text-base">{t("chapterChart.title")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<ChartContainer config={chartConfig} className="h-[300px] w-full"> <ChartContainer
config={chartConfig}
className="h-[300px] w-full"
aria-label={t("chapterChart.title")}
>
<BarChart <BarChart
data={chartData} data={chartData}
layout="vertical" layout="vertical"
@@ -71,29 +100,32 @@ export function ChapterWeaknessChart({ data, className }: ChapterWeaknessChartPr
<ChartTooltipContent <ChartTooltipContent
className="w-[280px]" className="w-[280px]"
formatter={(payload: unknown) => { formatter={(payload: unknown) => {
const p = payload as unknown as { if (!isChapterChartPayload(payload)) return null
name: string
errorCount: number
masteredCount: number
masteryRate: number
knowledgePointCount: number
topKps: Array<{ knowledgePointName: string; errorCount: number }>
}
return ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="font-medium">{p.name}</div> <div className="font-medium">{payload.name}</div>
<div className="text-muted-foreground"> <div className="text-muted-foreground">
<span className="font-medium text-foreground">{p.errorCount}</span> {t("chapterChart.errorCount")}
<span className="ml-2"><span className="font-medium text-emerald-600">{p.masteredCount}</span></span> <span className="font-medium text-foreground">{payload.errorCount}</span>
<span className="ml-2">
{t("chapterChart.masteredLabel")}
<span className="font-medium text-emerald-600">{payload.masteredCount}</span>
</span>
</div> </div>
<div className="text-muted-foreground"> <div className="text-muted-foreground">
<span className="font-medium text-foreground">{p.masteryRate}%</span> {t("chapterChart.masteryRateLabel")}
<span className="ml-2"><span className="font-medium text-foreground">{p.knowledgePointCount}</span></span> <span className="font-medium text-foreground">{payload.masteryRate}%</span>
<span className="ml-2">
{t("chapterChart.knowledgePointCount")}
<span className="font-medium text-foreground">{payload.knowledgePointCount}</span>
</span>
</div> </div>
{p.topKps && p.topKps.length > 0 ? ( {payload.topKps && payload.topKps.length > 0 ? (
<div className="border-t pt-1.5 mt-1.5"> <div className="border-t pt-1.5 mt-1.5">
<div className="text-xs text-muted-foreground mb-1"></div> <div className="text-xs text-muted-foreground mb-1">
{p.topKps.map((kp) => ( {t("chapterChart.weakKpsLabel")}
</div>
{payload.topKps.map((kp) => (
<div key={kp.knowledgePointName} className="flex justify-between text-xs"> <div key={kp.knowledgePointName} className="flex justify-between text-xs">
<span>{kp.knowledgePointName}</span> <span>{kp.knowledgePointName}</span>
<span className="font-medium text-rose-600">{kp.errorCount}</span> <span className="font-medium text-rose-600">{kp.errorCount}</span>
@@ -129,7 +161,7 @@ export function ChapterWeaknessChart({ data, className }: ChapterWeaknessChartPr
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="truncate font-medium text-sm">{chapter.chapterTitle}</span> <span className="truncate font-medium text-sm">{chapter.chapterTitle}</span>
<Badge variant="outline" className="shrink-0 text-xs"> <Badge variant="outline" className="shrink-0 text-xs">
{chapter.knowledgePointCount} {t("chapterChart.knowledgePointBadge", { count: chapter.knowledgePointCount })}
</Badge> </Badge>
</div> </div>
{chapter.topKnowledgePoints.length > 0 ? ( {chapter.topKnowledgePoints.length > 0 ? (
@@ -144,7 +176,9 @@ export function ChapterWeaknessChart({ data, className }: ChapterWeaknessChartPr
</div> </div>
<div className="flex shrink-0 flex-col items-end gap-0.5 text-xs"> <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="font-bold text-rose-600">{chapter.errorCount}</span>
<span className="text-muted-foreground"> {Math.round(chapter.masteryRate * 100)}%</span> <span className="text-muted-foreground">
{t("chapterChart.masteryRateLabel")} {Math.round(chapter.masteryRate * 100)}%
</span>
</div> </div>
</div> </div>
))} ))}

View File

@@ -1,6 +1,7 @@
"use client" "use client"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts" import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts"
import { useTranslations } from "next-intl"
import { import {
ChartContainer, ChartContainer,
ChartTooltip, ChartTooltip,
@@ -17,6 +18,29 @@ interface ClassErrorBarChartProps {
className?: string className?: string
} }
interface ClassBarChartPayload {
name: string
totalErrorItems: number
studentCount: number
averageErrorPerStudent: number
averageMasteryRate: number
dueReviewCount: number
}
function isClassBarChartPayload(v: unknown): v is ClassBarChartPayload {
if (typeof v !== "object" || v === null) return false
// 从 unknown 转换:类型守卫内需要属性访问来校验字段类型
const obj = v as Record<string, unknown>
return (
typeof obj.name === "string" &&
typeof obj.totalErrorItems === "number" &&
typeof obj.studentCount === "number" &&
typeof obj.averageErrorPerStudent === "number" &&
typeof obj.averageMasteryRate === "number" &&
typeof obj.dueReviewCount === "number"
)
}
const CHART_COLORS = [ const CHART_COLORS = [
"var(--color-chart-1)", "var(--color-chart-1)",
"var(--color-chart-2)", "var(--color-chart-2)",
@@ -31,6 +55,8 @@ const CHART_COLORS = [
* 颜色按班级区分tooltip 显示学生数/人均/掌握率 * 颜色按班级区分tooltip 显示学生数/人均/掌握率
*/ */
export function ClassErrorBarChart({ data, className }: ClassErrorBarChartProps) { export function ClassErrorBarChart({ data, className }: ClassErrorBarChartProps) {
const t = useTranslations("error-book")
if (data.length === 0) return null if (data.length === 0) return null
const chartData = data.map((d) => ({ const chartData = data.map((d) => ({
@@ -44,7 +70,7 @@ export function ClassErrorBarChart({ data, className }: ClassErrorBarChartProps)
const chartConfig: ChartConfig = { const chartConfig: ChartConfig = {
totalErrorItems: { totalErrorItems: {
label: "错题总数", label: t("classErrorBar.errorCount"),
color: "var(--color-chart-1)", color: "var(--color-chart-1)",
}, },
} }
@@ -52,10 +78,14 @@ export function ClassErrorBarChart({ data, className }: ClassErrorBarChartProps)
return ( return (
<Card className={cn("overflow-hidden", className)}> <Card className={cn("overflow-hidden", className)}>
<CardHeader> <CardHeader>
<CardTitle className="text-base"></CardTitle> <CardTitle className="text-base">{t("classErrorBar.title")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ChartContainer config={chartConfig} className="h-[280px] w-full"> <ChartContainer
config={chartConfig}
className="h-[280px] w-full"
aria-label={t("classErrorBar.title")}
>
<BarChart data={chartData} margin={{ left: 8, right: 8, top: 8, bottom: 8 }}> <BarChart data={chartData} margin={{ left: 8, right: 8, top: 8, bottom: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} /> <CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
<XAxis <XAxis
@@ -73,31 +103,29 @@ export function ClassErrorBarChart({ data, className }: ClassErrorBarChartProps)
<ChartTooltipContent <ChartTooltipContent
className="w-[220px]" className="w-[220px]"
formatter={(payload: unknown) => { formatter={(payload: unknown) => {
const p = payload as unknown as { if (!isClassBarChartPayload(payload)) return null
name: string
totalErrorItems: number
studentCount: number
averageErrorPerStudent: number
averageMasteryRate: number
dueReviewCount: number
}
return ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="font-medium">{p.name}</div> <div className="font-medium">{payload.name}</div>
<div className="text-muted-foreground"> <div className="text-muted-foreground">
<span className="font-medium text-foreground">{p.totalErrorItems}</span> {t("classErrorBar.errorCount")}
<span className="font-medium text-foreground">{payload.totalErrorItems}</span>
</div> </div>
<div className="text-muted-foreground"> <div className="text-muted-foreground">
<span className="font-medium text-foreground">{p.studentCount}</span> {t("classErrorBar.studentCount")}
<span className="font-medium text-foreground">{payload.studentCount}</span>
</div> </div>
<div className="text-muted-foreground"> <div className="text-muted-foreground">
<span className="font-medium text-foreground">{p.averageErrorPerStudent}</span> {t("classErrorBar.avgPerStudent")}
<span className="font-medium text-foreground">{payload.averageErrorPerStudent}</span>
</div> </div>
<div className="text-muted-foreground"> <div className="text-muted-foreground">
<span className="font-medium text-foreground">{p.averageMasteryRate}%</span> {t("classErrorBar.avgMastery")}
<span className="font-medium text-foreground">{payload.averageMasteryRate}%</span>
</div> </div>
<div className="text-muted-foreground"> <div className="text-muted-foreground">
<span className="font-medium text-rose-600">{p.dueReviewCount}</span> {t("classErrorBar.dueReview")}
<span className="font-medium text-rose-600">{payload.dueReviewCount}</span>
</div> </div>
</div> </div>
) )

View File

@@ -1,198 +0,0 @@
import Link from "next/link"
import { Users, AlertTriangle, TrendingUp, Target } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { StatCard } from "@/shared/components/ui/stat-card"
import { Badge } from "@/shared/components/ui/badge"
import { Progress } from "@/shared/components/ui/progress"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { formatDate, formatNumber } from "@/shared/lib/utils"
import type {
StudentErrorBookSummary,
KnowledgePointWeakness,
SubjectErrorDistribution,
} from "../types"
interface ClassErrorBookOverviewProps {
totalStudents: number
studentsWithErrorBook: number
totalErrorItems: number
averageMasteryRate: number
topWeakKnowledgePoints: KnowledgePointWeakness[]
subjectDistribution: SubjectErrorDistribution[]
}
export function ClassErrorBookOverview({
totalStudents,
studentsWithErrorBook,
totalErrorItems,
averageMasteryRate,
topWeakKnowledgePoints,
subjectDistribution,
}: ClassErrorBookOverviewProps) {
return (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title="覆盖学生"
value={`${studentsWithErrorBook}/${totalStudents}`}
icon={Users}
description="有错题记录的学生数"
/>
<StatCard
title="错题总数"
value={totalErrorItems}
icon={AlertTriangle}
color="text-rose-500"
description="班级累计错题"
/>
<StatCard
title="平均掌握率"
value={`${formatNumber(averageMasteryRate * 100, 0)}%`}
icon={TrendingUp}
color="text-emerald-500"
description="已掌握错题占比"
/>
<StatCard
title="薄弱知识点"
value={topWeakKnowledgePoints.length}
icon={Target}
color="text-amber-500"
description="需重点讲解"
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
{/* 薄弱知识点 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Target className="h-4 w-4" />
Top 10
</CardTitle>
</CardHeader>
<CardContent>
{topWeakKnowledgePoints.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">
</p>
) : (
<div className="space-y-3">
{topWeakKnowledgePoints.map((kp, idx) => (
<div key={kp.knowledgePointId} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="flex items-center gap-2">
<Badge variant="outline" className="w-6 justify-center">
{idx + 1}
</Badge>
<span className="line-clamp-1">{kp.knowledgePointName}</span>
</span>
<span className="text-muted-foreground">
{kp.errorCount} · {formatNumber(kp.masteryRate * 100, 0)}%
</span>
</div>
<Progress value={kp.masteryRate * 100} className="h-1.5" />
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* 学科分布 */}
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
{subjectDistribution.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">
</p>
) : (
<div className="space-y-3">
{subjectDistribution.map((s) => (
<div key={s.subjectId ?? "none"} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>{s.subjectName}</span>
<span className="text-muted-foreground">
{s.errorCount} · {formatNumber(s.masteryRate * 100, 0)}%
</span>
</div>
<Progress value={s.masteryRate * 100} className="h-1.5" />
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
)
}
interface StudentErrorTableProps {
students: StudentErrorBookSummary[]
studentNames: Map<string, string>
basePath: string
}
export function StudentErrorTable({ students, studentNames, basePath }: StudentErrorTableProps) {
if (students.length === 0) {
return (
<EmptyState
icon={Users}
title="暂无学生错题数据"
description="学生完成作业或考试后,错题数据会自动汇总到这里。"
className="h-[300px] bg-card"
/>
)
}
return (
<div className="rounded-md border bg-card">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
</tr>
</thead>
<tbody>
{students.map((s) => {
const name = studentNames.get(s.studentId) ?? "未知"
return (
<tr key={s.studentId} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3">
<Link
href={`${basePath}?studentId=${s.studentId}`}
className="font-medium hover:underline"
>
{name}
</Link>
</td>
<td className="px-4 py-3 text-right">{s.totalCount}</td>
<td className="px-4 py-3 text-right text-blue-600 dark:text-blue-400">{s.newCount}</td>
<td className="px-4 py-3 text-right text-amber-600 dark:text-amber-400">{s.learningCount}</td>
<td className="px-4 py-3 text-right text-emerald-600 dark:text-emerald-400">{s.masteredCount}</td>
<td className="px-4 py-3 text-right text-rose-600 dark:text-rose-400">{s.dueReviewCount}</td>
<td className="px-4 py-3 text-right">
{formatNumber(s.masteredRate * 100, 0)}%
</td>
<td className="px-4 py-3 text-right text-muted-foreground text-xs">
{s.lastActivityAt ? formatDate(s.lastActivityAt) : "-"}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}

View File

@@ -1,6 +1,7 @@
"use client" "use client"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import { useTranslations } from "next-intl"
import { cn } from "@/shared/lib/utils" import { cn } from "@/shared/lib/utils"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
@@ -25,6 +26,7 @@ export function ClassFilter({
}: ClassFilterProps) { }: ClassFilterProps) {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const t = useTranslations("error-book")
const handleSelect = (classId: string) => { const handleSelect = (classId: string) => {
const params = new URLSearchParams(searchParams.toString()) const params = new URLSearchParams(searchParams.toString())
@@ -42,6 +44,8 @@ export function ClassFilter({
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<button <button
type="button" type="button"
role="tab"
aria-selected={currentClassId === "all"}
onClick={() => handleSelect("all")} onClick={() => handleSelect("all")}
className={cn( className={cn(
"inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors", "inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors",
@@ -50,7 +54,7 @@ export function ClassFilter({
: "bg-card hover:bg-muted" : "bg-card hover:bg-muted"
)} )}
> >
<span className="font-medium"></span> <span className="font-medium">{t("classFilter.all")}</span>
</button> </button>
{classes.map((cls) => { {classes.map((cls) => {
const isActive = currentClassId === cls.classId const isActive = currentClassId === cls.classId
@@ -58,6 +62,8 @@ export function ClassFilter({
<button <button
key={cls.classId} key={cls.classId}
type="button" type="button"
role="tab"
aria-selected={isActive}
onClick={() => handleSelect(cls.classId)} onClick={() => handleSelect(cls.classId)}
className={cn( className={cn(
"inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors", "inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors",
@@ -71,7 +77,7 @@ export function ClassFilter({
variant={isActive ? "secondary" : "outline"} variant={isActive ? "secondary" : "outline"}
className="text-xs" className="text-xs"
> >
{cls.totalErrorItems} {t("classFilter.errorCount", { count: cls.totalErrorItems })}
</Badge> </Badge>
{cls.dueReviewCount > 0 ? ( {cls.dueReviewCount > 0 ? (
<span <span
@@ -80,7 +86,7 @@ export function ClassFilter({
isActive ? "text-primary-foreground/80" : "text-rose-600" isActive ? "text-primary-foreground/80" : "text-rose-600"
)} )}
> >
{cls.dueReviewCount} {t("classFilter.dueReview", { count: cls.dueReviewCount })}
</span> </span>
) : null} ) : null}
</button> </button>

View File

@@ -1,7 +1,6 @@
"use client" "use client"
import { useState, useTransition } from "react" import { useState, useTransition } from "react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { Archive, Trash2, FileText, Calendar, History, Target } from "lucide-react" import { Archive, Trash2, FileText, Calendar, History, Target } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
@@ -37,64 +36,31 @@ import {
type ErrorBookItem, type ErrorBookItem,
} 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 { 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"] })
trigger: React.ReactNode trigger: React.ReactNode
/** 当前学生 ID用于 AI 薄弱点分析 */ /** 当前学生 ID保留接口兼容AI 分析已通过 aiAnalysisSlot 注入 */
studentId?: string studentId?: string
/** 全部错题列表(用于 AI 薄弱点分析,不传则禁用 AI 分析 */ /** 全部错题列表(保留接口兼容AI 分析已通过 aiAnalysisSlot 注入 */
errorItems?: ErrorBookItem[] errorItems?: ErrorBookItem[]
/** AI 分析区域插槽(由父组件注入,未提供则不渲染) */
aiAnalysisSlot?: React.ReactNode
/** 发起变式练习回调(未提供则不渲染变式练习入口) */
onStartVariantPractice?: () => void
} }
/** export function ErrorBookDetailDialog({
* 从题目内容中提取纯文本(用于 AI 相似题推荐) item,
* trigger,
* 类型收窄:从 unknown 逐步缩小到具体类型,避免使用 as 断言。 aiAnalysisSlot,
*/ onStartVariantPractice,
function extractQuestionText(content: unknown): string { }: ErrorBookDetailDialogProps) {
if (!content) return ""
if (typeof content === "string") return content
if (typeof content === "object" && content !== null && "text" in content) {
const textValue = (content as Record<string, unknown>).text
if (typeof textValue === "string") return textValue
}
try {
return JSON.stringify(content)
} catch {
return ""
}
}
/**
* 将错题条目转换为 AI 薄弱点分析所需的输入格式
*/
function mapErrorItemsForAnalysis(items: ErrorBookItem[]): Array<{
questionText: string
questionType: string
knowledgePointIds?: string[]
errorCount: number
masteryLevel: number
}> {
return items.map((it) => ({
questionText: extractQuestionText(it.question?.content),
questionType: it.question?.type ?? "unknown",
knowledgePointIds: it.knowledgePointIds ?? undefined,
errorCount: it.reviewCount > 0 ? it.reviewCount : 1,
masteryLevel: it.masteryLevel,
}))
}
export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }: ErrorBookDetailDialogProps) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
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 t = useTranslations("error-book")
const tPractice = useTranslations("practice")
function handleSaveNote() { function handleSaveNote() {
startTransition(async () => { startTransition(async () => {
@@ -140,49 +106,12 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
}) })
} }
/**
* 发起错题变式练习。
*
* 从当前错题出发,创建一个 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"))
}
})
}
function toggleTag(tag: string) { function toggleTag(tag: string) {
setErrorTags((prev) => setErrorTags((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag] prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
) )
} }
// AI 分析所需数据
const currentQuestionText = extractQuestionText(item.question?.content)
const currentQuestionType = item.question?.type
const aiErrorItems = errorItems ? mapErrorItemsForAnalysis(errorItems) : []
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger> <DialogTrigger asChild>{trigger}</DialogTrigger>
@@ -208,10 +137,10 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
<DialogDescription className="flex items-center gap-3 text-xs"> <DialogDescription className="flex items-center gap-3 text-xs">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Calendar className="h-3 w-3" /> <Calendar className="h-3 w-3" />
{formatDate(item.createdAt)} {t("itemCard.addedAt", { date: formatDate(item.createdAt) })}
</span> </span>
<span>: {item.masteryLevel}/5</span> <span>{t("itemCard.masteryOutOf", { level: item.masteryLevel })}</span>
<span> {item.reviewCount} </span> <span>{t("itemCard.reviewTimes", { count: item.reviewCount })}</span>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -219,7 +148,7 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
<div className="space-y-4 pb-4"> <div className="space-y-4 pb-4">
{/* 题目内容 */} {/* 题目内容 */}
<section> <section>
<h4 className="mb-2 text-sm font-medium"></h4> <h4 className="mb-2 text-sm font-medium">{t("detailDialog.question")}</h4>
<div className="rounded-md border bg-muted/30 p-3 text-sm"> <div className="rounded-md border bg-muted/30 p-3 text-sm">
{item.question ? ( {item.question ? (
<pre className="whitespace-pre-wrap break-words font-sans"> <pre className="whitespace-pre-wrap break-words font-sans">
@@ -228,7 +157,9 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
: JSON.stringify(item.question.content, null, 2)} : JSON.stringify(item.question.content, null, 2)}
</pre> </pre>
) : ( ) : (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground">
{t("detailDialog.questionDeleted")}
</span>
)} )}
</div> </div>
</section> </section>
@@ -239,7 +170,7 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
{item.studentAnswer !== null && item.studentAnswer !== undefined ? ( {item.studentAnswer !== null && item.studentAnswer !== undefined ? (
<div> <div>
<h4 className="mb-2 text-sm font-medium text-rose-600 dark:text-rose-400"> <h4 className="mb-2 text-sm font-medium text-rose-600 dark:text-rose-400">
{t("detailDialog.myAnswer")}
</h4> </h4>
<div className="rounded-md border border-rose-200 bg-rose-50/50 p-3 text-sm dark:border-rose-900 dark:bg-rose-950/20"> <div className="rounded-md border border-rose-200 bg-rose-50/50 p-3 text-sm dark:border-rose-900 dark:bg-rose-950/20">
<pre className="whitespace-pre-wrap break-words font-sans"> <pre className="whitespace-pre-wrap break-words font-sans">
@@ -253,7 +184,7 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
{item.correctAnswer !== null && item.correctAnswer !== undefined ? ( {item.correctAnswer !== null && item.correctAnswer !== undefined ? (
<div> <div>
<h4 className="mb-2 text-sm font-medium text-emerald-600 dark:text-emerald-400"> <h4 className="mb-2 text-sm font-medium text-emerald-600 dark:text-emerald-400">
{t("detailDialog.correctAnswer")}
</h4> </h4>
<div className="rounded-md border border-emerald-200 bg-emerald-50/50 p-3 text-sm dark:border-emerald-900 dark:bg-emerald-950/20"> <div className="rounded-md border border-emerald-200 bg-emerald-50/50 p-3 text-sm dark:border-emerald-900 dark:bg-emerald-950/20">
<pre className="whitespace-pre-wrap break-words font-sans"> <pre className="whitespace-pre-wrap break-words font-sans">
@@ -267,40 +198,36 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
</section> </section>
) : null} ) : null}
{/* AI 分析区(相似题推荐 + 薄弱点分析 */} {/* AI 分析区(由父组件注入 */}
{studentId && currentQuestionText ? ( {aiAnalysisSlot ? (
<section> <section>
<h4 className="mb-2 text-sm font-medium">AI </h4> <h4 className="mb-2 text-sm font-medium">{t("detailDialog.aiAnalysis")}</h4>
<AiErrorBookAnalysis {aiAnalysisSlot}
studentId={studentId}
subjectId={item.subjectId ?? undefined}
currentQuestionText={currentQuestionText}
currentQuestionType={currentQuestionType}
errorItems={aiErrorItems}
/>
</section> </section>
) : null} ) : null}
{/* 变式练习入口 */} {/* 变式练习入口(由父组件注入回调) */}
<section> {onStartVariantPractice ? (
<Button <section>
onClick={handleStartVariantPractice} <Button
disabled={isPending} onClick={onStartVariantPractice}
className="w-full" disabled={isPending}
variant="default" className="w-full"
> variant="default"
<Target className="h-4 w-4" /> >
{isPending ? tPractice("starter.creating") : tPractice("types.error_variant")} <Target className="h-4 w-4" />
</Button> {t("detailDialog.variantPractice")}
<p className="mt-1 text-xs text-muted-foreground text-center"> </Button>
{tPractice("starter.description")} <p className="mt-1 text-xs text-muted-foreground text-center">
</p> {t("detailDialog.variantPracticeDesc")}
</section> </p>
</section>
) : null}
{/* 复习区 */} {/* 复习区 */}
{item.status !== "mastered" && item.status !== "archived" ? ( {item.status !== "mastered" && item.status !== "archived" ? (
<section> <section>
<h4 className="mb-2 text-sm font-medium"></h4> <h4 className="mb-2 text-sm font-medium">{t("detailDialog.reviewSelf")}</h4>
<ReviewButtons <ReviewButtons
itemId={item.id} itemId={item.id}
onReviewed={() => setOpen(false)} onReviewed={() => setOpen(false)}
@@ -312,17 +239,19 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
<section> <section>
<h4 className="mb-2 flex items-center gap-1 text-sm font-medium"> <h4 className="mb-2 flex items-center gap-1 text-sm font-medium">
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
{t("detailDialog.studyNote")}
</h4> </h4>
<textarea <textarea
value={note} value={note}
onChange={(e) => setNote(e.target.value)} onChange={(e) => setNote(e.target.value)}
placeholder="记录你的反思、解题思路、易错点..." placeholder={t("detailDialog.notePlaceholder")}
className="w-full min-h-[80px] rounded-md border bg-background p-3 text-sm resize-y focus:outline-none focus:ring-2 focus:ring-ring" className="w-full min-h-[80px] rounded-md border bg-background p-3 text-sm resize-y focus:outline-none focus:ring-2 focus:ring-ring"
maxLength={2000} maxLength={2000}
/> />
<div className="mt-2"> <div className="mt-2">
<p className="mb-1 text-xs text-muted-foreground"></p> <p className="mb-1 text-xs text-muted-foreground">
{t("detailDialog.errorTagsLabel")}
</p>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{COMMON_ERROR_TAGS.map((tag) => ( {COMMON_ERROR_TAGS.map((tag) => (
<Badge <Badge
@@ -343,7 +272,7 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
disabled={isPending} disabled={isPending}
onClick={handleSaveNote} onClick={handleSaveNote}
> >
{t("actions.saveNote")}
</Button> </Button>
</section> </section>
@@ -352,7 +281,7 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
<section> <section>
<h4 className="mb-2 flex items-center gap-1 text-sm font-medium"> <h4 className="mb-2 flex items-center gap-1 text-sm font-medium">
<History className="h-4 w-4" /> <History className="h-4 w-4" />
{t("detailDialog.reviewHistory")}
</h4> </h4>
<div className="space-y-1"> <div className="space-y-1">
{item.reviews.slice(0, 10).map((r) => ( {item.reviews.slice(0, 10).map((r) => (
@@ -386,7 +315,7 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
onClick={handleArchive} onClick={handleArchive}
> >
<Archive className="h-4 w-4" data-icon="inline-start" /> <Archive className="h-4 w-4" data-icon="inline-start" />
{t("actions.archive")}
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -396,7 +325,7 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
className="text-destructive hover:text-destructive" className="text-destructive hover:text-destructive"
> >
<Trash2 className="h-4 w-4" data-icon="inline-start" /> <Trash2 className="h-4 w-4" data-icon="inline-start" />
{t("actions.delete")}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
"use client" "use client"
import { useQueryState, parseAsString } from "nuqs" import { useQueryState, parseAsString } from "nuqs"
import { useTranslations } from "next-intl"
import { import {
Select, Select,
@@ -12,6 +13,7 @@ import {
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar" import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
export function ErrorBookFilters() { export function ErrorBookFilters() {
const t = useTranslations("error-book")
const [search, setSearch] = useQueryState("q", parseAsString.withDefault("")) const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all")) const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all"))
const [sourceType, setSourceType] = useQueryState("source", parseAsString.withDefault("all")) const [sourceType, setSourceType] = useQueryState("source", parseAsString.withDefault("all"))
@@ -37,40 +39,40 @@ export function ErrorBookFilters() {
<FilterSearchInput <FilterSearchInput
value={search} value={search}
onChange={(v) => setSearch(v || null)} onChange={(v) => setSearch(v || null)}
placeholder="搜索笔记内容..." placeholder={t("filters.searchPlaceholder")}
className="flex-1 md:max-w-sm" className="flex-1 md:max-w-sm"
inputClassName="border-muted-foreground/20 pl-8" inputClassName="border-muted-foreground/20 pl-8"
/> />
<Select value={status} onValueChange={(val) => setStatus(val === "all" ? null : val)}> <Select value={status} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
<SelectTrigger className="w-[140px]"> <SelectTrigger className="w-[140px]">
<SelectValue placeholder="状态" /> <SelectValue placeholder={t("filters.status")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all"></SelectItem> <SelectItem value="all">{t("filters.allStatus")}</SelectItem>
<SelectItem value="new"></SelectItem> <SelectItem value="new">{t("status.new")}</SelectItem>
<SelectItem value="learning"></SelectItem> <SelectItem value="learning">{t("status.learning")}</SelectItem>
<SelectItem value="mastered"></SelectItem> <SelectItem value="mastered">{t("status.mastered")}</SelectItem>
<SelectItem value="archived"></SelectItem> <SelectItem value="archived">{t("status.archived")}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={sourceType} onValueChange={(val) => setSourceType(val === "all" ? null : val)}> <Select value={sourceType} onValueChange={(val) => setSourceType(val === "all" ? null : val)}>
<SelectTrigger className="w-[140px]"> <SelectTrigger className="w-[140px]">
<SelectValue placeholder="来源" /> <SelectValue placeholder={t("filters.source")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all"></SelectItem> <SelectItem value="all">{t("filters.allSource")}</SelectItem>
<SelectItem value="exam"></SelectItem> <SelectItem value="exam">{t("source.exam")}</SelectItem>
<SelectItem value="homework"></SelectItem> <SelectItem value="homework">{t("source.homework")}</SelectItem>
<SelectItem value="manual"></SelectItem> <SelectItem value="manual">{t("source.manual")}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={dueOnly} onValueChange={(val) => setDueOnly(val === "all" ? null : val)}> <Select value={dueOnly} onValueChange={(val) => setDueOnly(val === "all" ? null : val)}>
<SelectTrigger className="w-[140px]"> <SelectTrigger className="w-[140px]">
<SelectValue placeholder="复习" /> <SelectValue placeholder={t("filters.review")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all"></SelectItem> <SelectItem value="all">{t("filters.allErrors")}</SelectItem>
<SelectItem value="due"></SelectItem> <SelectItem value="due">{t("filters.dueOnly")}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>

View File

@@ -1,10 +1,14 @@
"use client"
import { Calendar, FileText, BookMarked } from "lucide-react" import { Calendar, FileText, BookMarked } from "lucide-react"
import { useTranslations } from "next-intl"
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { StatusBadge } from "@/shared/components/ui/status-badge" import { StatusBadge } from "@/shared/components/ui/status-badge"
import { cn } from "@/shared/lib/utils" import { cn } from "@/shared/lib/utils"
import { formatDate } from "@/shared/lib/utils" import { formatDate } from "@/shared/lib/utils"
import { extractQuestionPreview } from "@/shared/lib/question-content"
import { import {
ERROR_BOOK_SOURCE_LABEL, ERROR_BOOK_SOURCE_LABEL,
@@ -19,45 +23,19 @@ interface ErrorBookItemCardProps {
children?: React.ReactNode children?: React.ReactNode
} }
/** 从题目内容中提取纯文本预览 */
function extractQuestionPreview(content: unknown): string {
if (typeof content === "string") return content
if (Array.isArray(content)) {
const texts: string[] = []
for (const node of content) {
if (typeof node === "string") {
texts.push(node)
} else if (typeof node === "object" && node !== null) {
const n = node as Record<string, unknown>
if (typeof n.text === "string") texts.push(n.text)
if (Array.isArray(n.children)) {
texts.push(extractQuestionPreview(n.children))
}
}
}
return texts.join("")
}
if (typeof content === "object" && content !== null) {
const c = content as Record<string, unknown>
if (typeof c.text === "string") return c.text
}
return "(题目内容)"
}
const MASTERY_LEVEL_LABELS: Record<number, string> = {
0: "未学习",
1: "入门",
2: "了解",
3: "熟悉",
4: "熟练",
5: "掌握",
}
export function ErrorBookItemCard({ item, children }: ErrorBookItemCardProps) { export function ErrorBookItemCard({ item, children }: ErrorBookItemCardProps) {
const preview = item.question ? extractQuestionPreview(item.question.content) : "(题目已删除)" const t = useTranslations("error-book")
const preview = item.question
? extractQuestionPreview(item.question.content, t("itemCard.questionContent"))
: t("itemCard.questionDeleted")
const isDue = item.nextReviewAt ? item.nextReviewAt <= new Date() : false const isDue = item.nextReviewAt ? item.nextReviewAt <= new Date() : false
const isMastered = item.status === "mastered" const isMastered = item.status === "mastered"
const masteryLabel =
item.masteryLevel >= 0 && item.masteryLevel <= 5
? t(`masteryLevel.${item.masteryLevel}`)
: String(item.masteryLevel)
return ( return (
<Card <Card
className={cn( className={cn(
@@ -87,7 +65,7 @@ export function ErrorBookItemCard({ item, children }: ErrorBookItemCardProps) {
) : null} ) : null}
{item.question?.difficulty ? ( {item.question?.difficulty ? (
<Badge variant="secondary"> <Badge variant="secondary">
{item.question.difficulty} {t("itemCard.difficulty", { level: item.question.difficulty })}
</Badge> </Badge>
) : null} ) : null}
</div> </div>
@@ -120,11 +98,13 @@ export function ErrorBookItemCard({ item, children }: ErrorBookItemCardProps) {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-xs text-muted-foreground"> <div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>: {MASTERY_LEVEL_LABELS[item.masteryLevel] ?? item.masteryLevel}</span> <span>{t("itemCard.mastery", { level: masteryLabel })}</span>
<span> {item.reviewCount} </span> <span>{t("itemCard.reviewTimes", { count: item.reviewCount })}</span>
{item.nextReviewAt && !isMastered ? ( {item.nextReviewAt && !isMastered ? (
<span className={cn(isDue && "font-medium text-rose-600 dark:text-rose-400")}> <span className={cn(isDue && "font-medium text-rose-600 dark:text-rose-400")}>
{isDue ? "需复习" : `下次 ${formatDate(item.nextReviewAt)}`} {isDue
? t("itemCard.needReview")
: t("itemCard.nextReview", { date: formatDate(item.nextReviewAt) })}
</span> </span>
) : null} ) : null}
</div> </div>

View File

@@ -1,4 +1,8 @@
"use client"
import type { JSX } from "react"
import { BookX } from "lucide-react" import { BookX } from "lucide-react"
import { useTranslations } from "next-intl"
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
@@ -8,19 +12,25 @@ import type { ErrorBookItem } from "../types"
interface ErrorBookListProps { interface ErrorBookListProps {
items: ErrorBookItem[] items: ErrorBookItem[]
/** 当前学生 ID用于 AI 薄弱点分析 */ /** AI 分析区域插槽(由父组件注入,传递给每个 ErrorBookDetailDialog */
studentId?: string aiAnalysisSlot?: (item: ErrorBookItem) => React.ReactNode
/** 全部错题列表(用于 AI 薄弱点分析,不传则禁用 AI 分析 */ /** 发起变式练习回调(由父组件注入 */
errorItems?: ErrorBookItem[] onStartVariantPractice?: (item: ErrorBookItem) => void
} }
export function ErrorBookList({ items, studentId, errorItems }: ErrorBookListProps) { export function ErrorBookList({
items,
aiAnalysisSlot,
onStartVariantPractice,
}: ErrorBookListProps): JSX.Element {
const t = useTranslations("error-book")
if (items.length === 0) { if (items.length === 0) {
return ( return (
<EmptyState <EmptyState
icon={BookX} icon={BookX}
title="错题本为空" title={t("empty.title")}
description="完成考试或作业后,错题会自动收录到这里。你也可以手动添加错题。" description={t("empty.description")}
className="h-[360px] bg-card" className="h-[360px] bg-card"
/> />
) )
@@ -32,11 +42,11 @@ export function ErrorBookList({ items, studentId, errorItems }: ErrorBookListPro
<ErrorBookItemCard key={item.id} item={item}> <ErrorBookItemCard key={item.id} item={item}>
<ErrorBookDetailDialog <ErrorBookDetailDialog
item={item} item={item}
studentId={studentId} aiAnalysisSlot={aiAnalysisSlot?.(item)}
errorItems={errorItems} onStartVariantPractice={onStartVariantPractice ? () => onStartVariantPractice(item) : undefined}
trigger={ trigger={
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
{t("actions.viewDetail")}
</Button> </Button>
} }
/> />

View File

@@ -1,4 +1,7 @@
"use client"
import { BookX, Clock, GraduationCap, Repeat, Sparkles } from "lucide-react" import { BookX, Clock, GraduationCap, Repeat, Sparkles } from "lucide-react"
import { useTranslations } from "next-intl"
import { StatCard } from "@/shared/components/ui/stat-card" import { StatCard } from "@/shared/components/ui/stat-card"
import type { ErrorBookStats } from "../types" import type { ErrorBookStats } from "../types"
@@ -9,6 +12,7 @@ interface ErrorBookStatsCardsProps {
} }
export function ErrorBookStatsCards({ stats, isLoading }: ErrorBookStatsCardsProps) { export function ErrorBookStatsCards({ stats, isLoading }: ErrorBookStatsCardsProps) {
const t = useTranslations("error-book")
const masteredPercent = stats.totalCount > 0 const masteredPercent = stats.totalCount > 0
? Math.round(stats.masteredRate * 100) ? Math.round(stats.masteredRate * 100)
: 0 : 0
@@ -16,42 +20,42 @@ export function ErrorBookStatsCards({ stats, isLoading }: ErrorBookStatsCardsPro
return ( return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<StatCard <StatCard
title="错题总数" title={t("stats.total")}
value={stats.totalCount} value={stats.totalCount}
icon={BookX} icon={BookX}
description="累计收录的错题" description={t("stats.totalDesc")}
isLoading={isLoading} isLoading={isLoading}
/> />
<StatCard <StatCard
title="待学习" title={t("stats.new")}
value={stats.newCount} value={stats.newCount}
icon={Sparkles} icon={Sparkles}
color="text-blue-500" color="text-blue-500"
description="尚未开始复习" description={t("stats.newDesc")}
isLoading={isLoading} isLoading={isLoading}
/> />
<StatCard <StatCard
title="学习中" title={t("stats.learning")}
value={stats.learningCount} value={stats.learningCount}
icon={Repeat} icon={Repeat}
color="text-amber-500" color="text-amber-500"
description="正在复习掌握" description={t("stats.learningDesc")}
isLoading={isLoading} isLoading={isLoading}
/> />
<StatCard <StatCard
title="已掌握" title={t("stats.mastered")}
value={stats.masteredCount} value={stats.masteredCount}
icon={GraduationCap} icon={GraduationCap}
color="text-emerald-500" color="text-emerald-500"
description={`掌握率 ${masteredPercent}%`} description={t("stats.masteredDesc", { rate: masteredPercent })}
isLoading={isLoading} isLoading={isLoading}
/> />
<StatCard <StatCard
title="待复习" title={t("stats.dueReview")}
value={stats.dueReviewCount} value={stats.dueReviewCount}
icon={Clock} icon={Clock}
color="text-rose-500" color="text-rose-500"
description="今日到期复习" description={t("stats.dueReviewDesc")}
highlight={stats.dueReviewCount > 0} highlight={stats.dueReviewCount > 0}
isLoading={isLoading} isLoading={isLoading}
/> />

View File

@@ -1,7 +1,9 @@
"use client" "use client"
import { useState } from "react" import { useState } from "react"
import Link from "next/link"
import { ChevronDown, ChevronRight, Users } from "lucide-react" import { ChevronDown, ChevronRight, Users } from "lucide-react"
import { useTranslations } from "next-intl"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { Progress } from "@/shared/components/ui/progress" import { Progress } from "@/shared/components/ui/progress"
@@ -32,6 +34,7 @@ export function GroupedStudentErrorTable({
studentNames, studentNames,
basePath, basePath,
}: GroupedStudentErrorTableProps) { }: GroupedStudentErrorTableProps) {
const t = useTranslations("error-book")
const [expandedClasses, setExpandedClasses] = useState<Set<string>>(new Set()) const [expandedClasses, setExpandedClasses] = useState<Set<string>>(new Set())
// 按班级分组 // 按班级分组
@@ -44,7 +47,7 @@ export function GroupedStudentErrorTable({
if (!group) { if (!group) {
group = { group = {
classId: key, classId: key,
className: student.className ?? "未分班", className: student.className ?? t("groupedTable.unclassified"),
students: [], students: [],
totalErrors: 0, totalErrors: 0,
averageMasteryRate: 0, averageMasteryRate: 0,
@@ -95,6 +98,7 @@ export function GroupedStudentErrorTable({
{/* 班级头部(可点击展开) */} {/* 班级头部(可点击展开) */}
<button <button
type="button" type="button"
aria-expanded={isExpanded}
onClick={() => toggleClass(group.classId)} 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" className="flex w-full items-center justify-between gap-3 bg-muted/50 p-3 text-left transition-colors hover:bg-muted"
> >
@@ -107,21 +111,21 @@ export function GroupedStudentErrorTable({
<Users className="h-4 w-4 text-muted-foreground" /> <Users className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{group.className}</span> <span className="font-medium">{group.className}</span>
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{group.students.length} {t("groupedTable.studentCount", { count: group.students.length })}
</Badge> </Badge>
{studentsWithErrors.length < group.students.length ? ( {studentsWithErrors.length < group.students.length ? (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{studentsWithErrors.length} {t("groupedTable.studentsWithErrors", { count: studentsWithErrors.length })}
</Badge> </Badge>
) : null} ) : null}
</div> </div>
<div className="flex items-center gap-4 text-sm"> <div className="flex items-center gap-4 text-sm">
<div className="text-right"> <div className="text-right">
<div className="text-xs text-muted-foreground"></div> <div className="text-xs text-muted-foreground">{t("groupedTable.totalErrors")}</div>
<div className="font-bold text-rose-600">{group.totalErrors}</div> <div className="font-bold text-rose-600">{group.totalErrors}</div>
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="text-xs text-muted-foreground"></div> <div className="text-xs text-muted-foreground">{t("groupedTable.avgMastery")}</div>
<div className="font-medium"> <div className="font-medium">
{Math.round(group.averageMasteryRate * 100)}% {Math.round(group.averageMasteryRate * 100)}%
</div> </div>
@@ -135,18 +139,18 @@ export function GroupedStudentErrorTable({
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="border-b bg-muted/30"> <thead className="border-b bg-muted/30">
<tr className="text-left text-xs text-muted-foreground"> <tr className="text-left text-xs text-muted-foreground">
<th className="px-3 py-2 font-medium"></th> <th className="px-3 py-2 font-medium">{t("groupedTable.student")}</th>
<th className="px-3 py-2 text-right font-medium"></th> <th className="px-3 py-2 text-right font-medium">{t("groupedTable.totalErrors")}</th>
<th className="px-3 py-2 text-right font-medium"></th> <th className="px-3 py-2 text-right font-medium">{t("groupedTable.new")}</th>
<th className="px-3 py-2 text-right font-medium"></th> <th className="px-3 py-2 text-right font-medium">{t("groupedTable.learning")}</th>
<th className="px-3 py-2 text-right font-medium"></th> <th className="px-3 py-2 text-right font-medium">{t("groupedTable.mastered")}</th>
<th className="px-3 py-2 text-right font-medium"></th> <th className="px-3 py-2 text-right font-medium">{t("groupedTable.dueReview")}</th>
<th className="px-3 py-2 font-medium"></th> <th className="px-3 py-2 font-medium">{t("groupedTable.masteryRate")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{group.students.map((student) => { {group.students.map((student) => {
const name = studentNames.get(student.studentId) ?? "未知" const name = studentNames.get(student.studentId) ?? t("groupedTable.unknown")
const hasErrors = student.totalCount > 0 const hasErrors = student.totalCount > 0
return ( return (
<tr <tr
@@ -158,12 +162,12 @@ export function GroupedStudentErrorTable({
> >
<td className="px-3 py-2"> <td className="px-3 py-2">
{hasErrors ? ( {hasErrors ? (
<a <Link
href={`${basePath}?studentId=${student.studentId}`} href={`${basePath}?studentId=${student.studentId}`}
className="font-medium text-primary hover:underline" className="font-medium text-primary hover:underline"
> >
{name} {name}
</a> </Link>
) : ( ) : (
<span className="text-muted-foreground">{name}</span> <span className="text-muted-foreground">{name}</span>
)} )}

View File

@@ -1,6 +1,7 @@
"use client" "use client"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts" import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts"
import { useTranslations } from "next-intl"
import { import {
ChartContainer, ChartContainer,
ChartTooltip, ChartTooltip,
@@ -18,6 +19,27 @@ interface KnowledgePointWeaknessChartProps {
className?: string className?: string
} }
interface KpChartPayload {
name: string
errorCount: number
masteredCount: number
masteryRate: number
chapterTitle: string
}
function isKpChartPayload(v: unknown): v is KpChartPayload {
if (typeof v !== "object" || v === null) return false
// 从 unknown 转换:类型守卫内需要属性访问来校验字段类型
const obj = v as Record<string, unknown>
return (
typeof obj.name === "string" &&
typeof obj.errorCount === "number" &&
typeof obj.masteredCount === "number" &&
typeof obj.masteryRate === "number" &&
typeof obj.chapterTitle === "string"
)
}
/** /**
* 知识点薄弱度图表 * 知识点薄弱度图表
* 横向柱状图,按错题数降序 * 横向柱状图,按错题数降序
@@ -28,6 +50,8 @@ export function KnowledgePointWeaknessChart({
data, data,
className, className,
}: KnowledgePointWeaknessChartProps) { }: KnowledgePointWeaknessChartProps) {
const t = useTranslations("error-book")
if (data.length === 0) return null if (data.length === 0) return null
const chartData = data.map((d) => ({ const chartData = data.map((d) => ({
@@ -35,12 +59,12 @@ export function KnowledgePointWeaknessChart({
errorCount: d.errorCount, errorCount: d.errorCount,
masteredCount: d.masteredCount, masteredCount: d.masteredCount,
masteryRate: Number((d.masteryRate * 100).toFixed(0)), masteryRate: Number((d.masteryRate * 100).toFixed(0)),
chapterTitle: d.chapterTitle ?? "未分类", chapterTitle: d.chapterTitle ?? t("weaknessChart.unclassified"),
})) }))
const chartConfig: ChartConfig = { const chartConfig: ChartConfig = {
errorCount: { errorCount: {
label: "错题数", label: t("weaknessChart.errorCount"),
color: "var(--color-chart-1)", color: "var(--color-chart-1)",
}, },
} }
@@ -48,10 +72,16 @@ export function KnowledgePointWeaknessChart({
return ( return (
<Card className={cn("overflow-hidden", className)}> <Card className={cn("overflow-hidden", className)}>
<CardHeader> <CardHeader>
<CardTitle className="text-base"> Top {data.length}</CardTitle> <CardTitle className="text-base">
{t("weaknessChart.title", { count: data.length })}
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<ChartContainer config={chartConfig} className="h-[320px] w-full"> <ChartContainer
config={chartConfig}
className="h-[320px] w-full"
aria-label={t("weaknessChart.title", { count: data.length })}
>
<BarChart <BarChart
data={chartData} data={chartData}
layout="vertical" layout="vertical"
@@ -74,25 +104,24 @@ export function KnowledgePointWeaknessChart({
<ChartTooltipContent <ChartTooltipContent
className="w-[240px]" className="w-[240px]"
formatter={(payload: unknown) => { formatter={(payload: unknown) => {
const p = payload as unknown as { if (!isKpChartPayload(payload)) return null
name: string
errorCount: number
masteredCount: number
masteryRate: number
chapterTitle: string
}
return ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="font-medium">{p.name}</div> <div className="font-medium">{payload.name}</div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{p.chapterTitle} {t("weaknessChart.chapterLabel", { title: payload.chapterTitle })}
</div> </div>
<div className="text-muted-foreground"> <div className="text-muted-foreground">
<span className="font-medium text-foreground">{p.errorCount}</span> {t("weaknessChart.errorCount")}
<span className="ml-2"><span className="font-medium text-emerald-600">{p.masteredCount}</span></span> <span className="font-medium text-foreground">{payload.errorCount}</span>
<span className="ml-2">
{t("weaknessChart.masteredLabel")}
<span className="font-medium text-emerald-600">{payload.masteredCount}</span>
</span>
</div> </div>
<div className="text-muted-foreground"> <div className="text-muted-foreground">
<span className="font-medium text-foreground">{p.masteryRate}%</span> {t("weaknessChart.masteryRateLabel")}
<span className="font-medium text-foreground">{payload.masteryRate}%</span>
</div> </div>
</div> </div>
) )

View File

@@ -3,6 +3,7 @@
import { useState, useTransition } from "react" import { useState, useTransition } from "react"
import { RotateCcw, ThumbsUp, Check, Zap } from "lucide-react" import { RotateCcw, ThumbsUp, Check, Zap } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { reviewErrorBookItemAction } from "../actions" import { reviewErrorBookItemAction } from "../actions"
@@ -13,47 +14,50 @@ interface ReviewButtonsProps {
onReviewed?: () => void onReviewed?: () => void
} }
const REVIEW_OPTIONS: Array<{ type ReviewOption = {
result: ErrorBookReviewResultValue result: ErrorBookReviewResultValue
label: string label: string
description: string description: string
icon: typeof RotateCcw icon: typeof RotateCcw
variant: "destructive" | "secondary" | "default" | "outline" variant: "destructive" | "secondary" | "default" | "outline"
}> = [ }
{
result: "again",
label: "重来",
description: "完全不会,明天再复习",
icon: RotateCcw,
variant: "destructive",
},
{
result: "hard",
label: "困难",
description: "勉强答对2 天后复习",
icon: Zap,
variant: "secondary",
},
{
result: "good",
label: "良好",
description: "正常答对4 天后复习",
icon: ThumbsUp,
variant: "default",
},
{
result: "easy",
label: "简单",
description: "轻松答对7 天后复习",
icon: Check,
variant: "outline",
},
]
export function ReviewButtons({ itemId, onReviewed }: ReviewButtonsProps) { export function ReviewButtons({ itemId, onReviewed }: ReviewButtonsProps) {
const t = useTranslations("error-book")
const [isPending, startTransition] = useTransition() const [isPending, startTransition] = useTransition()
const [selected, setSelected] = useState<ErrorBookReviewResultValue | null>(null) const [selected, setSelected] = useState<ErrorBookReviewResultValue | null>(null)
const reviewOptions: ReviewOption[] = [
{
result: "again",
label: t("review.again"),
description: t("review.againDesc"),
icon: RotateCcw,
variant: "destructive",
},
{
result: "hard",
label: t("review.hard"),
description: t("review.hardDesc"),
icon: Zap,
variant: "secondary",
},
{
result: "good",
label: t("review.good"),
description: t("review.goodDesc"),
icon: ThumbsUp,
variant: "default",
},
{
result: "easy",
label: t("review.easy"),
description: t("review.easyDesc"),
icon: Check,
variant: "outline",
},
]
function handleReview(result: ErrorBookReviewResultValue) { function handleReview(result: ErrorBookReviewResultValue) {
setSelected(result) setSelected(result)
startTransition(async () => { startTransition(async () => {
@@ -61,10 +65,10 @@ export function ReviewButtons({ itemId, onReviewed }: ReviewButtonsProps) {
formData.append("json", JSON.stringify({ itemId, result })) formData.append("json", JSON.stringify({ itemId, result }))
const res = await reviewErrorBookItemAction(undefined, formData) const res = await reviewErrorBookItemAction(undefined, formData)
if (res.success) { if (res.success) {
toast.success(res.message ?? "复习结果已记录") toast.success(res.message ?? t("messages.reviewRecorded"))
onReviewed?.() onReviewed?.()
} else { } else {
toast.error(res.message ?? "记录失败") toast.error(res.message ?? t("messages.recordFailed"))
setSelected(null) setSelected(null)
} }
}) })
@@ -72,7 +76,7 @@ export function ReviewButtons({ itemId, onReviewed }: ReviewButtonsProps) {
return ( return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{REVIEW_OPTIONS.map((opt) => { {reviewOptions.map((opt) => {
const Icon = opt.icon const Icon = opt.icon
const isLoading = isPending && selected === opt.result const isLoading = isPending && selected === opt.result
return ( return (

View File

@@ -1,6 +1,7 @@
"use client" "use client"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts" import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts"
import { useTranslations } from "next-intl"
import { import {
ChartContainer, ChartContainer,
ChartTooltip, ChartTooltip,
@@ -17,6 +18,25 @@ interface SubjectDistributionChartProps {
className?: string className?: string
} }
interface SubjectDistChartPayload {
name: string
errorCount: number
masteredCount: number
masteryRate: number
}
function isSubjectDistChartPayload(v: unknown): v is SubjectDistChartPayload {
if (typeof v !== "object" || v === null) return false
// 从 unknown 转换:类型守卫内需要属性访问来校验字段类型
const obj = v as Record<string, unknown>
return (
typeof obj.name === "string" &&
typeof obj.errorCount === "number" &&
typeof obj.masteredCount === "number" &&
typeof obj.masteryRate === "number"
)
}
const SUBJECT_COLORS = [ const SUBJECT_COLORS = [
"var(--color-chart-1)", "var(--color-chart-1)",
"var(--color-chart-2)", "var(--color-chart-2)",
@@ -34,6 +54,8 @@ export function SubjectDistributionChart({
data, data,
className, className,
}: SubjectDistributionChartProps) { }: SubjectDistributionChartProps) {
const t = useTranslations("error-book")
if (data.length === 0) return null if (data.length === 0) return null
const chartData = data.map((d) => ({ const chartData = data.map((d) => ({
@@ -45,7 +67,7 @@ export function SubjectDistributionChart({
const chartConfig: ChartConfig = { const chartConfig: ChartConfig = {
errorCount: { errorCount: {
label: "错题数", label: t("subjectDistChart.errorCount"),
color: "var(--color-chart-1)", color: "var(--color-chart-1)",
}, },
} }
@@ -53,10 +75,14 @@ export function SubjectDistributionChart({
return ( return (
<Card className={cn("overflow-hidden", className)}> <Card className={cn("overflow-hidden", className)}>
<CardHeader> <CardHeader>
<CardTitle className="text-base"></CardTitle> <CardTitle className="text-base">{t("subjectDistChart.title")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ChartContainer config={chartConfig} className="h-[280px] w-full"> <ChartContainer
config={chartConfig}
className="h-[280px] w-full"
aria-label={t("subjectDistChart.title")}
>
<BarChart data={chartData} margin={{ left: 8, right: 8, top: 8, bottom: 8 }}> <BarChart data={chartData} margin={{ left: 8, right: 8, top: 8, bottom: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} /> <CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
<XAxis <XAxis
@@ -74,23 +100,21 @@ export function SubjectDistributionChart({
<ChartTooltipContent <ChartTooltipContent
className="w-[200px]" className="w-[200px]"
formatter={(payload: unknown) => { formatter={(payload: unknown) => {
const p = payload as unknown as { if (!isSubjectDistChartPayload(payload)) return null
name: string
errorCount: number
masteredCount: number
masteryRate: number
}
return ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="font-medium">{p.name}</div> <div className="font-medium">{payload.name}</div>
<div className="text-muted-foreground"> <div className="text-muted-foreground">
<span className="font-medium text-foreground">{p.errorCount}</span> {t("subjectDistChart.errorCount")}
<span className="font-medium text-foreground">{payload.errorCount}</span>
</div> </div>
<div className="text-muted-foreground"> <div className="text-muted-foreground">
<span className="font-medium text-emerald-600">{p.masteredCount}</span> {t("subjectDistChart.masteredLabel")}
<span className="font-medium text-emerald-600">{payload.masteredCount}</span>
</div> </div>
<div className="text-muted-foreground"> <div className="text-muted-foreground">
<span className="font-medium text-foreground">{p.masteryRate}%</span> {t("subjectDistChart.masteryRateLabel")}
<span className="font-medium text-foreground">{payload.masteryRate}%</span>
</div> </div>
</div> </div>
) )

View File

@@ -1,6 +1,7 @@
"use client" "use client"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import { useTranslations } from "next-intl"
import { cn } from "@/shared/lib/utils" import { cn } from "@/shared/lib/utils"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
@@ -25,6 +26,7 @@ export function SubjectTabs({
}: SubjectTabsProps) { }: SubjectTabsProps) {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const t = useTranslations("error-book")
const handleSelect = (subjectId: string | null) => { const handleSelect = (subjectId: string | null) => {
const params = new URLSearchParams(searchParams.toString()) const params = new URLSearchParams(searchParams.toString())
@@ -44,6 +46,8 @@ export function SubjectTabs({
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<button <button
type="button" type="button"
role="tab"
aria-selected={currentSubjectId === null}
onClick={() => handleSelect(null)} onClick={() => handleSelect(null)}
className={cn( className={cn(
"inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors", "inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors",
@@ -52,7 +56,7 @@ export function SubjectTabs({
: "bg-card hover:bg-muted" : "bg-card hover:bg-muted"
)} )}
> >
<span className="font-medium"></span> <span className="font-medium">{t("subjectTabs.all")}</span>
<Badge <Badge
variant={currentSubjectId === null ? "secondary" : "outline"} variant={currentSubjectId === null ? "secondary" : "outline"}
className="text-xs" className="text-xs"
@@ -66,6 +70,8 @@ export function SubjectTabs({
<button <button
key={subject.subjectId} key={subject.subjectId}
type="button" type="button"
role="tab"
aria-selected={isActive}
onClick={() => handleSelect(subject.subjectId)} onClick={() => handleSelect(subject.subjectId)}
className={cn( className={cn(
"inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors", "inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors",
@@ -88,7 +94,7 @@ export function SubjectTabs({
isActive ? "text-primary-foreground/80" : "text-rose-600" isActive ? "text-primary-foreground/80" : "text-rose-600"
)} )}
> >
{subject.dueReviewCount} {t("subjectTabs.dueReview", { count: subject.dueReviewCount })}
</span> </span>
) : null} ) : null}
</button> </button>

View File

@@ -1,8 +1,12 @@
"use client"
import { Flame } from "lucide-react" import { Flame } from "lucide-react"
import { useTranslations } from "next-intl"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
import { extractQuestionPreview } from "@/shared/lib/question-content"
interface TopWrongQuestion { interface TopWrongQuestion {
questionId: string questionId: string
@@ -16,45 +20,36 @@ interface TopWrongQuestionsProps {
questions: TopWrongQuestion[] questions: TopWrongQuestion[]
} }
function extractPreview(content: unknown): string { const KNOWN_QUESTION_TYPES = [
if (typeof content === "string") return content.slice(0, 120) "single_choice",
if (Array.isArray(content)) { "multiple_choice",
const texts: string[] = [] "judgment",
for (const node of content) { "text",
if (typeof node === "string") texts.push(node) "composite",
else if (typeof node === "object" && node !== null) { ] as const
const n = node as Record<string, unknown> type KnownQuestionType = (typeof KNOWN_QUESTION_TYPES)[number]
if (typeof n.text === "string") texts.push(n.text)
}
}
return texts.join("").slice(0, 120)
}
return "题目内容"
}
const QUESTION_TYPE_LABEL: Record<string, string> = { function isKnownQuestionType(v: string): v is KnownQuestionType {
single_choice: "单选", return (KNOWN_QUESTION_TYPES as readonly string[]).includes(v)
multiple_choice: "多选",
judgment: "判断",
text: "简答",
composite: "复合",
} }
export function TopWrongQuestions({ questions }: TopWrongQuestionsProps) { export function TopWrongQuestions({ questions }: TopWrongQuestionsProps) {
const t = useTranslations("error-book")
if (questions.length === 0) { if (questions.length === 0) {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<Flame className="h-4 w-4" /> <Flame className="h-4 w-4" />
{t("topWrong.title")}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<EmptyState <EmptyState
icon={Flame} icon={Flame}
title="暂无高频错题" title={t("topWrong.emptyTitle")}
description="学生完成作业或考试后,错频统计会显示在这里。" description={t("topWrong.emptyDesc")}
className="h-[200px]" className="h-[200px]"
/> />
</CardContent> </CardContent>
@@ -67,7 +62,7 @@ export function TopWrongQuestions({ questions }: TopWrongQuestionsProps) {
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<Flame className="h-4 w-4" /> <Flame className="h-4 w-4" />
Top 10 {t("topWrong.topTitle")}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -84,19 +79,21 @@ export function TopWrongQuestions({ questions }: TopWrongQuestionsProps) {
</Badge> </Badge>
<div className="flex-1 min-w-0 space-y-1"> <div className="flex-1 min-w-0 space-y-1">
<p className="text-sm line-clamp-2"> <p className="text-sm line-clamp-2">
{extractPreview(q.questionContent)} {extractQuestionPreview(q.questionContent, t("itemCard.questionContent"), 120)}
</p> </p>
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{QUESTION_TYPE_LABEL[q.questionType] ?? q.questionType} {isKnownQuestionType(q.questionType)
? t(`questionType.${q.questionType}`)
: q.questionType}
</Badge> </Badge>
<span>{q.errorCount} </span> <span>{t("topWrong.errorCount", { count: q.errorCount })}</span>
<span>·</span> <span>·</span>
<span className="text-emerald-600 dark:text-emerald-400"> <span className="text-emerald-600 dark:text-emerald-400">
{q.masteredCount} {t("topWrong.masteredCount", { count: q.masteredCount })}
</span> </span>
<span>·</span> <span>·</span>
<span> {Math.round(masteryRate * 100)}%</span> <span>{t("topWrong.masteryRate", { rate: Math.round(masteryRate * 100) })}</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,552 @@
import "server-only"
import { count, desc, eq, inArray, sql } from "drizzle-orm"
import { db } from "@/shared/db"
import {
errorBookItems,
questions,
knowledgePoints,
chapters,
subjects,
users,
classEnrollments,
classes,
} from "@/shared/db/schema"
import { getStudentIdsByClassIds } from "@/modules/classes/data-access"
import { ROLE_NAMES } from "@/shared/types/permissions"
import type {
ChapterWeakness,
ClassErrorOverview,
KnowledgePointWeakness,
StudentErrorBookSummary,
SubjectErrorOverview,
} from "./types"
import { buildStudentErrorWhereClause, toStatus, toStringArray } from "./data-access"
// ---------------------------------------------------------------------------
// 教师/管理员分析查询SQL 聚合优化)
// ---------------------------------------------------------------------------
/** 查询多个学生的错题统计(教师视图,支持按学科过滤) */
export async function getStudentErrorBookSummaries(
studentIds: string[],
subjectId?: string | null
): Promise<StudentErrorBookSummary[]> {
if (studentIds.length === 0) return []
const now = new Date()
const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)
// 使用 SQL 条件聚合GROUP BY studentId
const rows = await db
.select({
studentId: errorBookItems.studentId,
totalCount: count(),
newCount: sql<number>`sum(case when ${errorBookItems.status} = 'new' then 1 else 0 end)`,
learningCount: sql<number>`sum(case when ${errorBookItems.status} = 'learning' then 1 else 0 end)`,
masteredCount: sql<number>`sum(case when ${errorBookItems.status} = 'mastered' then 1 else 0 end)`,
dueReviewCount: sql<number>`sum(case when ${errorBookItems.status} not in ('mastered', 'archived') and (${errorBookItems.nextReviewAt} is null or ${errorBookItems.nextReviewAt} <= ${now}) then 1 else 0 end)`,
lastActivityAt: sql<Date | null>`max(${errorBookItems.updatedAt})`,
})
.from(errorBookItems)
.where(whereClause)
.groupBy(errorBookItems.studentId)
// 查询学生所属班级(用于按班级分组展示)
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 })
}
}
return rows.map((row) => {
const totalCount = Number(row.totalCount)
const masteredCount = Number(row.masteredCount)
const classInfo = studentClassMap.get(row.studentId)
return {
studentId: row.studentId,
totalCount,
newCount: Number(row.newCount),
learningCount: Number(row.learningCount),
masteredCount,
dueReviewCount: Number(row.dueReviewCount),
masteredRate: totalCount > 0 ? masteredCount / totalCount : 0,
lastActivityAt: row.lastActivityAt,
classId: classInfo?.classId ?? null,
className: classInfo?.className ?? null,
}
})
}
/** 查询班级内错题最多的题目(教师视图:高频错题,支持按学科过滤) */
export async function getTopWrongQuestionsByStudentIds(
studentIds: string[],
limit = 10,
subjectId?: string | null
): Promise<Array<{
questionId: string
questionContent: unknown
questionType: string
errorCount: number
masteredCount: number
}>> {
if (studentIds.length === 0) return []
const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)
// 使用子查询聚合,避免 GROUP BY JSON 列的问题
const aggregatedSubquery = db
.select({
questionId: errorBookItems.questionId,
errorCount: count(),
masteredCount: sql<number>`sum(case when ${errorBookItems.status} = 'mastered' then 1 else 0 end)`,
})
.from(errorBookItems)
.where(whereClause)
.groupBy(errorBookItems.questionId)
.as("aggregated")
const rows = await db
.select({
questionId: aggregatedSubquery.questionId,
errorCount: aggregatedSubquery.errorCount,
masteredCount: aggregatedSubquery.masteredCount,
content: questions.content,
type: questions.type,
})
.from(aggregatedSubquery)
.innerJoin(questions, eq(questions.id, aggregatedSubquery.questionId))
.orderBy(desc(aggregatedSubquery.errorCount))
.limit(limit)
return rows.map((row) => ({
questionId: row.questionId,
questionContent: row.content,
questionType: row.type,
errorCount: Number(row.errorCount),
masteredCount: Number(row.masteredCount),
}))
}
/** 查询多个学生的知识点薄弱度统计(支持按学科过滤,关联章节信息) */
export async function getKnowledgePointWeakness(
studentIds: string[],
limit = 10,
subjectId?: string | null
): Promise<KnowledgePointWeakness[]> {
if (studentIds.length === 0) return []
const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)
// knowledgePointIds 是 JSON 数组字段,无法直接用 SQL GROUP BY保留 JS 聚合
// 仅查询必要字段(不查询全行)
const rows = await db
.select({
status: errorBookItems.status,
knowledgePointIds: errorBookItems.knowledgePointIds,
})
.from(errorBookItems)
.where(whereClause)
// 展开知识点并统计(使用类型守卫替代 as 断言)
const kpMap = new Map<string, { errorCount: number; masteredCount: number }>()
for (const row of rows) {
const kps = toStringArray(row.knowledgePointIds) ?? []
for (const kpId of kps) {
const stat = kpMap.get(kpId) ?? { errorCount: 0, masteredCount: 0 }
stat.errorCount++
if (toStatus(row.status) === "mastered") stat.masteredCount++
kpMap.set(kpId, stat)
}
}
if (kpMap.size === 0) return []
// 查询知识点名称及所属章节
const kpIds = Array.from(kpMap.keys())
const kpRows = await db
.select({
id: knowledgePoints.id,
name: knowledgePoints.name,
chapterId: knowledgePoints.chapterId,
})
.from(knowledgePoints)
.where(inArray(knowledgePoints.id, kpIds))
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]) => {
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
return a.masteryRate - b.masteryRate
})
.slice(0, limit)
}
/** 查询多个学生的学科错题分布 */
export async function getSubjectErrorDistribution(
studentIds: string[]
): Promise<Array<{
subjectId: string | null
subjectName: string
errorCount: number
masteredCount: number
masteryRate: number
}>> {
if (studentIds.length === 0) return []
// 使用 SQL GROUP BY 聚合
const rows = await db
.select({
subjectId: errorBookItems.subjectId,
errorCount: count(),
masteredCount: sql<number>`sum(case when ${errorBookItems.status} = 'mastered' then 1 else 0 end)`,
})
.from(errorBookItems)
.where(inArray(errorBookItems.studentId, studentIds))
.groupBy(errorBookItems.subjectId)
// 查询学科名称
const subjectIds = rows
.map((r) => r.subjectId)
.filter((s): s is string => s !== null)
let subjectNameMap = new Map<string, string>()
if (subjectIds.length > 0) {
const subjectRows = await db
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.where(inArray(subjects.id, subjectIds))
subjectNameMap = new Map(subjectRows.map((s) => [s.id, s.name]))
}
return rows.map((row) => {
const errorCount = Number(row.errorCount)
const masteredCount = Number(row.masteredCount)
const sid = row.subjectId
return {
subjectId: sid,
subjectName: sid ? (subjectNameMap.get(sid) ?? "未知学科") : "未分类",
errorCount,
masteredCount,
masteryRate: errorCount > 0 ? masteredCount / errorCount : 0,
}
})
}
/**
* 查询章节薄弱度统计(哪些课在错)。
* 通过 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)
// knowledgePointIds 是 JSON 数组字段,无法直接用 SQL GROUP BY保留 JS 聚合
// 仅查询必要字段(不查询全行)
const rows = await db
.select({
status: errorBookItems.status,
knowledgePointIds: errorBookItems.knowledgePointIds,
})
.from(errorBookItems)
.where(whereClause)
// 展开知识点,建立 kpId → 错题统计(使用类型守卫替代 as 断言)
const kpErrorMap = new Map<string, { errorCount: number; masteredCount: number }>()
for (const row of rows) {
const kps = toStringArray(row.knowledgePointIds) ?? []
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)
}
// 查询所有相关学生的错题(使用 SQL 聚合按 studentId 分组)
const allStudentIds = Array.from(new Set(enrollmentRows.map((r) => r.studentId)))
if (allStudentIds.length === 0) return []
const now = new Date()
const whereClause = buildStudentErrorWhereClause(allStudentIds, subjectId)
const errorRows = await db
.select({
studentId: errorBookItems.studentId,
total: count(),
mastered: sql<number>`sum(case when ${errorBookItems.status} = 'mastered' then 1 else 0 end)`,
due: sql<number>`sum(case when ${errorBookItems.status} not in ('mastered', 'archived') and (${errorBookItems.nextReviewAt} is null or ${errorBookItems.nextReviewAt} <= ${now}) then 1 else 0 end)`,
})
.from(errorBookItems)
.where(whereClause)
.groupBy(errorBookItems.studentId)
// 按学生聚合结果映射
const studentStatMap = new Map<string, { total: number; mastered: number; due: number }>()
for (const row of errorRows) {
studentStatMap.set(row.studentId, {
total: Number(row.total),
mastered: Number(row.mastered),
due: Number(row.due),
})
}
// 按班级聚合
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 now = new Date()
// 使用 SQL GROUP BY 聚合(含 count distinct studentId
const rows = await db
.select({
subjectId: errorBookItems.subjectId,
totalErrorItems: count(),
studentCount: sql<number>`count(distinct ${errorBookItems.studentId})`,
masteredCount: sql<number>`sum(case when ${errorBookItems.status} = 'mastered' then 1 else 0 end)`,
dueReviewCount: sql<number>`sum(case when ${errorBookItems.status} not in ('mastered', 'archived') and (${errorBookItems.nextReviewAt} is null or ${errorBookItems.nextReviewAt} <= ${now}) then 1 else 0 end)`,
})
.from(errorBookItems)
.where(inArray(errorBookItems.studentId, studentIds))
.groupBy(errorBookItems.subjectId)
// 过滤无学科的错题
const filtered = rows.filter((r) => r.subjectId !== null)
if (filtered.length === 0) return []
// 查询学科名称
const subjectIds = filtered.map((r) => r.subjectId as string)
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 filtered
.map((row) => {
const totalErrorItems = Number(row.totalErrorItems)
const masteredCount = Number(row.masteredCount)
const sid = row.subjectId as string
return {
subjectId: sid,
subjectName: subjectNameMap.get(sid) ?? "未知学科",
totalErrorItems,
studentCount: Number(row.studentCount),
averageMasteryRate: totalErrorItems > 0 ? masteredCount / totalErrorItems : 0,
dueReviewCount: Number(row.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()
const rows = await db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, studentIds))
return new Map(rows.map((r) => [r.id, r.name ?? "未知"]))
}
/** 按班级 ID 查询学生 ID 列表(委托给 classes 模块) */
export async function getStudentIdsByClassIdList(classIds: string[]): Promise<string[]> {
return await getStudentIdsByClassIds(classIds)
}
/**
* 查询所有学生用户 ID管理员视图
* 通过 usersToRoles + roles 表关联查询 role === "student" 的用户。
* 此函数封装了 DB 访问,避免 app 层直接查询 DB遵循三层架构
*/
export async function getAllStudentIds(): Promise<string[]> {
const { usersToRoles, roles } = await import("@/shared/db/schema")
const studentRole = await db
.select({ id: roles.id })
.from(roles)
.where(eq(roles.name, ROLE_NAMES.STUDENT))
.limit(1)
if (studentRole.length === 0) return []
const userRoleRows = await db
.select({ userId: usersToRoles.userId })
.from(usersToRoles)
.where(eq(usersToRoles.roleId, studentRole[0].id))
return userRoleRows.map((r) => r.userId)
}

View File

@@ -1,23 +1,15 @@
import "server-only" import "server-only"
import { cache } from "react" import { cache } from "react"
import { and, count, desc, eq, inArray, isNull, lte, or, sql, type SQL } from "drizzle-orm" import { and, count, desc, eq, inArray, isNull, lte, not, or, sql, type SQL } from "drizzle-orm"
import { createId } from "@paralleldrive/cuid2" import { createId } from "@paralleldrive/cuid2"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { import {
errorBookItems, errorBookItems,
errorBookReviews, errorBookReviews,
questions,
questionsToKnowledgePoints, questionsToKnowledgePoints,
knowledgePoints,
chapters,
subjects,
users,
classEnrollments,
classes,
} from "@/shared/db/schema" } from "@/shared/db/schema"
import { getStudentIdsByClassIds } from "@/modules/classes/data-access"
import { import {
calculateNewInterval, calculateNewInterval,
calculateNewMastery, calculateNewMastery,
@@ -34,18 +26,9 @@ 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"
// ---------------------------------------------------------------------------
// SM-2 间隔重复算法(简化版)
// ---------------------------------------------------------------------------
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 类型守卫 // 类型守卫
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -59,27 +42,41 @@ const toReviewResult = (v: string | null | undefined): ErrorBookReviewResult =>
const isStatus = (v: unknown): v is ErrorBookStatusValue => const isStatus = (v: unknown): v is ErrorBookStatusValue =>
v === "new" || v === "learning" || v === "mastered" || v === "archived" v === "new" || v === "learning" || v === "mastered" || v === "archived"
const toStatus = (v: string | null | undefined): ErrorBookStatusValue => /** 将数据库状态字符串转换为 ErrorBookStatusValue,无效值回退为 "new" */
export const toStatus = (v: string | null | undefined): ErrorBookStatusValue =>
isStatus(v) ? v : "new" isStatus(v) ? v : "new"
/** 类型守卫:判断未知值是否为 string[] */
function isStringArray(v: unknown): v is string[] {
return Array.isArray(v) && v.every((item) => typeof item === "string")
}
/** 将未知值安全转换为 string[] | null用于 JSON 列字段) */
export function toStringArray(v: unknown): string[] | null {
return isStringArray(v) ? v : null
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 行映射 // 行映射
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function mapRowToItem(row: typeof errorBookItems.$inferSelect & { /** 错题条目查询结果类型(含关联的 question/subject 字段) */
question?: typeof questions.$inferSelect | null type ErrorBookItemWithRelations = typeof errorBookItems.$inferSelect & {
subject?: typeof subjects.$inferSelect | null question?: { id: string; content: unknown; type: string; difficulty: number | null } | null
}): ErrorBookItem { subject?: { id: string; name: string } | null
}
function mapRowToItem(row: ErrorBookItemWithRelations): ErrorBookItem {
return { return {
id: row.id, id: row.id,
studentId: row.studentId, studentId: row.studentId,
questionId: row.questionId, questionId: row.questionId,
sourceType: row.sourceType as ErrorBookItem["sourceType"], sourceType: row.sourceType,
sourceId: row.sourceId, sourceId: row.sourceId,
studentAnswer: row.studentAnswer, studentAnswer: row.studentAnswer,
correctAnswer: row.correctAnswer, correctAnswer: row.correctAnswer,
subjectId: row.subjectId, subjectId: row.subjectId,
knowledgePointIds: row.knowledgePointIds as string[] | null, knowledgePointIds: toStringArray(row.knowledgePointIds),
status: toStatus(row.status), status: toStatus(row.status),
masteryLevel: row.masteryLevel, masteryLevel: row.masteryLevel,
nextReviewAt: row.nextReviewAt, nextReviewAt: row.nextReviewAt,
@@ -87,7 +84,7 @@ function mapRowToItem(row: typeof errorBookItems.$inferSelect & {
reviewCount: row.reviewCount, reviewCount: row.reviewCount,
correctStreak: row.correctStreak, correctStreak: row.correctStreak,
note: row.note, note: row.note,
errorTags: row.errorTags as string[] | null, errorTags: toStringArray(row.errorTags),
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
question: row.question question: row.question
@@ -126,12 +123,13 @@ export const getErrorBookItems = cache(async (params: GetErrorBookItemsParams):
if (dueOnly) { if (dueOnly) {
const now = new Date() const now = new Date()
conditions.push( const dueCondition = or(
or( isNull(errorBookItems.nextReviewAt),
isNull(errorBookItems.nextReviewAt), lte(errorBookItems.nextReviewAt, now)
lte(errorBookItems.nextReviewAt, now)
)!
) )
if (dueCondition) {
conditions.push(dueCondition)
}
} }
if (q && q.trim().length > 0) { if (q && q.trim().length > 0) {
@@ -172,7 +170,7 @@ export const getErrorBookItems = cache(async (params: GetErrorBookItemsParams):
}) })
return { return {
data: rows.map((row) => mapRowToItem(row as unknown as Parameters<typeof mapRowToItem>[0])), data: rows.map((row) => mapRowToItem(row)),
meta: { meta: {
page, page,
pageSize, pageSize,
@@ -219,7 +217,7 @@ export const getErrorBookItemById = cache(async (
if (!row) return null if (!row) return null
const base = mapRowToItem(row as unknown as Parameters<typeof mapRowToItem>[0]) const base = mapRowToItem(row)
const reviews: ErrorBookReviewRecord[] = (row.reviews ?? []).map((r) => ({ const reviews: ErrorBookReviewRecord[] = (row.reviews ?? []).map((r) => ({
id: r.id, id: r.id,
result: toReviewResult(r.result), result: toReviewResult(r.result),
@@ -232,49 +230,65 @@ export const getErrorBookItemById = cache(async (
}) })
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 查询:错题本统计 // 查询:错题本统计SQL 聚合优化)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const getErrorBookStats = cache(async (studentId: string): Promise<ErrorBookStats> => { export const getErrorBookStats = cache(async (studentId: string): Promise<ErrorBookStats> => {
const now = new Date() const now = new Date()
const rows = await db // 使用 SQL GROUP BY 聚合状态计数
const statusCounts = await db
.select({ .select({
status: errorBookItems.status, status: errorBookItems.status,
nextReviewAt: errorBookItems.nextReviewAt, count: count(),
}) })
.from(errorBookItems) .from(errorBookItems)
.where(eq(errorBookItems.studentId, studentId)) .where(eq(errorBookItems.studentId, studentId))
.groupBy(errorBookItems.status)
const total = rows.length let totalCount = 0
let newCount = 0 let newCount = 0
let learningCount = 0 let learningCount = 0
let masteredCount = 0 let masteredCount = 0
let archivedCount = 0 let archivedCount = 0
let dueReviewCount = 0
for (const row of rows) { for (const row of statusCounts) {
const status = toStatus(row.status) const status = toStatus(row.status)
if (status === "new") newCount++ totalCount += row.count
else if (status === "learning") learningCount++ if (status === "new") newCount = row.count
else if (status === "mastered") masteredCount++ else if (status === "learning") learningCount = row.count
else if (status === "archived") archivedCount++ else if (status === "mastered") masteredCount = row.count
else if (status === "archived") archivedCount = row.count
if (status !== "mastered" && status !== "archived") {
if (!row.nextReviewAt || row.nextReviewAt <= now) {
dueReviewCount++
}
}
} }
// 待复习数单独查询status NOT IN mastered/archived AND due
const dueConditions: SQL[] = [
eq(errorBookItems.studentId, studentId),
not(inArray(errorBookItems.status, ["mastered", "archived"])),
]
const dueCondition = or(
isNull(errorBookItems.nextReviewAt),
lte(errorBookItems.nextReviewAt, now)
)
if (dueCondition) {
dueConditions.push(dueCondition)
}
const dueReviewResult = await db
.select({ value: count() })
.from(errorBookItems)
.where(and(...dueConditions))
const dueReviewCount = Number(dueReviewResult[0]?.value ?? 0)
return { return {
totalCount: total, totalCount,
newCount, newCount,
learningCount, learningCount,
masteredCount, masteredCount,
archivedCount, archivedCount,
dueReviewCount, dueReviewCount,
masteredRate: total > 0 ? masteredCount / total : 0, masteredRate: totalCount > 0 ? masteredCount / totalCount : 0,
} }
}) })
@@ -448,11 +462,11 @@ export {
} from "./data-access-collection" } from "./data-access-collection"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 跨模块查询接口:供教师/家长视图使用 // 共享工具函数:供 data-access-analytics.ts 使用
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** 构建学生错题查询的 where 条件(支持按学科过滤) */ /** 构建学生错题查询的 where 条件(支持按学科过滤) */
function buildStudentErrorWhereClause( export function buildStudentErrorWhereClause(
studentIds: string[], studentIds: string[],
subjectId?: string | null subjectId?: string | null
): SQL | undefined { ): SQL | undefined {
@@ -462,568 +476,3 @@ function buildStudentErrorWhereClause(
} }
return and(...conditions) return and(...conditions)
} }
/** 查询多个学生的错题统计(教师视图,支持按学科过滤) */
export async function getStudentErrorBookSummaries(
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,
status: errorBookItems.status,
nextReviewAt: errorBookItems.nextReviewAt,
updatedAt: errorBookItems.updatedAt,
})
.from(errorBookItems)
.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
newCount: number
learningCount: number
masteredCount: number
dueReviewCount: number
lastActivityAt: Date | null
}>()
for (const row of rows) {
const stat = map.get(row.studentId) ?? {
totalCount: 0,
newCount: 0,
learningCount: 0,
masteredCount: 0,
dueReviewCount: 0,
lastActivityAt: null,
}
stat.totalCount++
const status = toStatus(row.status)
if (status === "new") stat.newCount++
else if (status === "learning") stat.learningCount++
else if (status === "mastered") stat.masteredCount++
if (status !== "mastered" && status !== "archived") {
if (!row.nextReviewAt || row.nextReviewAt <= now) {
stat.dueReviewCount++
}
}
if (!stat.lastActivityAt || row.updatedAt > stat.lastActivityAt) {
stat.lastActivityAt = row.updatedAt
}
map.set(row.studentId, stat)
}
return Array.from(map.entries()).map(([studentId, stat]) => {
const classInfo = studentClassMap.get(studentId)
return {
studentId,
...stat,
masteredRate: stat.totalCount > 0 ? stat.masteredCount / stat.totalCount : 0,
classId: classInfo?.classId ?? null,
className: classInfo?.className ?? null,
}
})
}
/** 查询班级内错题最多的题目(教师视图:高频错题,支持按学科过滤) */
export async function getTopWrongQuestionsByStudentIds(
studentIds: string[],
limit = 10,
subjectId?: string | null
): Promise<Array<{
questionId: string
questionContent: unknown
questionType: string
errorCount: number
masteredCount: number
}>> {
if (studentIds.length === 0) return []
const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)
const rows = await db
.select({
questionId: errorBookItems.questionId,
status: errorBookItems.status,
content: questions.content,
type: questions.type,
})
.from(errorBookItems)
.innerJoin(questions, eq(questions.id, errorBookItems.questionId))
.where(whereClause)
const map = new Map<string, { questionContent: unknown; questionType: string; errorCount: number; masteredCount: number }>()
for (const row of rows) {
const stat = map.get(row.questionId) ?? {
questionContent: row.content,
questionType: row.type,
errorCount: 0,
masteredCount: 0,
}
stat.errorCount++
if (toStatus(row.status) === "mastered") stat.masteredCount++
map.set(row.questionId, stat)
}
return Array.from(map.entries())
.map(([questionId, stat]) => ({ questionId, ...stat }))
.sort((a, b) => b.errorCount - a.errorCount)
.slice(0, limit)
}
/** 按班级 ID 查询学生 ID 列表(委托给 classes 模块) */
export async function getStudentIdsByClassIdList(classIds: string[]): Promise<string[]> {
return await getStudentIdsByClassIds(classIds)
}
/**
* 查询所有学生用户 ID管理员视图
* 通过 usersToRoles + roles 表关联查询 role === "student" 的用户。
* 此函数封装了 DB 访问,避免 app 层直接查询 DB遵循三层架构
*/
export async function getAllStudentIds(): Promise<string[]> {
const { usersToRoles, roles } = await import("@/shared/db/schema")
const studentRole = await db
.select({ id: roles.id })
.from(roles)
.where(eq(roles.name, "student"))
.limit(1)
if (studentRole.length === 0) return []
const userRoleRows = await db
.select({ userId: usersToRoles.userId })
.from(usersToRoles)
.where(eq(usersToRoles.roleId, studentRole[0].id))
return userRoleRows.map((r) => r.userId)
}
// ---------------------------------------------------------------------------
// 统计:知识点薄弱度 & 学科分布 & 章节维度(教师/管理员视图)
// ---------------------------------------------------------------------------
/** 查询多个学生的知识点薄弱度统计(支持按学科过滤,关联章节信息) */
export async function getKnowledgePointWeakness(
studentIds: string[],
limit = 10,
subjectId?: string | null
): Promise<KnowledgePointWeakness[]> {
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)
// 展开知识点并统计
const kpMap = 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 = kpMap.get(kpId) ?? { errorCount: 0, masteredCount: 0 }
stat.errorCount++
if (toStatus(row.status) === "mastered") stat.masteredCount++
kpMap.set(kpId, stat)
}
}
if (kpMap.size === 0) return []
// 查询知识点名称及所属章节
const kpIds = Array.from(kpMap.keys())
const kpRows = await db
.select({
id: knowledgePoints.id,
name: knowledgePoints.name,
chapterId: knowledgePoints.chapterId,
})
.from(knowledgePoints)
.where(inArray(knowledgePoints.id, kpIds))
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]) => {
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
return a.masteryRate - b.masteryRate
})
.slice(0, limit)
}
/** 查询多个学生的学科错题分布 */
export async function getSubjectErrorDistribution(
studentIds: string[]
): Promise<Array<{
subjectId: string | null
subjectName: string
errorCount: number
masteredCount: number
masteryRate: number
}>> {
if (studentIds.length === 0) return []
const rows = await db
.select({
subjectId: errorBookItems.subjectId,
status: errorBookItems.status,
})
.from(errorBookItems)
.where(inArray(errorBookItems.studentId, studentIds))
const subjectMap = new Map<string | null, { errorCount: number; masteredCount: number }>()
for (const row of rows) {
const key = row.subjectId
const stat = subjectMap.get(key) ?? { errorCount: 0, masteredCount: 0 }
stat.errorCount++
if (toStatus(row.status) === "mastered") stat.masteredCount++
subjectMap.set(key, stat)
}
// 查询学科名称
const subjectIds = Array.from(subjectMap.keys()).filter((k): k is string => k !== null)
let subjectNameMap = new Map<string, string>()
if (subjectIds.length > 0) {
const subjectRows = await db
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.where(inArray(subjects.id, subjectIds))
subjectNameMap = new Map(subjectRows.map((s) => [s.id, s.name]))
}
return Array.from(subjectMap.entries()).map(([sid, stat]) => ({
subjectId: sid,
subjectName: sid ? (subjectNameMap.get(sid) ?? "未知学科") : "未分类",
errorCount: stat.errorCount,
masteredCount: stat.masteredCount,
masteryRate: stat.errorCount > 0 ? stat.masteredCount / stat.errorCount : 0,
}))
}
/**
* 查询章节薄弱度统计(哪些课在错)。
* 通过 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()
const rows = await db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, studentIds))
return new Map(rows.map((r) => [r.id, r.name ?? "未知"]))
}