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:
@@ -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):
|
||||
* - 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user