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,198 @@
"use client";
/**
* 班级课表页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* 数据契约:
* - 课表查询 classSchedule(classId):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/classes_contract.md#class-schedule
*
* URL 状态:?classId=(可选,指定班级课表;未指定时返回默认课表)
*
* 三态规范§11.3 DoD
* - loadingDetailPageSkeleton
* - errorerrorNode 局部降级
* - emptydata 为 null 时显示空态节点
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { Calendar } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useClassSchedule, type ClassScheduleItem } from "@/lib/api";
import { EmptyState } from "@/shared/components/ui/empty-state";
import {
DetailPageShell,
DetailPageSkeleton,
DetailSection,
DetailField,
} from "@/shared/components/page-templates";
import {
formatSchedulePeriod,
formatScheduleTime,
formatWeekday,
groupScheduleByWeekday,
sortScheduleByDay,
} from "@/features/teacher/classes/transformations";
/**
* 课表客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function ClassScheduleClient(): React.ReactElement {
const t = useTranslations("classes");
const tCommon = useTranslations("common");
const searchParams = useSearchParams();
const classId = searchParams.get("classId") ?? "cls-001";
// @contract-pendingMSW 兜底
const { data, loading, error } = useClassSchedule(classId);
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("schedule.mswNotice")}
</p>
</div>
) : undefined;
const emptyNode =
!loading && !error && !data ? (
<EmptyState
icon={Calendar}
title={t("schedule.notFound")}
action={{
label: t("schedule.backToList"),
href: "/shell/teacher/classes",
}}
/>
) : undefined;
return (
<DetailPageShell
title={t("schedule.title")}
description={
data
? t("schedule.subtitle", {
className: data.className,
weekRange: data.weekRange,
})
: undefined
}
icon={<Calendar className="size-6" />}
backHref="/shell/teacher/classes"
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
emptyNode={emptyNode}
>
{data ? <ScheduleOverviewSection data={data} /> : null}
{data ? <ScheduleByDaySection items={data.items} /> : null}
</DetailPageShell>
);
}
type ScheduleData = NonNullable<ReturnType<typeof useClassSchedule>["data"]>;
/**
* 课表概览区。
*/
function ScheduleOverviewSection({
data,
}: {
data: ScheduleData;
}): React.ReactElement {
const t = useTranslations("classes");
return (
<DetailSection title={t("schedule.sectionOverview")}>
<DetailField
label={t("schedule.fieldClassName")}
value={data.className}
/>
<DetailField
label={t("schedule.fieldWeekRange")}
value={data.weekRange}
/>
<DetailField
label={t("schedule.fieldTotalLessons")}
value={String(data.items.length)}
/>
</DetailSection>
);
}
/**
* 按星期分组的课表区。
*/
function ScheduleByDaySection({
items,
}: {
items: ClassScheduleItem[];
}): React.ReactElement {
const t = useTranslations("classes");
const sorted = sortScheduleByDay(items);
const groups = groupScheduleByWeekday(sorted);
return (
<DetailSection title={t("schedule.sectionByDay")}>
<div className="space-y-4">
{groups.map((group, idx) => {
if (group.length === 0) return null;
return (
<div key={idx} className="space-y-2">
<h4 className="text-sm font-semibold text-foreground">
{formatWeekday(idx)}
</h4>
<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("schedule.colPeriod")}
</th>
<th className="p-3 text-left font-medium">
{t("schedule.colSubject")}
</th>
<th className="p-3 text-left font-medium">
{t("schedule.colTeacher")}
</th>
<th className="p-3 text-left font-medium">
{t("schedule.colClassroom")}
</th>
<th className="p-3 text-left font-medium">
{t("schedule.colTime")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{group.map((item) => (
<tr key={item.id} className="hover:bg-muted/30">
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatSchedulePeriod(item.period)}
</td>
<td className="p-3 font-medium">{item.subjectName}</td>
<td className="p-3 text-muted-foreground">
{item.teacherName}
</td>
<td className="p-3 text-xs text-muted-foreground">
{item.classroom ?? "--"}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatScheduleTime(item.startTime, item.endTime)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
})}
</div>
</DetailSection>
);
}