Files
NextEdu/src/modules/error-book/components/class-error-bar-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

165 lines
5.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 { 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 { cn } from "@/shared/lib/utils"
import type { ClassErrorOverview } from "@/modules/error-book/types"
/** recharts 内联对象常量:避免每次渲染创建新对象触发 re-render */
const CHART_MARGIN = { left: 8, right: 8, top: 8, bottom: 8 }
const GRID_PROPS = { vertical: false, strokeDasharray: "4 4", strokeOpacity: 0.4 }
const X_AXIS_BASE_PROPS = {
dataKey: "name",
tickLine: false,
axisLine: false,
tickMargin: 8,
}
const Y_AXIS_PROPS = { tickLine: false, axisLine: false, width: 36 }
const BAR_RADIUS: [number, number, number, number] = [4, 4, 0, 0]
function truncateTick(value: string): string {
return value.length > 8 ? `${value.slice(0, 8)}...` : value
}
interface ClassErrorBarChartProps {
data: ClassErrorOverview[]
className?: string
}
interface ClassBarChartPayload {
name: string
totalErrorItems: number
studentCount: number
averageErrorPerStudent: number
averageMasteryRate: number
dueReviewCount: number
}
function isClassBarChartPayload(v: unknown): v is ClassBarChartPayload {
if (typeof v !== "object" || v === null) return false
// 从 unknown 转换:类型守卫内需要属性访问来校验字段类型
const obj = v as Record<string, unknown>
return (
typeof obj.name === "string" &&
typeof obj.totalErrorItems === "number" &&
typeof obj.studentCount === "number" &&
typeof obj.averageErrorPerStudent === "number" &&
typeof obj.averageMasteryRate === "number" &&
typeof obj.dueReviewCount === "number"
)
}
const CHART_COLORS = [
"var(--color-chart-1)",
"var(--color-chart-2)",
"var(--color-chart-3)",
"var(--color-chart-4)",
"var(--color-chart-5)",
]
/**
* 班级错题数对比柱状图(教师视图)
* 横轴:班级名称,纵轴:错题总数
* 颜色按班级区分tooltip 显示学生数/人均/掌握率
*/
export function ClassErrorBarChart({ data, className }: ClassErrorBarChartProps) {
const t = useTranslations("errorBook")
const renderTooltip = useCallback(
(payload: unknown) => {
if (!isClassBarChartPayload(payload)) return null
return (
<div className="space-y-1.5">
<div className="font-medium">{payload.name}</div>
<div className="text-muted-foreground">
{t("classErrorBar.errorCount")}
<span className="font-medium text-foreground">{payload.totalErrorItems}</span>
</div>
<div className="text-muted-foreground">
{t("classErrorBar.studentCount")}
<span className="font-medium text-foreground">{payload.studentCount}</span>
</div>
<div className="text-muted-foreground">
{t("classErrorBar.avgPerStudent")}
<span className="font-medium text-foreground">{payload.averageErrorPerStudent}</span>
</div>
<div className="text-muted-foreground">
{t("classErrorBar.avgMastery")}
<span className="font-medium text-foreground">{payload.averageMasteryRate}%</span>
</div>
<div className="text-muted-foreground">
{t("classErrorBar.dueReview")}
<span className="font-medium text-rose-600">{payload.dueReviewCount}</span>
</div>
</div>
)
},
[t]
)
if (data.length === 0) return null
const chartData = data.map((d) => ({
name: d.className,
totalErrorItems: d.totalErrorItems,
studentCount: d.studentCount,
averageErrorPerStudent: Number(d.averageErrorPerStudent.toFixed(1)),
averageMasteryRate: Number((d.averageMasteryRate * 100).toFixed(0)),
dueReviewCount: d.dueReviewCount,
}))
const chartConfig: ChartConfig = {
totalErrorItems: {
label: t("classErrorBar.errorCount"),
color: "var(--color-chart-1)",
},
}
return (
<Card className={cn("overflow-hidden", className)}>
<CardHeader>
<CardTitle className="text-base">{t("classErrorBar.title")}</CardTitle>
</CardHeader>
<CardContent>
{/* arbitrary-value: chart canvas fixed size */}
<ChartContainer
config={chartConfig}
className="h-[280px] w-full"
aria-label={t("classErrorBar.title")}
>
<BarChart data={chartData} margin={CHART_MARGIN}>
<CartesianGrid {...GRID_PROPS} />
<XAxis
{...X_AXIS_BASE_PROPS}
tickFormatter={truncateTick}
/>
<YAxis {...Y_AXIS_PROPS} />
<ChartTooltip
content={
<ChartTooltipContent
className="w-[220px]"
formatter={renderTooltip}
/>
}
/>
<Bar dataKey="totalErrorItems" radius={BAR_RADIUS}>
{chartData.map((_, idx) => (
<Cell key={idx} fill={CHART_COLORS[idx % CHART_COLORS.length]} />
))}
</Bar>
</BarChart>
</ChartContainer>
</CardContent>
</Card>
)
}