Files
Edu/apps/portal-shell/src/features/admin/attendance/class-comparison-card.tsx
SpecialX f991bf0446 feat(portal-shell): 管理域全模块功能补齐与差异修复
按 ARCHITECTURE.md 与 admin-NeedTodo.md 要求补齐所有管理页面缺失功能:
- users/roles/permissions:权限矩阵搜索/折叠、zod 校验、value 字段
- audit-logs:行内详情对话框、分页页码、ChartCardShell
- school:CRUD 对话框、GradeOverviewCards、academic-year 侧栏
- announcements/invitation-codes/ai-settings:发布按钮、分页、zod 校验
- course-plans/elective:Select 导入、undefined 处理
- error-book/scheduling/questions/lesson-plans/attendance:统计卡片

验证:typecheck 0 错误、arch:scan 已更新
2026-07-30 17:49:49 +08:00

265 lines
7.8 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";
/**
* 班级对比卡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<ClassComparisonItem[]>(() => {
if (!data) return [];
return [...data].sort((a, b) => b.attendanceRate - a.attendanceRate);
}, [data]);
const chartData = useMemo<BarChartData[]>(() => {
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 <ClassComparisonSkeleton />;
}
if (error || !data || data.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<GitCompare className="size-5" />
{t("title")}
</CardTitle>
<CardDescription>{t("description")}</CardDescription>
</CardHeader>
<CardContent>
<EmptyState
icon={error ? AlertCircle : GitCompare}
title={error ? t("errorTitle") : t("emptyTitle")}
description={error ? t("errorDescription") : t("emptyDescription")}
className="min-h-[300px]"
/>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<GitCompare className="size-5" />
{t("title")}
</CardTitle>
<CardDescription>
{t("description")}
<span className="ml-2 text-xs text-muted-foreground">
{t("updatedAt", { time: updatedAt })}
</span>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<SimpleBarChart
data={chartData}
bars={bars}
xKey="name"
yDomain={[0, 100]}
yTickFormatter={(v) => `${v}%`}
heightClassName="h-[280px]"
/>
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="w-12 p-2 text-left font-medium">
{t("colRank")}
</th>
<th className="p-2 text-left font-medium">{t("colClass")}</th>
<th className="p-2 text-right font-medium">{t("colTotal")}</th>
<th className="p-2 text-right font-medium">
{t("colPresent")}
</th>
<th className="p-2 text-right font-medium">{t("colRate")}</th>
<th className="p-2 text-center font-medium">{t("colBadge")}</th>
</tr>
</thead>
<tbody className="divide-y">
{sorted.map((item, idx) => (
<tr key={item.className} className="hover:bg-muted/30">
<td className="p-2 font-medium tabular-nums">{idx + 1}</td>
<td className="p-2 font-medium">{item.className}</td>
<td className="p-2 text-right tabular-nums">
{item.totalStudents}
</td>
<td className="p-2 text-right tabular-nums">
{item.presentStudents}
</td>
<td
className={cn(
"p-2 text-right font-mono text-xs tabular-nums",
rateToColorClass(item.attendanceRate),
)}
>
{formatPercent(item.attendanceRate)}
</td>
<td className="p-2 text-center">
{renderRateBadge(item.attendanceRate, t)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
/** 班级对比卡骨架屏。 */
function ClassComparisonSkeleton(): React.ReactElement {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
<Skeleton className="mt-2 h-4 w-64" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-[280px] w-full" />
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>
);
}
/**
* 将 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<typeof useTranslations>,
): 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 (
<span
className={cn(
"inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold",
className,
)}
>
{label}
</span>
);
}