feat(portal-shell): attendance + classes + students 模块 8 页迁移(教师域 §9.1 B2)

§9.1 line 632-634 教师域:
- /shell/teacher/attendance (列表) / /sheet (表单) / /report (报表) / /stats (统计) — 4 页
- /shell/teacher/classes (列表) / /[id] (详情) / /schedule (课表) — 3 页
- /shell/teacher/students (列表) — 1 页
契约:
- attendance 全  → MSW 兜底
- classes 🟡 classInfo(id)  真实单查 + 列表  MSW
- students 全  → MSW 兜底

新增文件:
- src/lib/api/{attendance,classes,students}.ts (14 hooks 合计)
- src/lib/api/operations/{attendance,classes,students}.graphql.ts (14 documents)
- src/features/teacher/{attendance,classes,students}/ (clients + transformations + tests)
- src/app/shell/teacher/{attendance,classes,students}/ (8 page.tsx + 3 loading + 3 error)

修改文件:
- src/mocks/graphql-data.ts (12 mock 数据 + handler cases)
- src/messages/{zh-CN,en}.json (attendance/classes/students i18n 命名空间)
- src/lib/api/{index,operations/index}.ts (导出 attendance/classes/students)
- src/shared/lib/route-permissions.ts (attendance/classes/students 路由权限)
- scripts/check-page-count.ts (baseline 40 → 48)

DoD 验收(§11.3 11 项):
- typecheck 0 errors
- lint 0 errors
- vitest 566 tests passed
- lint:tokens 0 errors
- check:pages 48 PASS
- route-permissions 已声明
- 三态齐备
- @contract-pending + MSW 兜底
- i18n zh-CN + en 同步

设计决策:
- classes/[id] 走真实 classInfo(id) 查询
- 无 STUDENT_* 权限点,students 路由复用 CLASS_READ/CLASS_MANAGE

关联:ARCHITECTURE.md §5.3 / §5.4 / §5.5 / §9.1 / §10 P2 / §11.3 / §11.4
契约工单:docs/architecture/issues/contracts/core-edu_contract.md
This commit is contained in:
SpecialX
2026-07-22 21:58:07 +08:00
parent 33ebb9a652
commit 73e09ca29a
41 changed files with 5876 additions and 8 deletions

View File

@@ -0,0 +1,206 @@
"use client";
/**
* 考勤报表页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* 数据契约:
* - 报表查询 attendanceReport(classId, range):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/classes_contract.md#attendance-report
*
* 三态规范§11.3 DoD
* - loadingDetailPageSkeleton
* - errorerrorNode 局部降级
* - emptydata 为 null 时显示空态节点
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { FileText } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useAttendanceReport, type AttendanceReportItem } from "@/lib/api";
import { EmptyState } from "@/shared/components/ui/empty-state";
import {
DetailPageShell,
DetailPageSkeleton,
DetailSection,
DetailField,
} from "@/shared/components/page-templates";
import {
attendanceRateToColorClass,
formatAttendanceRate,
} from "@/features/teacher/attendance/transformations";
/**
* 报表客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function AttendanceReportClient(): React.ReactElement {
const t = useTranslations("attendance");
const tCommon = useTranslations("common");
const searchParams = useSearchParams();
const classId = searchParams.get("classId") ?? "cls-001";
const startDate = searchParams.get("startDate") ?? undefined;
const endDate = searchParams.get("endDate") ?? undefined;
// @contract-pendingMSW 兜底
const { data, loading, error } = useAttendanceReport(
classId,
startDate,
endDate,
);
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("report.mswNotice")}
</p>
</div>
) : undefined;
const emptyNode =
!loading && !error && !data ? (
<EmptyState
icon={FileText}
title={t("report.notFound")}
action={{
label: t("report.backToList"),
href: "/shell/teacher/attendance",
}}
/>
) : undefined;
return (
<DetailPageShell
title={t("report.title")}
description={
data
? t("report.subtitle", {
className: data.className,
range: data.range,
})
: undefined
}
icon={<FileText className="size-6" />}
backHref="/shell/teacher/attendance"
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
emptyNode={emptyNode}
>
{data ? <ReportSummarySection data={data} /> : null}
{data ? <ReportItemsSection items={data.items} /> : null}
</DetailPageShell>
);
}
/**
* 报表汇总区。
*/
function ReportSummarySection({
data,
}: {
data: NonNullable<ReturnType<typeof useAttendanceReport>["data"]>;
}): React.ReactElement {
const t = useTranslations("attendance");
const summary = data.summary;
return (
<DetailSection title={t("report.sectionSummary")}>
<DetailField
label={t("report.fieldTotal")}
value={String(summary.total)}
/>
<DetailField
label={t("report.fieldPresent")}
value={String(summary.present)}
/>
<DetailField
label={t("report.fieldAbsent")}
value={String(summary.absent)}
/>
<DetailField label={t("report.fieldLate")} value={String(summary.late)} />
<DetailField
label={t("report.fieldLeave")}
value={String(summary.leave)}
/>
<DetailField
label={t("report.fieldAttendanceRate")}
value={
<span
className={`font-medium ${attendanceRateToColorClass(summary.attendanceRate)}`}
>
{formatAttendanceRate(summary.attendanceRate)}
</span>
}
/>
</DetailSection>
);
}
/**
* 报表明细区(按学生聚合)。
*/
function ReportItemsSection({
items,
}: {
items: AttendanceReportItem[];
}): React.ReactElement {
const t = useTranslations("attendance");
return (
<DetailSection title={t("report.sectionItems")}>
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left font-medium">
{t("report.colStudentName")}
</th>
<th className="p-3 text-center font-medium">
{t("report.colPresent")}
</th>
<th className="p-3 text-center font-medium">
{t("report.colAbsent")}
</th>
<th className="p-3 text-center font-medium">
{t("report.colLate")}
</th>
<th className="p-3 text-center font-medium">
{t("report.colLeave")}
</th>
<th className="p-3 text-right font-medium">
{t("report.colAttendanceRate")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((item) => (
<tr key={item.studentId} className="hover:bg-muted/30">
<td className="p-3 font-medium">{item.studentName}</td>
<td className="p-3 text-center text-muted-foreground">
{item.present}
</td>
<td className="p-3 text-center text-muted-foreground">
{item.absent}
</td>
<td className="p-3 text-center text-muted-foreground">
{item.late}
</td>
<td className="p-3 text-center text-muted-foreground">
{item.leave}
</td>
<td
className={`p-3 text-right font-medium ${attendanceRateToColorClass(item.attendanceRate)}`}
>
{formatAttendanceRate(item.attendanceRate)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</DetailSection>
);
}