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,294 @@
/**
* Attendance 数据变换工具单测ARCHITECTURE.md §11.3 DoD
*
* 关联ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
*/
import { describe, expect, it } from "vitest";
import {
ATTENDANCE_STATUS_LABEL,
attendanceRateToBarClass,
attendanceRateToColorClass,
attendanceStatusToBadgeClass,
computeAttendanceRate,
countAttendanceStatus,
formatAttendanceDate,
formatAttendanceDay,
formatAttendanceRate,
formatAttendanceStatus,
formatSchedulePeriod,
formatWeekday,
isAttendanceAbnormal,
isAttendancePresent,
} from "../transformations";
describe("formatAttendanceStatus", () => {
it("maps known statuses to Chinese labels", () => {
expect(formatAttendanceStatus("present")).toBe("出勤");
expect(formatAttendanceStatus("absent")).toBe("缺勤");
expect(formatAttendanceStatus("late")).toBe("迟到");
expect(formatAttendanceStatus("leave")).toBe("请假");
});
it("returns original value for unknown status", () => {
expect(formatAttendanceStatus("unknown")).toBe("unknown");
expect(formatAttendanceStatus("")).toBe("");
});
it("ATTENDANCE_STATUS_LABEL covers 4 standard statuses", () => {
expect(Object.keys(ATTENDANCE_STATUS_LABEL)).toHaveLength(4);
});
});
describe("attendanceStatusToBadgeClass", () => {
it("returns emerald class for present", () => {
expect(attendanceStatusToBadgeClass("present")).toContain("emerald");
});
it("returns destructive class for absent", () => {
expect(attendanceStatusToBadgeClass("absent")).toContain("destructive");
});
it("returns amber class for late", () => {
expect(attendanceStatusToBadgeClass("late")).toContain("amber");
});
it("returns blue class for leave", () => {
expect(attendanceStatusToBadgeClass("leave")).toContain("blue");
});
it("returns muted for unknown status", () => {
expect(attendanceStatusToBadgeClass("unknown")).toBe(
"bg-muted text-muted-foreground",
);
});
});
describe("formatAttendanceDate", () => {
it("formats valid ISO date string with time", () => {
const result = formatAttendanceDate("2026-07-22T10:30:00Z");
expect(result).toContain("2026");
expect(result).toContain("07");
});
it("returns placeholder for null/undefined/empty", () => {
expect(formatAttendanceDate(null)).toBe("--");
expect(formatAttendanceDate(undefined)).toBe("--");
expect(formatAttendanceDate("")).toBe("--");
});
it("returns placeholder for invalid date", () => {
expect(formatAttendanceDate("not-a-date")).toBe("--");
});
});
describe("formatAttendanceDay", () => {
it("formats valid ISO date string as date only", () => {
const result = formatAttendanceDay("2026-07-22T10:30:00Z");
expect(result).toContain("2026");
expect(result).toContain("07");
});
it("returns placeholder for null/undefined/empty", () => {
expect(formatAttendanceDay(null)).toBe("--");
expect(formatAttendanceDay(undefined)).toBe("--");
expect(formatAttendanceDay("")).toBe("--");
});
});
describe("formatAttendanceRate", () => {
it("formats 0~1 float as percentage", () => {
expect(formatAttendanceRate(0)).toBe("0.0%");
expect(formatAttendanceRate(0.85)).toBe("85.0%");
expect(formatAttendanceRate(1)).toBe("100.0%");
});
it("treats values > 1 as already percentage", () => {
expect(formatAttendanceRate(85)).toBe("85.0%");
expect(formatAttendanceRate(100)).toBe("100.0%");
});
it("returns placeholder for null/undefined/non-finite", () => {
expect(formatAttendanceRate(null)).toBe("--");
expect(formatAttendanceRate(undefined)).toBe("--");
expect(formatAttendanceRate(Number.NaN)).toBe("--");
expect(formatAttendanceRate(Number.POSITIVE_INFINITY)).toBe("--");
});
});
describe("attendanceRateToColorClass", () => {
it("returns emerald for >= 0.95", () => {
expect(attendanceRateToColorClass(0.95)).toBe("text-emerald-600");
expect(attendanceRateToColorClass(1)).toBe("text-emerald-600");
});
it("returns primary for >= 0.9 and < 0.95", () => {
expect(attendanceRateToColorClass(0.9)).toBe("text-primary");
expect(attendanceRateToColorClass(0.94)).toBe("text-primary");
});
it("returns amber for >= 0.8 and < 0.9", () => {
expect(attendanceRateToColorClass(0.8)).toBe("text-amber-600");
expect(attendanceRateToColorClass(0.89)).toBe("text-amber-600");
});
it("returns destructive for < 0.8", () => {
expect(attendanceRateToColorClass(0.79)).toBe("text-destructive");
expect(attendanceRateToColorClass(0)).toBe("text-destructive");
});
it("treats values > 1 as percentage and normalizes", () => {
expect(attendanceRateToColorClass(95)).toBe("text-emerald-600");
expect(attendanceRateToColorClass(85)).toBe("text-amber-600");
});
it("returns muted for null/undefined/non-finite", () => {
expect(attendanceRateToColorClass(null)).toBe("text-muted-foreground");
expect(attendanceRateToColorClass(undefined)).toBe("text-muted-foreground");
expect(attendanceRateToColorClass(Number.NaN)).toBe(
"text-muted-foreground",
);
});
});
describe("attendanceRateToBarClass", () => {
it("returns emerald bar for >= 0.95", () => {
expect(attendanceRateToBarClass(0.95)).toBe("bg-emerald-500/70");
expect(attendanceRateToBarClass(1)).toBe("bg-emerald-500/70");
});
it("returns primary bar for >= 0.9 and < 0.95", () => {
expect(attendanceRateToBarClass(0.9)).toBe("bg-primary/70");
expect(attendanceRateToBarClass(0.94)).toBe("bg-primary/70");
});
it("returns amber bar for >= 0.8 and < 0.9", () => {
expect(attendanceRateToBarClass(0.8)).toBe("bg-amber-500/70");
expect(attendanceRateToBarClass(0.89)).toBe("bg-amber-500/70");
});
it("returns destructive bar for < 0.8", () => {
expect(attendanceRateToBarClass(0.79)).toBe("bg-destructive/70");
expect(attendanceRateToBarClass(0)).toBe("bg-destructive/70");
});
it("treats values > 1 as percentage and normalizes", () => {
expect(attendanceRateToBarClass(95)).toBe("bg-emerald-500/70");
expect(attendanceRateToBarClass(85)).toBe("bg-amber-500/70");
});
it("returns muted bar for null/undefined/non-finite", () => {
expect(attendanceRateToBarClass(null)).toBe("bg-muted");
expect(attendanceRateToBarClass(undefined)).toBe("bg-muted");
expect(attendanceRateToBarClass(Number.NaN)).toBe("bg-muted");
});
});
describe("formatWeekday", () => {
it("maps 0-6 to Chinese weekday labels", () => {
expect(formatWeekday(0)).toBe("周日");
expect(formatWeekday(1)).toBe("周一");
expect(formatWeekday(2)).toBe("周二");
expect(formatWeekday(3)).toBe("周三");
expect(formatWeekday(4)).toBe("周四");
expect(formatWeekday(5)).toBe("周五");
expect(formatWeekday(6)).toBe("周六");
});
it("returns placeholder for out-of-range", () => {
expect(formatWeekday(-1)).toBe("--");
expect(formatWeekday(7)).toBe("--");
expect(formatWeekday(3.7)).toBe("周三");
});
});
describe("formatSchedulePeriod", () => {
it("formats integer period as section label", () => {
expect(formatSchedulePeriod(1)).toBe("第 1 节");
expect(formatSchedulePeriod(8)).toBe("第 8 节");
});
it("returns placeholder for non-finite", () => {
expect(formatSchedulePeriod(Number.NaN)).toBe("--");
expect(formatSchedulePeriod(Number.POSITIVE_INFINITY)).toBe("--");
});
});
describe("isAttendancePresent / isAttendanceAbnormal", () => {
it("present is present, not abnormal", () => {
expect(isAttendancePresent("present")).toBe(true);
expect(isAttendanceAbnormal("present")).toBe(false);
});
it("absent is abnormal, not present", () => {
expect(isAttendancePresent("absent")).toBe(false);
expect(isAttendanceAbnormal("absent")).toBe(true);
});
it("late is abnormal", () => {
expect(isAttendanceAbnormal("late")).toBe(true);
});
it("leave is not abnormal, not present", () => {
expect(isAttendancePresent("leave")).toBe(false);
expect(isAttendanceAbnormal("leave")).toBe(false);
});
it("unknown is neither present nor abnormal", () => {
expect(isAttendancePresent("unknown")).toBe(false);
expect(isAttendanceAbnormal("unknown")).toBe(false);
});
});
describe("countAttendanceStatus", () => {
it("counts each status correctly", () => {
const counts = countAttendanceStatus([
"present",
"present",
"absent",
"late",
"leave",
"present",
"unknown",
]);
expect(counts.present).toBe(3);
expect(counts.absent).toBe(1);
expect(counts.late).toBe(1);
expect(counts.leave).toBe(1);
});
it("returns zeros for empty array", () => {
const counts = countAttendanceStatus([]);
expect(counts.present).toBe(0);
expect(counts.absent).toBe(0);
expect(counts.late).toBe(0);
expect(counts.leave).toBe(0);
});
it("ignores unknown statuses", () => {
const counts = countAttendanceStatus(["foo", "bar"]);
expect(counts.present).toBe(0);
expect(counts.absent).toBe(0);
});
});
describe("computeAttendanceRate", () => {
it("computes rate = present / total", () => {
expect(computeAttendanceRate(8, 10)).toBe(0.8);
expect(computeAttendanceRate(0, 10)).toBe(0);
expect(computeAttendanceRate(10, 10)).toBe(1);
});
it("returns 0 when total is 0", () => {
expect(computeAttendanceRate(0, 0)).toBe(0);
});
it("returns 0 when total is negative or non-finite", () => {
expect(computeAttendanceRate(1, -1)).toBe(0);
expect(computeAttendanceRate(1, Number.NaN)).toBe(0);
});
it("clamps to 1 when present > total", () => {
expect(computeAttendanceRate(12, 10)).toBe(1);
});
});

View File

@@ -0,0 +1,241 @@
"use client";
/**
* 考勤记录列表页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* 数据契约:
* - 列表查询 attendanceRecords(...):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/classes_contract.md#attendance-records-list
*
* URL 状态:?classId=&date=&status=&q=
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState + 行动按钮)
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { ClipboardList } from "lucide-react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useTranslations } from "next-intl";
import { useAttendanceRecords, type AttendanceRecord } from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import {
attendanceStatusToBadgeClass,
formatAttendanceDate,
formatAttendanceDay,
formatAttendanceStatus,
} from "@/features/teacher/attendance/transformations";
/**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
* useSearchParams 要求 Suspense 边界Next.js 15 强制)。
*/
export function AttendanceListClient(): React.ReactElement {
const t = useTranslations("attendance");
const tCommon = useTranslations("common");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const classId = searchParams.get("classId") ?? "";
const date = searchParams.get("date") ?? "";
const statusFilter = searchParams.get("status") ?? "";
const q = searchParams.get("q") ?? "";
// @contract-pendingMSW 兜底
const { data, loading, error } = useAttendanceRecords({
classId: classId || undefined,
date: date || undefined,
status: statusFilter || undefined,
q: q || undefined,
});
const filteredItems = useMemo<AttendanceRecord[]>(() => {
const items = data?.items ?? [];
return items;
}, [data]);
const updateQuery = (key: string, value: string): void => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
startTransition(() => {
router.push(`/shell/teacher/attendance?${params.toString()}`);
});
};
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("list.mswNotice")}
</p>
</div>
) : undefined;
const emptyNode = (
<EmptyState
icon={ClipboardList}
title={t("list.emptyTitle")}
description={t("list.emptyDescription")}
action={{
label: t("list.emptyAction"),
href: "/shell/teacher/attendance/sheet",
}}
/>
);
return (
<ListPageShell
title={t("list.title")}
description={t("list.description")}
icon={<ClipboardList className="size-6" />}
actions={
<Button asChild>
<Link href="/shell/teacher/attendance/sheet">
{t("list.newSheet")}
</Link>
</Button>
}
filters={
<>
<FilterSearchInput
placeholder={t("list.searchPlaceholder")}
value={q}
onChange={(v) => updateQuery("q", v)}
/>
<input
type="text"
value={classId}
onChange={(e) => updateQuery("classId", e.target.value)}
placeholder={t("list.classPlaceholder")}
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("list.classPlaceholder")}
/>
<input
type="date"
value={date}
onChange={(e) => updateQuery("date", e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("list.dateFilter")}
/>
<select
value={statusFilter}
onChange={(e) => updateQuery("status", e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("list.statusFilter")}
>
<option value="">{t("list.statusAll")}</option>
<option value="present">{t("list.statusPresent")}</option>
<option value="absent">{t("list.statusAbsent")}</option>
<option value="late">{t("list.statusLate")}</option>
<option value="leave">{t("list.statusLeave")}</option>
</select>
</>
}
loading={loading}
loadingNode={<ListPageSkeleton rows={5} />}
empty={filteredItems.length === 0 && !loading}
emptyNode={emptyNode}
errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("list.total", { count: filteredItems.length })}</span>
</div>
}
>
<AttendanceTable items={filteredItems} />
</ListPageShell>
);
}
/**
* 考勤记录列表表格(纯展示组件,对齐 §8.2 排版规范)。
*/
function AttendanceTable({
items,
}: {
items: AttendanceRecord[];
}): React.ReactElement {
const t = useTranslations("attendance");
return (
<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("list.colStudentName")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colClassName")}
</th>
<th className="p-3 text-left font-medium">{t("list.colDate")}</th>
<th className="p-3 text-left font-medium">{t("list.colStatus")}</th>
<th className="p-3 text-left font-medium">{t("list.colRemark")}</th>
<th className="p-3 text-left font-medium">
{t("list.colRecordedBy")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colUpdatedAt")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((r) => (
<tr key={r.id} className="hover:bg-muted/30">
<td className="p-3 font-medium">{r.studentName}</td>
<td className="p-3 text-muted-foreground">{r.className}</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatAttendanceDay(r.date)}
</td>
<td className="p-3">
<AttendanceStatusBadge status={r.status} />
</td>
<td className="p-3 text-xs text-muted-foreground">
{r.remark ?? "-"}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{r.recordedBy}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatAttendanceDate(r.updatedAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/**
* 考勤状态徽章(按状态色阶展示)。
*/
function AttendanceStatusBadge({
status,
}: {
status: string;
}): React.ReactElement {
const label = formatAttendanceStatus(status);
const cls = attendanceStatusToBadgeClass(status);
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label}
</span>
);
}

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

View File

@@ -0,0 +1,232 @@
"use client";
/**
* 考勤点名表页 - 客户端组件ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2
*
* 数据契约:
* - 查询 attendanceSheet(classId, date):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - mutation saveAttendanceSheet(input):❌ schema 无 Mutation → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/classes_contract.md#attendance-sheet
*
* 三态规范§11.3 DoD
* - loadingFormPageSkeleton由 server page Suspense 兜底)
* - errorerrorSummary 表单级错误
* - successnotify.success + 刷新列表
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { ClipboardCheck } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useTransition, useState } from "react";
import { useTranslations } from "next-intl";
import {
ATTENDANCE_STATUS,
useAttendanceSheet,
useSaveAttendanceSheet,
type AttendanceEntryInput,
} from "@/lib/api";
import { FormPageShell } from "@/shared/components/page-templates";
import { notify } from "@/shared/lib/notify";
import {
attendanceStatusToBadgeClass,
formatAttendanceStatus,
} from "@/features/teacher/attendance/transformations";
const STATUS_OPTIONS = [
ATTENDANCE_STATUS.PRESENT,
ATTENDANCE_STATUS.ABSENT,
ATTENDANCE_STATUS.LATE,
ATTENDANCE_STATUS.LEAVE,
];
/**
* 表单客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function AttendanceSheetClient(): React.ReactElement {
const t = useTranslations("attendance");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const presetClassId = searchParams.get("classId") ?? "";
const today = new Date().toISOString().slice(0, 10);
const presetDate = searchParams.get("date") ?? today;
const [classId, setClassId] = useState(presetClassId);
const [date, setDate] = useState(presetDate);
const [entries, setEntries] = useState<Record<string, AttendanceEntryInput>>(
{},
);
const [submitting, setSubmitting] = useState(false);
// @contract-pendingMSW 兜底
const { data, loading, error } = useAttendanceSheet(classId, date, {
enabled: classId.length > 0 && date.length > 0,
});
// 初始化 entries首次加载数据后
useEffect(() => {
if (!data) return;
setEntries((prev) => {
if (Object.keys(prev).length > 0) return prev;
const next: Record<string, AttendanceEntryInput> = {};
for (const e of data.entries) {
next[e.studentId] = {
studentId: e.studentId,
status: e.status,
remark: e.remark ?? undefined,
};
}
return next;
});
}, [data]);
const handleStatusChange = (studentId: string, status: string): void => {
setEntries((prev) => ({
...prev,
[studentId]: {
studentId,
status,
remark: prev[studentId]?.remark,
},
}));
};
const { run: saveSheet } = useSaveAttendanceSheet();
const handleSubmit = async (): Promise<void> => {
if (!classId.trim()) {
notify.error(t("sheet.errorClassRequired"));
return;
}
if (!date) {
notify.error(t("sheet.errorDateRequired"));
return;
}
const entryList = Object.values(entries);
if (entryList.length === 0) {
notify.warning(t("sheet.errorNoEntries"));
return;
}
setSubmitting(true);
try {
await saveSheet({ classId, date, entries: entryList });
notify.success(t("sheet.success"));
startTransition(() => {
router.push(
`/shell/teacher/attendance?classId=${classId}&date=${date}`,
);
});
} catch (err) {
notify.error(`${t("sheet.error")}: ${String(err)}`);
} finally {
setSubmitting(false);
}
};
const errorSummary = error ? (
<p className="text-sm text-destructive">
{t("sheet.loadFailed", { message: String(error) })}
</p>
) : undefined;
return (
<FormPageShell
title={t("sheet.title")}
description={t("sheet.description")}
icon={<ClipboardCheck className="size-6" />}
backHref="/shell/teacher/attendance"
loading={loading && !data}
submitting={submitting}
submitLabel={t("sheet.submit")}
cancelLabel={t("sheet.cancel")}
errorSummary={errorSummary}
onSubmit={handleSubmit}
>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium">{t("sheet.classId")}</span>
<input
type="text"
value={classId}
onChange={(e) => setClassId(e.target.value)}
placeholder={t("sheet.classIdPlaceholder")}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-sm font-medium">{t("sheet.date")}</span>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
/>
</label>
</div>
{data ? (
<div className="space-y-3">
<div className="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("sheet.colStudentName")}
</th>
<th className="p-3 text-left font-medium">
{t("sheet.colStatus")}
</th>
<th className="p-3 text-left font-medium">
{t("sheet.colRemark")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{data.entries.map((entry) => {
const current = entries[entry.studentId];
const status = current?.status ?? entry.status;
return (
<tr key={entry.studentId}>
<td className="p-3 font-medium">{entry.studentName}</td>
<td className="p-3">
<div className="flex flex-wrap items-center gap-2">
{STATUS_OPTIONS.map((opt) => (
<button
key={opt}
type="button"
onClick={() =>
handleStatusChange(entry.studentId, opt)
}
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium transition-opacity ${
status === opt
? attendanceStatusToBadgeClass(opt)
: "bg-muted text-muted-foreground opacity-50"
}`}
aria-pressed={status === opt}
>
{formatAttendanceStatus(opt)}
</button>
))}
</div>
</td>
<td className="p-3 text-xs text-muted-foreground">
{current?.remark ?? entry.remark ?? "-"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<p className="text-xs text-muted-foreground">
{t("sheet.mswNotice")}
</p>
</div>
) : null}
</div>
</FormPageShell>
);
}

View File

@@ -0,0 +1,297 @@
"use client";
/**
* 考勤统计页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* 数据契约:
* - 统计查询 attendanceStats(...):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/classes_contract.md#attendance-stats
*
* 三态规范§11.3 DoD
* - loadingDetailPageSkeleton
* - errorerrorNode 局部降级
* - emptydata 为 null 时显示空态节点
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { BarChart3 } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import {
useAttendanceStats,
type AttendanceClassRankingItem,
type AttendanceStatusDistribution,
type AttendanceTrendItem,
} from "@/lib/api";
import { EmptyState } from "@/shared/components/ui/empty-state";
import {
DetailPageShell,
DetailPageSkeleton,
DetailSection,
DetailField,
} from "@/shared/components/page-templates";
import {
attendanceRateToBarClass,
attendanceRateToColorClass,
attendanceStatusToBadgeClass,
formatAttendanceDate,
formatAttendanceRate,
formatAttendanceStatus,
} from "@/features/teacher/attendance/transformations";
/**
* 统计客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function AttendanceStatsClient(): React.ReactElement {
const t = useTranslations("attendance");
const tCommon = useTranslations("common");
const searchParams = useSearchParams();
const classId = searchParams.get("classId") ?? undefined;
const startDate = searchParams.get("startDate") ?? undefined;
const endDate = searchParams.get("endDate") ?? undefined;
// @contract-pendingMSW 兜底
const { data, loading, error } = useAttendanceStats({
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("stats.mswNotice")}
</p>
</div>
) : undefined;
const emptyNode =
!loading && !error && !data ? (
<EmptyState
icon={BarChart3}
title={t("stats.notFound")}
action={{
label: t("stats.backToList"),
href: "/shell/teacher/attendance",
}}
/>
) : undefined;
return (
<DetailPageShell
title={t("stats.title")}
description={
data
? t("stats.subtitle", {
rate: formatAttendanceRate(data.overallAttendanceRate),
})
: undefined
}
icon={<BarChart3 className="size-6" />}
backHref="/shell/teacher/attendance"
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
emptyNode={emptyNode}
>
{data ? <StatsOverviewSection data={data} /> : null}
{data ? (
<StatsDistributionSection items={data.statusDistribution} />
) : null}
{data ? <StatsTrendSection items={data.trend} /> : null}
{data ? <StatsRankingSection items={data.classRanking} /> : null}
</DetailPageShell>
);
}
type StatsData = NonNullable<ReturnType<typeof useAttendanceStats>["data"]>;
/**
* 概览区。
*/
function StatsOverviewSection({
data,
}: {
data: StatsData;
}): React.ReactElement {
const t = useTranslations("attendance");
return (
<DetailSection title={t("stats.sectionOverview")}>
<DetailField
label={t("stats.fieldOverallRate")}
value={
<span
className={`font-medium ${attendanceRateToColorClass(data.overallAttendanceRate)}`}
>
{formatAttendanceRate(data.overallAttendanceRate)}
</span>
}
/>
<DetailField
label={t("stats.fieldTotalRecords")}
value={String(data.totalRecords)}
/>
</DetailSection>
);
}
/**
* 状态分布区。
*/
function StatsDistributionSection({
items,
}: {
items: AttendanceStatusDistribution[];
}): React.ReactElement {
const t = useTranslations("attendance");
return (
<DetailSection title={t("stats.sectionDistribution")}>
{items.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("stats.noDistribution")}
</p>
) : (
<div className="space-y-2">
{items.map((item) => (
<div
key={item.status}
className="flex items-center justify-between gap-4"
>
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${attendanceStatusToBadgeClass(item.status)}`}
>
{formatAttendanceStatus(item.status)}
</span>
<div className="flex flex-1 items-center gap-3">
<div className="h-2 flex-1 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${Math.round(item.ratio * 100)}%` }}
aria-hidden="true"
/>
</div>
<span className="w-20 text-right font-mono text-xs text-muted-foreground">
{item.count} · {formatAttendanceRate(item.ratio)}
</span>
</div>
</div>
))}
</div>
)}
</DetailSection>
);
}
/**
* 趋势区。
*/
function StatsTrendSection({
items,
}: {
items: AttendanceTrendItem[];
}): React.ReactElement {
const t = useTranslations("attendance");
if (items.length === 0) {
return (
<DetailSection title={t("stats.sectionTrend")}>
<p className="text-sm text-muted-foreground">{t("stats.noTrend")}</p>
</DetailSection>
);
}
const maxRate = Math.max(
...items.map((i) => Math.min(1, i.attendanceRate)),
0.01,
);
return (
<DetailSection title={t("stats.sectionTrend")}>
<div className="flex h-40 items-end gap-1">
{items.map((item) => {
const heightPct = Math.round(
(Math.min(1, item.attendanceRate) / maxRate) * 100,
);
return (
<div
key={item.date}
className="flex flex-1 flex-col items-center gap-1"
title={`${formatAttendanceDate(item.date)}: ${formatAttendanceRate(item.attendanceRate)}`}
>
<div
className={`w-full rounded-t ${attendanceRateToBarClass(item.attendanceRate)}`}
style={{ height: `${Math.max(2, heightPct)}%` }}
aria-label={`${formatAttendanceDate(item.date)} ${formatAttendanceRate(item.attendanceRate)}`}
/>
<span className="w-full truncate text-center text-xs text-muted-foreground">
{formatAttendanceDate(item.date).slice(5, 10)}
</span>
</div>
);
})}
</div>
</DetailSection>
);
}
/**
* 班级排名区。
*/
function StatsRankingSection({
items,
}: {
items: AttendanceClassRankingItem[];
}): React.ReactElement {
const t = useTranslations("attendance");
if (items.length === 0) {
return (
<DetailSection title={t("stats.sectionRanking")}>
<p className="text-sm text-muted-foreground">{t("stats.noRanking")}</p>
</DetailSection>
);
}
return (
<DetailSection title={t("stats.sectionRanking")}>
<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("stats.colRank")}
</th>
<th className="p-3 text-left font-medium">
{t("stats.colClassName")}
</th>
<th className="p-3 text-center font-medium">
{t("stats.colTotalStudents")}
</th>
<th className="p-3 text-right font-medium">
{t("stats.colAttendanceRate")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((item, idx) => (
<tr key={item.classId} className="hover:bg-muted/30">
<td className="p-3 font-mono text-xs text-muted-foreground">
{idx + 1}
</td>
<td className="p-3 font-medium">{item.className}</td>
<td className="p-3 text-center text-muted-foreground">
{item.totalStudents}
</td>
<td
className={`p-3 text-right font-medium ${attendanceRateToColorClass(item.attendanceRate)}`}
>
{formatAttendanceRate(item.attendanceRate)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</DetailSection>
);
}

View File

@@ -0,0 +1,184 @@
/**
* Attendance 数据变换工具ARCHITECTURE.md §11.3 DoD - 纯函数单测)
*
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
* 关联ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
*/
/** 考勤状态中文标签映射(对齐 ATTENDANCE_STATUS 枚举) */
export const ATTENDANCE_STATUS_LABEL: Record<string, string> = {
present: "出勤",
absent: "缺勤",
late: "迟到",
leave: "请假",
};
/**
* 将考勤状态映射为中文标签。未知状态回退为原始值。
*/
export function formatAttendanceStatus(status: string): string {
return ATTENDANCE_STATUS_LABEL[status] ?? status;
}
/**
* 根据考勤状态返回 Tailwind 徽章语义类名。
*/
export function attendanceStatusToBadgeClass(status: string): string {
switch (status) {
case "present":
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
case "absent":
return "bg-destructive/10 text-destructive";
case "late":
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
case "leave":
return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
default:
return "bg-muted text-muted-foreground";
}
}
/**
* 格式化 ISO 日期字符串为本地化展示zh-CN含年月日时分
* 输入无效时返回占位符。
*/
export function formatAttendanceDate(
isoDate: string | null | undefined,
): string {
if (!isoDate) return "--";
const d = new Date(isoDate);
if (Number.isNaN(d.getTime())) return "--";
return d.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* 格式化日期为纯日期展示zh-CN仅年月日
* 输入无效时返回占位符。
*/
export function formatAttendanceDay(
isoDate: string | null | undefined,
): string {
if (!isoDate) return "--";
const d = new Date(isoDate);
if (Number.isNaN(d.getTime())) return "--";
return d.toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}
/**
* 格式化出勤率为百分比字符串。
* 输入 0~1 的浮点数,返回 "85.0%" 形式;输入无效返回 "--"。
*/
export function formatAttendanceRate(rate: number | null | undefined): string {
if (rate == null || !Number.isFinite(rate)) return "--";
const pct = rate <= 1 ? rate * 100 : rate;
return `${pct.toFixed(1)}%`;
}
/**
* 根据出勤率返回 Tailwind 文本语义类名。
* - >= 0.95emerald优秀
* - >= 0.9primary达标
* - >= 0.8amber待改善
* - < 0.8destructive需关注
*/
export function attendanceRateToColorClass(
rate: number | null | undefined,
): string {
if (rate == null || !Number.isFinite(rate)) return "text-muted-foreground";
const r = rate <= 1 ? rate : rate / 100;
if (r >= 0.95) return "text-emerald-600";
if (r >= 0.9) return "text-primary";
if (r >= 0.8) return "text-amber-600";
return "text-destructive";
}
/**
* 根据出勤率返回 Tailwind 柱状图背景语义类名(静态映射,禁止动态拼接)。
* - >= 0.95emerald优秀
* - >= 0.9primary达标
* - >= 0.8amber待改善
* - < 0.8destructive需关注
*
* 关联project_rules §3.9 / §3.10 禁止字符串拼接动态类名
*/
export function attendanceRateToBarClass(
rate: number | null | undefined,
): string {
if (rate == null || !Number.isFinite(rate)) return "bg-muted";
const r = rate <= 1 ? rate : rate / 100;
if (r >= 0.95) return "bg-emerald-500/70";
if (r >= 0.9) return "bg-primary/70";
if (r >= 0.8) return "bg-amber-500/70";
return "bg-destructive/70";
}
/**
* 将星期几0=周日 … 6=周六)映射为中文标签。
*/
export function formatWeekday(weekday: number): string {
const labels = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
const idx = Math.trunc(weekday);
if (idx < 0 || idx > 6) return "--";
return labels[idx] ?? "--";
}
/**
* 格式化课表节次为展示字符串("第 1 节")。
* 输入无效返回 "--"。
*/
export function formatSchedulePeriod(period: number): string {
if (!Number.isFinite(period)) return "--";
return `${period}`;
}
/**
* 判断考勤状态是否为出勤present
*/
export function isAttendancePresent(status: string): boolean {
return status === "present";
}
/**
* 判断考勤状态是否为异常(缺勤/迟到)。
*/
export function isAttendanceAbnormal(status: string): boolean {
return status === "absent" || status === "late";
}
/**
* 统计给定状态列表中各状态的数量。
* 返回 { present, absent, late, leave } 计数对象。
*/
export function countAttendanceStatus(statuses: readonly string[]): {
present: number;
absent: number;
late: number;
leave: number;
} {
const counts = { present: 0, absent: 0, late: 0, leave: 0 };
for (const s of statuses) {
if (s in counts) {
counts[s as keyof typeof counts] += 1;
}
}
return counts;
}
/**
* 计算出勤率present / total
* total 为 0 时返回 0。
*/
export function computeAttendanceRate(present: number, total: number): number {
if (!Number.isFinite(total) || total <= 0) return 0;
return Math.min(1, present / total);
}