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 已更新
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user