迁移范围: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+ 函数。
203 lines
6.4 KiB
TypeScript
203 lines
6.4 KiB
TypeScript
"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>
|
||
)
|
||
}
|