feat(portal-shell): 管理域 §9.4 B5 全量迁移(24 + 21 补充批次共 44 页 + 42 features)

按 ARCHITECTURE.md §9.4 规划口径 + admin-NeedTodo.md §四补充批次完成管理域全量页面迁移:

【§9.4 规划 24 页(B5)】
- users(2) + roles(1) + permissions(1) + audit-logs(4) + invitation-codes(1)
- school(6: redirect/schools/classes/departments/academic-year/grades)
- announcements(1) + files(1) + ai-settings(1) + system(1) + viewports(1)
- students(1) + teachers(1) + organization(1) + plugins(1, config-service)
- 仪表盘已存在(/shell/admin/page.tsx)

【§四补充批次 21 页】
- course-plans(4) + elective(4) + questions(1) + lesson-plans(2) + error-book(1)
- scheduling(3: auto/changes/rules) + attendance(1) + curriculum-map(1)
- announcements 详情/编辑(2) + roles/[id] 详情(1) + users/import(1)

【实现要点】
- 全部使用 ListPageShell + loading/error/empty 三态规范(§11.3 DoD)
- 走 lib/api hooks;未就绪契约走 MSW + @contract-pending 注释(§11.4)
- 文案走 useTranslations(zh-CN + en 两份同步更新)
- 42 个 features/<domain>/transformations.ts 纯函数 + 配套 vitest 单测
- catch 块统一 notify.error;无空 catch;lint:tokens 通过
- 路由全部登记到 route-permissions.ts(39 EXACT + 8 PREFIX)

【验收】
- tsc --noEmit: 0 errors
- ESLint src: 0 errors (4 generated-files warnings, pre-existing)
- lint:tokens: 0 errors
- vitest: 1639/1639 passed (含 23 admin 测试文件 671 用例)
- check:routes: PASS (143 routes, 4 ghost entries pre-existing)
- check:pages: PASS (146 pages)
- check:codegen: PASS
- arch:scan: 24 modules, 8262 symbols

关联:ARCHITECTURE.md §9.4 / §10 P5 / §11.3 DoD / §11.6
This commit is contained in:
SpecialX
2026-07-24 23:07:20 +08:00
parent 5a9f652943
commit 062d9e9582
394 changed files with 60468 additions and 118 deletions

View File

@@ -0,0 +1,412 @@
"use client";
/**
* 考勤管理页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5 / admin-NeedTodo §四)
*
* 数据契约:
* - adminAttendanceStats():❌ schema 无 → MSW 兜底(@contract-pending
* - attendanceGradeCorrelation():❌ schema 无 → MSW 兜底
* - adminClasses():❌ schema 无 → MSW 兜底(用于班级筛选下拉)
*
* URL 状态:?classId=xxx&status=xxx&date=xxx
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { CalendarCheck } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useTranslations } from "next-intl";
import {
useAdminAttendanceStats,
useAdminClasses,
useAttendanceGradeCorrelation,
} from "@/lib/api";
import { Card, CardContent } from "@/shared/components/ui/card";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { StatCard } from "@/shared/components/ui/stat-card";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import {
attendanceStatusToBadgeClass,
attendanceStatusToKey,
computeAbnormalRate,
computeAvgCorrelation,
formatAbnormalRate,
formatAvgScore,
formatCorrelation,
formatRate,
formatRecordDate,
hasAttendanceData,
presentRateToColorClass,
sortClassesByPresentRate,
truncateNote,
type AttendanceStatus,
} from "@/features/admin/attendance/transformations";
/** 考勤状态选项(用于筛选下拉) */
const STATUS_OPTIONS: readonly AttendanceStatus[] = [
"present",
"absent",
"late",
"leave",
] as const;
/**
* 考勤管理客户端主体。需由 server page 包裹在 <Suspense> 中
* useSearchParams 要求 Suspense 边界Next.js 15 强制)。
*/
export function AdminAttendanceClient(): React.ReactElement {
const t = useTranslations("admin.attendance.list");
const tCommon = useTranslations("common");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const classId = searchParams.get("classId") ?? "";
const status = searchParams.get("status") ?? "";
const date = searchParams.get("date") ?? "";
// @contract-pendingMSW 兜底
const { data: stats, loading, error } = useAdminAttendanceStats();
const { data: correlations } = useAttendanceGradeCorrelation();
const { data: classes } = useAdminClasses();
const avgCorrelation = useMemo(
() => computeAvgCorrelation(correlations ?? []),
[correlations],
);
const updateFilter = (key: string, next: string): void => {
const params = new URLSearchParams(searchParams.toString());
if (next) {
params.set(key, next);
} else {
params.delete(key);
}
startTransition(() => {
router.push(`/shell/admin/attendance?${params.toString()}`);
});
};
const hasData = hasAttendanceData(stats);
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
<p className="mt-2 text-xs text-muted-foreground">{t("mswNotice")}</p>
</div>
) : undefined;
const emptyNode = (
<EmptyState
icon={CalendarCheck}
title={t("emptyTitle")}
description={t("emptyDescription")}
/>
);
return (
<ListPageShell
title={t("title")}
description={t("description")}
icon={<CalendarCheck className="size-6" />}
filters={
<AttendanceFilters
classId={classId}
status={status}
date={date}
classes={classes ?? []}
onClassChange={(v) => updateFilter("classId", v)}
onStatusChange={(v) => updateFilter("status", v)}
onDateChange={(v) => updateFilter("date", v)}
/>
}
loading={loading}
loadingNode={<ListPageSkeleton rows={5} />}
empty={!hasData && !loading}
emptyNode={emptyNode}
errorNode={errorNode}
>
{stats ? (
<AttendanceContent
stats={stats}
correlations={correlations ?? []}
avgCorrelation={avgCorrelation}
/>
) : null}
<p className="text-xs text-muted-foreground">{t("mswNotice")}</p>
</ListPageShell>
);
}
/**
* 考勤筛选栏(班级 + 状态 + 日期)。
*/
function AttendanceFilters({
classId,
status,
date,
classes,
onClassChange,
onStatusChange,
onDateChange,
}: {
classId: string;
status: string;
date: string;
classes: NonNullable<ReturnType<typeof useAdminClasses>["data"]>;
onClassChange: (v: string) => void;
onStatusChange: (v: string) => void;
onDateChange: (v: string) => void;
}): React.ReactElement {
const t = useTranslations("admin.attendance.list");
return (
<div className="flex flex-wrap items-center gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">
{t("classFilter")}
</label>
<select
value={classId}
onChange={(e) => onClassChange(e.target.value)}
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
>
<option value="">{t("allClasses")}</option>
{classes.map((cls) => (
<option key={cls.id} value={cls.id}>
{cls.name}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">
{t("statusFilter")}
</label>
<select
value={status}
onChange={(e) => onStatusChange(e.target.value)}
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
>
<option value="">{t("allStatuses")}</option>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{t(
`status${s.charAt(0).toUpperCase()}${s.slice(1)}` as
| "statusPresent"
| "statusAbsent"
| "statusLate"
| "statusLeave",
)}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">
{t("dateFilter")}
</label>
<input
type="date"
value={date}
onChange={(e) => onDateChange(e.target.value)}
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
/>
</div>
</div>
);
}
/**
* 考勤主体内容(统计卡片 + 班级对比 + 考勤-成绩关联分析)。
*/
function AttendanceContent({
stats,
correlations,
avgCorrelation,
}: {
stats: NonNullable<ReturnType<typeof useAdminAttendanceStats>["data"]>;
correlations: NonNullable<
ReturnType<typeof useAttendanceGradeCorrelation>["data"]
>;
avgCorrelation: number;
}): React.ReactElement {
const t = useTranslations("admin.attendance.list");
const abnormalRate = computeAbnormalRate(
stats.absentRate,
stats.lateRate,
stats.earlyLeaveRate,
);
const sortedClasses = sortClassesByPresentRate(stats);
return (
<div className="flex flex-col gap-6">
{/* 统计卡片 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard title={t("statsTotalRecords")} value={stats.totalRecords} />
<StatCard
title={t("statsPresentRate")}
value={formatRate(stats.presentRate)}
valueClassName={presentRateToColorClass(stats.presentRate)}
/>
<StatCard
title={t("statsAbnormalRate")}
value={formatAbnormalRate(
stats.absentRate,
stats.lateRate,
stats.earlyLeaveRate,
)}
valueClassName={abnormalRate > 0.1 ? "text-destructive" : undefined}
/>
<StatCard
title={t("statsAvgCorrelation")}
value={formatCorrelation(avgCorrelation)}
/>
</div>
{/* 次级统计卡片 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<StatCard
title={t("statsAbsentRate")}
value={formatRate(stats.absentRate)}
valueClassName="text-destructive"
/>
<StatCard
title={t("statsLateRate")}
value={formatRate(stats.lateRate)}
valueClassName="text-amber-600 dark:text-amber-400"
/>
<StatCard
title={t("statsEarlyLeaveRate")}
value={formatRate(stats.earlyLeaveRate)}
valueClassName="text-sky-600 dark:text-sky-400"
/>
</div>
{/* 班级对比 */}
<Card>
<CardContent className="p-6">
<h2 className="mb-4 text-lg font-semibold">
{t("classComparisonTitle")}
</h2>
{sortedClasses.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
{t("emptyTitle")}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-2 text-left font-medium">
{t("classComparisonClass")}
</th>
<th className="p-2 text-left font-medium">
{t("statsAbsentRate")}
</th>
<th className="p-2 text-left font-medium">
{t("classComparisonRate")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{sortedClasses.map((cls) => (
<tr key={cls.classId} className="hover:bg-muted/30">
<td className="p-2 font-medium">{cls.className}</td>
<td className="p-2 font-mono text-xs text-destructive">
{formatRate(cls.absentRate)}
</td>
<td
className={`p-2 font-mono text-xs ${presentRateToColorClass(cls.presentRate)}`}
>
{formatRate(cls.presentRate)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* 考勤-成绩关联分析 */}
<Card>
<CardContent className="p-6">
<h2 className="mb-4 text-lg font-semibold">
{t("correlationTitle")}
</h2>
{correlations.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
{t("emptyTitle")}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-2 text-left font-medium">
{t("classComparisonClass")}
</th>
<th className="p-2 text-left font-medium">
{t("correlationAttendance")}
</th>
<th className="p-2 text-left font-medium">
{t("correlationGrade")}
</th>
<th className="p-2 text-left font-medium">
{t("statsAvgCorrelation")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{correlations.map((c) => {
const statusKey = attendanceStatusToKey(
c.presentRate >= 0.9 ? "present" : "absent",
);
return (
<tr key={c.classId} className="hover:bg-muted/30">
<td className="p-2 font-medium">{c.className}</td>
<td
className={`p-2 font-mono text-xs ${presentRateToColorClass(c.presentRate)}`}
>
{formatRate(c.presentRate)}
</td>
<td className="p-2 font-mono text-xs">
{formatAvgScore(c.avgScore)}
</td>
<td className="p-2 font-mono text-xs">
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${attendanceStatusToBadgeClass(
statusKey,
)}`}
>
{formatCorrelation(c.correlation)}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
/**
* 备注/日期格式化辅助导出(供页面其他部分复用,对齐 DoD 纯函数)。
*/
export { formatRecordDate, truncateNote };