Files
NextEdu/src/modules/error-book/components/chapter-weakness-chart.tsx
SpecialX eee0145274 feat(error-book): update components and analytics data-access
- Update all error-book components (filters, cards, charts, dialogs)

- Update data-access-analytics.ts

- Update student-error-book-list-client.tsx
2026-07-04 10:22:36 +08:00

190 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts"
import { useTranslations } from "next-intl"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/shared/components/ui/chart"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { cn } from "@/shared/lib/utils"
import type { ChapterWeakness } from "@/modules/error-book/types"
interface ChapterWeaknessChartProps {
data: ChapterWeakness[]
className?: string
}
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) {
const t = useTranslations("errorBook")
if (data.length === 0) return null
const chartData = data.map((d) => ({
name: d.chapterTitle,
errorCount: d.errorCount,
masteredCount: d.masteredCount,
masteryRate: Number((d.masteryRate * 100).toFixed(0)),
knowledgePointCount: d.knowledgePointCount,
topKps: d.topKnowledgePoints,
}))
const chartConfig: ChartConfig = {
errorCount: {
label: t("chapterChart.errorCount"),
color: "var(--color-chart-2)",
},
}
return (
<Card className={cn("overflow-hidden", className)}>
<CardHeader>
<CardTitle className="text-base">{t("chapterChart.title")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<ChartContainer
config={chartConfig}
className="h-[300px] w-full"
aria-label={t("chapterChart.title")}
>
<BarChart
data={chartData}
layout="vertical"
margin={{ left: 8, right: 16, top: 8, bottom: 8 }}
>
<CartesianGrid horizontal={false} strokeDasharray="4 4" strokeOpacity={0.4} />
<XAxis type="number" tickLine={false} axisLine={false} />
<YAxis
type="number"
dataKey="name"
tickLine={false}
axisLine={false}
width={120}
tickFormatter={(value: string) =>
value.length > 10 ? `${value.slice(0, 10)}...` : value
}
/>
<ChartTooltip
content={
<ChartTooltipContent
className="w-[280px]"
formatter={(payload: unknown) => {
if (!isChapterChartPayload(payload)) return null
return (
<div className="space-y-1.5">
<div className="font-medium">{payload.name}</div>
<div className="text-muted-foreground">
{t("chapterChart.errorCount")}
<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 className="text-muted-foreground">
{t("chapterChart.masteryRateLabel")}
<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>
{payload.topKps && payload.topKps.length > 0 ? (
<div className="border-t pt-1.5 mt-1.5">
<div className="text-xs text-muted-foreground mb-1">
{t("chapterChart.weakKpsLabel")}
</div>
{payload.topKps.map((kp) => (
<div key={kp.knowledgePointName} className="flex justify-between text-xs">
<span>{kp.knowledgePointName}</span>
<span className="font-medium text-rose-600">{kp.errorCount}</span>
</div>
))}
</div>
) : null}
</div>
)
}}
/>
}
/>
<Bar dataKey="errorCount" radius={[0, 4, 4, 0]}>
{chartData.map((entry, idx) => (
<Cell
key={idx}
fill={entry.masteryRate < 30 ? "var(--color-rose-500)" : entry.masteryRate < 60 ? "var(--color-amber-500)" : "var(--color-emerald-500)"}
/>
))}
</Bar>
</BarChart>
</ChartContainer>
{/* 章节详情列表 */}
<div className="space-y-2">
{data.slice(0, 5).map((chapter) => (
<div
key={chapter.chapterId}
className="flex items-start justify-between gap-3 rounded-lg border p-3"
>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium text-sm">{chapter.chapterTitle}</span>
<Badge variant="outline" className="shrink-0 text-xs">
{t("chapterChart.knowledgePointBadge", { count: chapter.knowledgePointCount })}
</Badge>
</div>
{chapter.topKnowledgePoints.length > 0 ? (
<div className="flex flex-wrap gap-1">
{chapter.topKnowledgePoints.map((kp) => (
<Badge key={kp.knowledgePointId} variant="secondary" className="text-xs">
{kp.knowledgePointName} · {kp.errorCount}
</Badge>
))}
</div>
) : null}
</div>
<div className="flex shrink-0 flex-col items-end gap-0.5 text-xs">
<span className="font-bold text-rose-600">{chapter.errorCount}</span>
<span className="text-muted-foreground">
{t("chapterChart.masteryRateLabel")} {Math.round(chapter.masteryRate * 100)}%
</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)
}