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:
@@ -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):
|
||||
* - loading:DetailPageSkeleton
|
||||
* - error:errorNode 局部降级
|
||||
* - empty:data 为 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-pending:MSW 兜底
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user