"use client"; /** * 班级对比卡(ARCHITECTURE.md §9.4 / §11.3 DoD 三态) * * 数据契约: * - classComparison():❌ schema 无 → MSW 兜底(@contract-pending) * * 三态规范(§11.3 DoD): * - loading:骨架屏 * - error:局部降级(EmptyState + 错误文案) * - data:横向柱状图 + 排名表格 * * 关联:ARCHITECTURE.md §5.3 / §5.4 / §9.4 / §11.3 / §11.4 */ import { useEffect, useMemo } from "react"; import { useTranslations } from "next-intl"; import { GitCompare, AlertCircle } from "lucide-react"; import { useClassComparison } from "@/lib/api"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/shared/components/ui/card"; import { Skeleton } from "@/shared/components/ui/skeleton"; import { EmptyState } from "@/shared/components/ui/empty-state"; import { SimpleBarChart, type BarSeries } from "@/shared/components/charts"; import { notify } from "@/shared/lib/notify"; import { cn } from "@/shared/lib/utils"; /** 班级对比项(与 GET_CLASS_COMPARISON_DOC 返回结构一致) */ interface ClassComparisonItem { className: string; attendanceRate: number; totalStudents: number; presentStudents: number; } /** 柱状图数据项 */ interface BarChartData { name: string; rate: number; [key: string]: string | number; } /** 出勤率阈值(0-1),用于着色分级 */ const RATE_TIER_HIGH = 0.95; const RATE_TIER_MID = 0.9; /** * 班级对比卡:展示多个班级的出勤率对比。 * * 内部调用 useClassComparison hook,三态:loading / error / data。 * 数据态展示横向柱状图 + 排名表格 + 数据更新时间。 */ export function ClassComparisonCard(): React.ReactElement { const t = useTranslations("admin.attendance.classComparison"); const { data, loading, error } = useClassComparison(); useEffect(() => { if (error) { notify.error(t("errorNotification")); } }, [error, t]); const sorted = useMemo(() => { if (!data) return []; return [...data].sort((a, b) => b.attendanceRate - a.attendanceRate); }, [data]); const chartData = useMemo(() => { return sorted.map((item) => ({ name: item.className, rate: Math.round(item.attendanceRate * 100), })); }, [sorted]); const bars: BarSeries[] = [ { dataKey: "rate", name: t("seriesRate"), color: "hsl(var(--chart-1))", }, ]; const updatedAt = useMemo(() => { return new Date().toLocaleString(); }, [data]); if (loading) { return ; } if (error || !data || data.length === 0) { return ( {t("title")} {t("description")} ); } return ( {t("title")} {t("description")} {t("updatedAt", { time: updatedAt })} `${v}%`} heightClassName="h-[280px]" />
{sorted.map((item, idx) => ( ))}
{t("colRank")} {t("colClass")} {t("colTotal")} {t("colPresent")} {t("colRate")} {t("colBadge")}
{idx + 1} {item.className} {item.totalStudents} {item.presentStudents} {formatPercent(item.attendanceRate)} {renderRateBadge(item.attendanceRate, t)}
); } /** 班级对比卡骨架屏。 */ function ClassComparisonSkeleton(): React.ReactElement { return ( ); } /** * 将 0-1 的出勤率格式化为百分比字符串。 * 输入无效返回 "--"。 */ function formatPercent(rate: number | null | undefined): string { if (rate == null || !Number.isFinite(rate) || rate < 0 || rate > 1) { return "--"; } return `${(rate * 100).toFixed(1)}%`; } /** * 根据出勤率(0-1)返回 Tailwind 文本语义类名。 * - >= 0.95 → emerald(优秀) * - >= 0.9 → amber(一般) * - 其他 → destructive(低出勤率) */ function rateToColorClass(rate: number | null | undefined): string { if (rate == null || !Number.isFinite(rate) || rate < 0 || rate > 1) { return "text-muted-foreground"; } if (rate >= RATE_TIER_HIGH) return "text-emerald-600 dark:text-emerald-400"; if (rate >= RATE_TIER_MID) return "text-amber-600 dark:text-amber-400"; return "text-destructive"; } /** * 根据出勤率渲染排名徽章。 */ function renderRateBadge( rate: number, t: ReturnType, ): React.ReactNode { let label: string; let className: string; if (rate >= RATE_TIER_HIGH) { label = t("badgeHigh"); className = "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/30"; } else if (rate >= RATE_TIER_MID) { label = t("badgeMid"); className = "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30"; } else { label = t("badgeLow"); className = "bg-destructive/10 text-destructive border-destructive/30"; } return ( {label} ); }