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:
@@ -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 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
* 关联: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-pending:MSW 兜底
|
||||
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 };
|
||||
Reference in New Issue
Block a user