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:
SpecialX
2026-07-30 17:49:49 +08:00
parent 071542b757
commit f991bf0446
98 changed files with 25956 additions and 2140 deletions

View File

@@ -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>
);
}