§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
199 lines
6.2 KiB
TypeScript
199 lines
6.2 KiB
TypeScript
"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):
|
||
* - loading:DetailPageSkeleton
|
||
* - error:errorNode 局部降级
|
||
* - empty:data 为 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-pending:MSW 兜底
|
||
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>
|
||
);
|
||
}
|