Files
NextEdu/src/modules/error-book/components/knowledge-point-weakness-chart.tsx
SpecialX 9ce8d6d3fd refactor(data-access): 补全剩余 37 个 data-access 文件迁移至 cacheFn
迁移范围:lesson-preparation 11 文件、attendance 3 文件、settings 4 文件、classes-invitations、跨模块接口 4 文件、adaptive-practice/ai/onboarding/invitation-codes/search/standards/scheduling/leave-requests/audit。修复 3 个 cacheFn 块位置错误。同步架构文档迁移范围至 83 文件 250+ 函数。
2026-07-06 15:12:30 +08:00

203 lines
6.4 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 { useCallback } from "react"
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 { KnowledgePointWeakness } from "@/modules/error-book/types"
/** recharts 内联对象常量:避免每次渲染创建新对象触发 re-render */
const CHART_MARGIN = { left: 8, right: 16, top: 8, bottom: 8 }
const GRID_PROPS = { horizontal: false, strokeDasharray: "4 4", strokeOpacity: 0.4 }
const X_AXIS_PROPS = { type: "number" as const, tickLine: false, axisLine: false }
const Y_AXIS_BASE_PROPS = {
type: "number" as const,
dataKey: "name",
tickLine: false,
axisLine: false,
width: 100,
}
const BAR_RADIUS: [number, number, number, number] = [0, 4, 4, 0]
function truncateTick(value: string): string {
return value.length > 8 ? `${value.slice(0, 8)}...` : value
}
interface KnowledgePointWeaknessChartProps {
data: KnowledgePointWeakness[]
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"
)
}
/**
* 知识点薄弱度图表
* 横向柱状图,按错题数降序
* 颜色按掌握率区分(红/黄/绿)
* 显示所属章节
*/
export function KnowledgePointWeaknessChart({
data,
className,
}: KnowledgePointWeaknessChartProps) {
const t = useTranslations("errorBook")
const renderTooltip = useCallback(
(payload: unknown) => {
if (!isKpChartPayload(payload)) return null
return (
<div className="space-y-1.5">
<div className="font-medium">{payload.name}</div>
<div className="text-xs text-muted-foreground">
{t("weaknessChart.chapterLabel", { title: payload.chapterTitle })}
</div>
<div className="text-muted-foreground">
{t("weaknessChart.errorCount")}
<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 className="text-muted-foreground">
{t("weaknessChart.masteryRateLabel")}
<span className="font-medium text-foreground">{payload.masteryRate}%</span>
</div>
</div>
)
},
[t]
)
if (data.length === 0) return null
const chartData = data.map((d) => ({
name: d.knowledgePointName,
errorCount: d.errorCount,
masteredCount: d.masteredCount,
masteryRate: Number((d.masteryRate * 100).toFixed(0)),
chapterTitle: d.chapterTitle ?? t("weaknessChart.unclassified"),
}))
const chartConfig: ChartConfig = {
errorCount: {
label: t("weaknessChart.errorCount"),
color: "var(--color-chart-1)",
},
}
return (
<Card className={cn("overflow-hidden", className)}>
<CardHeader>
<CardTitle className="text-base">
{t("weaknessChart.title", { count: data.length })}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<ChartContainer
config={chartConfig}
className="h-[320px] w-full"
aria-label={t("weaknessChart.title", { count: data.length })}
>
<BarChart
data={chartData}
layout="vertical"
margin={CHART_MARGIN}
>
<CartesianGrid {...GRID_PROPS} />
<XAxis {...X_AXIS_PROPS} />
<YAxis
{...Y_AXIS_BASE_PROPS}
tickFormatter={truncateTick}
/>
<ChartTooltip
content={
<ChartTooltipContent
className="w-60"
formatter={renderTooltip}
/>
}
/>
<Bar dataKey="errorCount" radius={BAR_RADIUS}>
{chartData.map((entry, idx) => (
<Cell
key={idx}
fill={
entry.masteryRate < 30
? "var(--color-rose-500)"
: entry.masteryRate < 60
? "var(--color-amber-500)"
: "var(--color-emerald-500)"
}
/>
))}
</Bar>
</BarChart>
</ChartContainer>
{/* 知识点详情列表(带章节归属) */}
<div className="space-y-2">
{data.slice(0, 8).map((kp) => (
<div
key={kp.knowledgePointId}
className="flex items-center justify-between gap-3 rounded-lg border p-2.5"
>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{kp.knowledgePointName}</div>
{kp.chapterTitle ? (
<div className="truncate text-xs text-muted-foreground">
{kp.chapterTitle}
</div>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
<Badge
variant={
kp.masteryRate < 0.3
? "destructive"
: kp.masteryRate < 0.6
? "secondary"
: "outline"
}
className="text-xs"
>
{Math.round(kp.masteryRate * 100)}%
</Badge>
<span className="text-sm font-bold text-rose-600">{kp.errorCount}</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)
}