Files
Edu/apps/portal-shell/src/features/teacher/classes/class-schedule-client.tsx
SpecialX 73e09ca29a 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
2026-07-22 21:58:07 +08:00

199 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}