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