- Update all error-book components (filters, cards, charts, dialogs) - Update data-access-analytics.ts - Update student-error-book-list-client.tsx
190 lines
7.3 KiB
TypeScript
190 lines
7.3 KiB
TypeScript
"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>
|
||
)
|
||
}
|