按 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 已更新
265 lines
7.8 KiB
TypeScript
265 lines
7.8 KiB
TypeScript
"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>
|
||
);
|
||
}
|