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

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

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

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

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

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

View File

@@ -0,0 +1,99 @@
"use client"
import { useRouter, useSearchParams } from "next/navigation"
import { cn } from "@/shared/lib/utils"
import { Badge } from "@/shared/components/ui/badge"
import type { SubjectErrorOverview } from "@/modules/error-book/types"
interface SubjectTabsProps {
subjects: SubjectErrorOverview[]
/** 当前选中的学科 IDnull 表示全部) */
currentSubjectId: string | null
/** URL 参数名(默认 "subject" */
paramName?: string
}
/**
* 学科切换 Tab教师/管理员视图)
* 显示每个学科的错题数概览,点击切换
*/
export function SubjectTabs({
subjects,
currentSubjectId,
paramName = "subject",
}: SubjectTabsProps) {
const router = useRouter()
const searchParams = useSearchParams()
const handleSelect = (subjectId: string | null) => {
const params = new URLSearchParams(searchParams.toString())
if (subjectId === null) {
params.delete(paramName)
} else {
params.set(paramName, subjectId)
}
router.push(`?${params.toString()}`, { scroll: false })
}
if (subjects.length === 0) return null
const totalErrors = subjects.reduce((sum, s) => sum + s.totalErrorItems, 0)
return (
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => handleSelect(null)}
className={cn(
"inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors",
currentSubjectId === null
? "border-primary bg-primary text-primary-foreground"
: "bg-card hover:bg-muted"
)}
>
<span className="font-medium"></span>
<Badge
variant={currentSubjectId === null ? "secondary" : "outline"}
className="text-xs"
>
{totalErrors}
</Badge>
</button>
{subjects.map((subject) => {
const isActive = currentSubjectId === subject.subjectId
return (
<button
key={subject.subjectId}
type="button"
onClick={() => handleSelect(subject.subjectId)}
className={cn(
"inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors",
isActive
? "border-primary bg-primary text-primary-foreground"
: "bg-card hover:bg-muted"
)}
>
<span className="font-medium">{subject.subjectName}</span>
<Badge
variant={isActive ? "secondary" : "outline"}
className="text-xs"
>
{subject.totalErrorItems}
</Badge>
{subject.dueReviewCount > 0 ? (
<span
className={cn(
"text-xs",
isActive ? "text-primary-foreground/80" : "text-rose-600"
)}
>
{subject.dueReviewCount}
</span>
) : null}
</button>
)
})}
</div>
)
}