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

@@ -19,9 +19,10 @@ interface Baseline {
categories: Record<string, { pattern: string; min: number; label: string }>;
}
// Baseline as of P2 (2026-07-22, questions + textbooks modules added). Update when adding pages.
// Baseline as of P2 B2 (2026-07-22, attendance + classes + students modules added).
// Update when adding pages.
const BASELINE: Baseline = {
total: 40,
total: 48,
categories: {
dashboards: {
pattern: "shell/{admin,teacher,student,parent}/page.tsx",

View File

@@ -0,0 +1,38 @@
"use client";
/**
* 考勤路由错误边界ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment error.tsx捕获子树未处理异常。
*/
import { useEffect } from "react";
import { Button } from "@/shared/components/ui/button";
import { useTranslations } from "next-intl";
export default function AttendanceError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}): React.ReactElement {
const t = useTranslations("attendance");
useEffect(() => {
console.error("[portal-shell] attendance route error:", error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-destructive/30 bg-destructive/5 p-10">
<h2 className="text-lg font-semibold text-destructive">
{t("error.title")}
</h2>
<p className="text-sm text-muted-foreground">
{error.message || t("error.unknown")}
</p>
<Button onClick={reset} variant="outline">
{t("error.retry")}
</Button>
</div>
);
}

View File

@@ -0,0 +1,9 @@
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 考勤路由段加载骨架ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment loading.tsx自动包裹页面渲染期间。
*/
export default function AttendanceLoading(): React.ReactElement {
return <ListPageSkeleton rows={5} />;
}

View File

@@ -0,0 +1,23 @@
import { Suspense } from "react";
import { AttendanceListClient } from "@/features/teacher/attendance/attendance-list-client";
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 考勤记录列表页ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹useSearchParams 要求)。
* 业务逻辑在 AttendanceListClientclient component中。
*
* 数据契约attendanceRecords(...) ❌ schema 无 → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/classes_contract.md#attendance-records-list
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function AttendanceListPage(): React.ReactElement {
return (
<Suspense fallback={<ListPageSkeleton rows={5} />}>
<AttendanceListClient />
</Suspense>
);
}

View File

@@ -0,0 +1,23 @@
import { Suspense } from "react";
import { AttendanceReportClient } from "@/features/teacher/attendance/attendance-report-client";
import { DetailPageSkeleton } from "@/shared/components/page-templates";
/**
* 考勤报表页ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹useSearchParams 要求)。
* 业务逻辑在 AttendanceReportClientclient component中。
*
* 数据契约attendanceReport(classId, range) ❌ schema 无 → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/classes_contract.md#attendance-report
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function AttendanceReportPage(): React.ReactElement {
return (
<Suspense fallback={<DetailPageSkeleton />}>
<AttendanceReportClient />
</Suspense>
);
}

View File

@@ -0,0 +1,25 @@
import { Suspense } from "react";
import { AttendanceSheetClient } from "@/features/teacher/attendance/attendance-sheet-client";
import { FormPageSkeleton } from "@/shared/components/page-templates";
/**
* 考勤点名表页ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹useSearchParams 要求)。
* 业务逻辑在 AttendanceSheetClientclient component中。
*
* 数据契约:
* - attendanceSheet(classId, date) ❌ schema 无 → MSW 兜底(@contract-pending
* - saveAttendanceSheet(input) ❌ schema 无 Mutation → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/classes_contract.md#attendance-sheet
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function AttendanceSheetPage(): React.ReactElement {
return (
<Suspense fallback={<FormPageSkeleton />}>
<AttendanceSheetClient />
</Suspense>
);
}

View File

@@ -0,0 +1,23 @@
import { Suspense } from "react";
import { AttendanceStatsClient } from "@/features/teacher/attendance/attendance-stats-client";
import { DetailPageSkeleton } from "@/shared/components/page-templates";
/**
* 考勤统计页ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹useSearchParams 要求)。
* 业务逻辑在 AttendanceStatsClientclient component中。
*
* 数据契约attendanceStats(...) ❌ schema 无 → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/classes_contract.md#attendance-stats
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function AttendanceStatsPage(): React.ReactElement {
return (
<Suspense fallback={<DetailPageSkeleton />}>
<AttendanceStatsClient />
</Suspense>
);
}

View File

@@ -0,0 +1,25 @@
import { Suspense } from "react";
import { ClassDetailClient } from "@/features/teacher/classes/class-detail-client";
import { DetailPageSkeleton } from "@/shared/components/page-templates";
/**
* 班级详情页ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹useParams 要求)。
* 业务逻辑在 ClassDetailClientclient component中。
*
* 数据契约(混合):
* - classInfo(id: ID!) ✅ schema 真实字段classes 子图)
* - classStudents(classId) / classTeachers(classId) ❌ schema 无 → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/classes_contract.md#class-students
*
* 关联ARCHITECTURE.md §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function ClassDetailPage(): React.ReactElement {
return (
<Suspense fallback={<DetailPageSkeleton />}>
<ClassDetailClient />
</Suspense>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
/**
* 班级路由错误边界ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment error.tsx捕获子树未处理异常。
*/
import { useEffect } from "react";
import { Button } from "@/shared/components/ui/button";
import { useTranslations } from "next-intl";
export default function ClassesError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}): React.ReactElement {
const t = useTranslations("classes");
useEffect(() => {
console.error("[portal-shell] classes route error:", error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-destructive/30 bg-destructive/5 p-10">
<h2 className="text-lg font-semibold text-destructive">
{t("error.title")}
</h2>
<p className="text-sm text-muted-foreground">
{error.message || t("error.unknown")}
</p>
<Button onClick={reset} variant="outline">
{t("error.retry")}
</Button>
</div>
);
}

View File

@@ -0,0 +1,9 @@
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 班级路由段加载骨架ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment loading.tsx自动包裹页面渲染期间。
*/
export default function ClassesLoading(): React.ReactElement {
return <ListPageSkeleton rows={5} />;
}

View File

@@ -0,0 +1,23 @@
import { Suspense } from "react";
import { ClassesListClient } from "@/features/teacher/classes/classes-list-client";
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 班级管理列表页ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹useSearchParams 要求)。
* 业务逻辑在 ClassesListClientclient component中。
*
* 数据契约classes(...) ❌ schema 无 → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/classes_contract.md#classes-list
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function ClassesListPage(): React.ReactElement {
return (
<Suspense fallback={<ListPageSkeleton rows={5} />}>
<ClassesListClient />
</Suspense>
);
}

View File

@@ -0,0 +1,23 @@
import { Suspense } from "react";
import { ClassScheduleClient } from "@/features/teacher/classes/class-schedule-client";
import { DetailPageSkeleton } from "@/shared/components/page-templates";
/**
* 班级课表页ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹useSearchParams 要求)。
* 业务逻辑在 ClassScheduleClientclient component中。
*
* 数据契约classSchedule(classId) ❌ schema 无 → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/classes_contract.md#class-schedule
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function ClassSchedulePage(): React.ReactElement {
return (
<Suspense fallback={<DetailPageSkeleton />}>
<ClassScheduleClient />
</Suspense>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
/**
* 学生路由错误边界ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment error.tsx捕获子树未处理异常。
*/
import { useEffect } from "react";
import { Button } from "@/shared/components/ui/button";
import { useTranslations } from "next-intl";
export default function StudentsError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}): React.ReactElement {
const t = useTranslations("students");
useEffect(() => {
console.error("[portal-shell] students route error:", error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-destructive/30 bg-destructive/5 p-10">
<h2 className="text-lg font-semibold text-destructive">
{t("error.title")}
</h2>
<p className="text-sm text-muted-foreground">
{error.message || t("error.unknown")}
</p>
<Button onClick={reset} variant="outline">
{t("error.retry")}
</Button>
</div>
);
}

View File

@@ -0,0 +1,9 @@
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 学生路由段加载骨架ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD
* Next.js Route Segment loading.tsx自动包裹页面渲染期间。
*/
export default function StudentsLoading(): React.ReactElement {
return <ListPageSkeleton rows={5} />;
}

View File

@@ -0,0 +1,23 @@
import { Suspense } from "react";
import { StudentsListClient } from "@/features/teacher/students/students-list-client";
import { ListPageSkeleton } from "@/shared/components/page-templates";
/**
* 学生管理列表页ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* Server Component 入口:仅负责 Suspense 边界包裹useSearchParams 要求)。
* 业务逻辑在 StudentsListClientclient component中。
*
* 数据契约students(...) ❌ schema 无 → MSW 兜底(@contract-pending
* 契约工单docs/architecture/issues/contracts/classes_contract.md#students
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
export default function StudentsListPage(): React.ReactElement {
return (
<Suspense fallback={<ListPageSkeleton rows={5} />}>
<StudentsListClient />
</Suspense>
);
}

View File

@@ -0,0 +1,294 @@
/**
* Attendance 数据变换工具单测ARCHITECTURE.md §11.3 DoD
*
* 关联ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
*/
import { describe, expect, it } from "vitest";
import {
ATTENDANCE_STATUS_LABEL,
attendanceRateToBarClass,
attendanceRateToColorClass,
attendanceStatusToBadgeClass,
computeAttendanceRate,
countAttendanceStatus,
formatAttendanceDate,
formatAttendanceDay,
formatAttendanceRate,
formatAttendanceStatus,
formatSchedulePeriod,
formatWeekday,
isAttendanceAbnormal,
isAttendancePresent,
} from "../transformations";
describe("formatAttendanceStatus", () => {
it("maps known statuses to Chinese labels", () => {
expect(formatAttendanceStatus("present")).toBe("出勤");
expect(formatAttendanceStatus("absent")).toBe("缺勤");
expect(formatAttendanceStatus("late")).toBe("迟到");
expect(formatAttendanceStatus("leave")).toBe("请假");
});
it("returns original value for unknown status", () => {
expect(formatAttendanceStatus("unknown")).toBe("unknown");
expect(formatAttendanceStatus("")).toBe("");
});
it("ATTENDANCE_STATUS_LABEL covers 4 standard statuses", () => {
expect(Object.keys(ATTENDANCE_STATUS_LABEL)).toHaveLength(4);
});
});
describe("attendanceStatusToBadgeClass", () => {
it("returns emerald class for present", () => {
expect(attendanceStatusToBadgeClass("present")).toContain("emerald");
});
it("returns destructive class for absent", () => {
expect(attendanceStatusToBadgeClass("absent")).toContain("destructive");
});
it("returns amber class for late", () => {
expect(attendanceStatusToBadgeClass("late")).toContain("amber");
});
it("returns blue class for leave", () => {
expect(attendanceStatusToBadgeClass("leave")).toContain("blue");
});
it("returns muted for unknown status", () => {
expect(attendanceStatusToBadgeClass("unknown")).toBe(
"bg-muted text-muted-foreground",
);
});
});
describe("formatAttendanceDate", () => {
it("formats valid ISO date string with time", () => {
const result = formatAttendanceDate("2026-07-22T10:30:00Z");
expect(result).toContain("2026");
expect(result).toContain("07");
});
it("returns placeholder for null/undefined/empty", () => {
expect(formatAttendanceDate(null)).toBe("--");
expect(formatAttendanceDate(undefined)).toBe("--");
expect(formatAttendanceDate("")).toBe("--");
});
it("returns placeholder for invalid date", () => {
expect(formatAttendanceDate("not-a-date")).toBe("--");
});
});
describe("formatAttendanceDay", () => {
it("formats valid ISO date string as date only", () => {
const result = formatAttendanceDay("2026-07-22T10:30:00Z");
expect(result).toContain("2026");
expect(result).toContain("07");
});
it("returns placeholder for null/undefined/empty", () => {
expect(formatAttendanceDay(null)).toBe("--");
expect(formatAttendanceDay(undefined)).toBe("--");
expect(formatAttendanceDay("")).toBe("--");
});
});
describe("formatAttendanceRate", () => {
it("formats 0~1 float as percentage", () => {
expect(formatAttendanceRate(0)).toBe("0.0%");
expect(formatAttendanceRate(0.85)).toBe("85.0%");
expect(formatAttendanceRate(1)).toBe("100.0%");
});
it("treats values > 1 as already percentage", () => {
expect(formatAttendanceRate(85)).toBe("85.0%");
expect(formatAttendanceRate(100)).toBe("100.0%");
});
it("returns placeholder for null/undefined/non-finite", () => {
expect(formatAttendanceRate(null)).toBe("--");
expect(formatAttendanceRate(undefined)).toBe("--");
expect(formatAttendanceRate(Number.NaN)).toBe("--");
expect(formatAttendanceRate(Number.POSITIVE_INFINITY)).toBe("--");
});
});
describe("attendanceRateToColorClass", () => {
it("returns emerald for >= 0.95", () => {
expect(attendanceRateToColorClass(0.95)).toBe("text-emerald-600");
expect(attendanceRateToColorClass(1)).toBe("text-emerald-600");
});
it("returns primary for >= 0.9 and < 0.95", () => {
expect(attendanceRateToColorClass(0.9)).toBe("text-primary");
expect(attendanceRateToColorClass(0.94)).toBe("text-primary");
});
it("returns amber for >= 0.8 and < 0.9", () => {
expect(attendanceRateToColorClass(0.8)).toBe("text-amber-600");
expect(attendanceRateToColorClass(0.89)).toBe("text-amber-600");
});
it("returns destructive for < 0.8", () => {
expect(attendanceRateToColorClass(0.79)).toBe("text-destructive");
expect(attendanceRateToColorClass(0)).toBe("text-destructive");
});
it("treats values > 1 as percentage and normalizes", () => {
expect(attendanceRateToColorClass(95)).toBe("text-emerald-600");
expect(attendanceRateToColorClass(85)).toBe("text-amber-600");
});
it("returns muted for null/undefined/non-finite", () => {
expect(attendanceRateToColorClass(null)).toBe("text-muted-foreground");
expect(attendanceRateToColorClass(undefined)).toBe("text-muted-foreground");
expect(attendanceRateToColorClass(Number.NaN)).toBe(
"text-muted-foreground",
);
});
});
describe("attendanceRateToBarClass", () => {
it("returns emerald bar for >= 0.95", () => {
expect(attendanceRateToBarClass(0.95)).toBe("bg-emerald-500/70");
expect(attendanceRateToBarClass(1)).toBe("bg-emerald-500/70");
});
it("returns primary bar for >= 0.9 and < 0.95", () => {
expect(attendanceRateToBarClass(0.9)).toBe("bg-primary/70");
expect(attendanceRateToBarClass(0.94)).toBe("bg-primary/70");
});
it("returns amber bar for >= 0.8 and < 0.9", () => {
expect(attendanceRateToBarClass(0.8)).toBe("bg-amber-500/70");
expect(attendanceRateToBarClass(0.89)).toBe("bg-amber-500/70");
});
it("returns destructive bar for < 0.8", () => {
expect(attendanceRateToBarClass(0.79)).toBe("bg-destructive/70");
expect(attendanceRateToBarClass(0)).toBe("bg-destructive/70");
});
it("treats values > 1 as percentage and normalizes", () => {
expect(attendanceRateToBarClass(95)).toBe("bg-emerald-500/70");
expect(attendanceRateToBarClass(85)).toBe("bg-amber-500/70");
});
it("returns muted bar for null/undefined/non-finite", () => {
expect(attendanceRateToBarClass(null)).toBe("bg-muted");
expect(attendanceRateToBarClass(undefined)).toBe("bg-muted");
expect(attendanceRateToBarClass(Number.NaN)).toBe("bg-muted");
});
});
describe("formatWeekday", () => {
it("maps 0-6 to Chinese weekday labels", () => {
expect(formatWeekday(0)).toBe("周日");
expect(formatWeekday(1)).toBe("周一");
expect(formatWeekday(2)).toBe("周二");
expect(formatWeekday(3)).toBe("周三");
expect(formatWeekday(4)).toBe("周四");
expect(formatWeekday(5)).toBe("周五");
expect(formatWeekday(6)).toBe("周六");
});
it("returns placeholder for out-of-range", () => {
expect(formatWeekday(-1)).toBe("--");
expect(formatWeekday(7)).toBe("--");
expect(formatWeekday(3.7)).toBe("周三");
});
});
describe("formatSchedulePeriod", () => {
it("formats integer period as section label", () => {
expect(formatSchedulePeriod(1)).toBe("第 1 节");
expect(formatSchedulePeriod(8)).toBe("第 8 节");
});
it("returns placeholder for non-finite", () => {
expect(formatSchedulePeriod(Number.NaN)).toBe("--");
expect(formatSchedulePeriod(Number.POSITIVE_INFINITY)).toBe("--");
});
});
describe("isAttendancePresent / isAttendanceAbnormal", () => {
it("present is present, not abnormal", () => {
expect(isAttendancePresent("present")).toBe(true);
expect(isAttendanceAbnormal("present")).toBe(false);
});
it("absent is abnormal, not present", () => {
expect(isAttendancePresent("absent")).toBe(false);
expect(isAttendanceAbnormal("absent")).toBe(true);
});
it("late is abnormal", () => {
expect(isAttendanceAbnormal("late")).toBe(true);
});
it("leave is not abnormal, not present", () => {
expect(isAttendancePresent("leave")).toBe(false);
expect(isAttendanceAbnormal("leave")).toBe(false);
});
it("unknown is neither present nor abnormal", () => {
expect(isAttendancePresent("unknown")).toBe(false);
expect(isAttendanceAbnormal("unknown")).toBe(false);
});
});
describe("countAttendanceStatus", () => {
it("counts each status correctly", () => {
const counts = countAttendanceStatus([
"present",
"present",
"absent",
"late",
"leave",
"present",
"unknown",
]);
expect(counts.present).toBe(3);
expect(counts.absent).toBe(1);
expect(counts.late).toBe(1);
expect(counts.leave).toBe(1);
});
it("returns zeros for empty array", () => {
const counts = countAttendanceStatus([]);
expect(counts.present).toBe(0);
expect(counts.absent).toBe(0);
expect(counts.late).toBe(0);
expect(counts.leave).toBe(0);
});
it("ignores unknown statuses", () => {
const counts = countAttendanceStatus(["foo", "bar"]);
expect(counts.present).toBe(0);
expect(counts.absent).toBe(0);
});
});
describe("computeAttendanceRate", () => {
it("computes rate = present / total", () => {
expect(computeAttendanceRate(8, 10)).toBe(0.8);
expect(computeAttendanceRate(0, 10)).toBe(0);
expect(computeAttendanceRate(10, 10)).toBe(1);
});
it("returns 0 when total is 0", () => {
expect(computeAttendanceRate(0, 0)).toBe(0);
});
it("returns 0 when total is negative or non-finite", () => {
expect(computeAttendanceRate(1, -1)).toBe(0);
expect(computeAttendanceRate(1, Number.NaN)).toBe(0);
});
it("clamps to 1 when present > total", () => {
expect(computeAttendanceRate(12, 10)).toBe(1);
});
});

View File

@@ -0,0 +1,241 @@
"use client";
/**
* 考勤记录列表页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* 数据契约:
* - 列表查询 attendanceRecords(...):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/classes_contract.md#attendance-records-list
*
* URL 状态:?classId=&date=&status=&q=
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState + 行动按钮)
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { ClipboardList } from "lucide-react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useTranslations } from "next-intl";
import { useAttendanceRecords, type AttendanceRecord } from "@/lib/api";
import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import {
attendanceStatusToBadgeClass,
formatAttendanceDate,
formatAttendanceDay,
formatAttendanceStatus,
} from "@/features/teacher/attendance/transformations";
/**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
* useSearchParams 要求 Suspense 边界Next.js 15 强制)。
*/
export function AttendanceListClient(): React.ReactElement {
const t = useTranslations("attendance");
const tCommon = useTranslations("common");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const classId = searchParams.get("classId") ?? "";
const date = searchParams.get("date") ?? "";
const statusFilter = searchParams.get("status") ?? "";
const q = searchParams.get("q") ?? "";
// @contract-pendingMSW 兜底
const { data, loading, error } = useAttendanceRecords({
classId: classId || undefined,
date: date || undefined,
status: statusFilter || undefined,
q: q || undefined,
});
const filteredItems = useMemo<AttendanceRecord[]>(() => {
const items = data?.items ?? [];
return items;
}, [data]);
const updateQuery = (key: string, value: string): void => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
startTransition(() => {
router.push(`/shell/teacher/attendance?${params.toString()}`);
});
};
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("list.mswNotice")}
</p>
</div>
) : undefined;
const emptyNode = (
<EmptyState
icon={ClipboardList}
title={t("list.emptyTitle")}
description={t("list.emptyDescription")}
action={{
label: t("list.emptyAction"),
href: "/shell/teacher/attendance/sheet",
}}
/>
);
return (
<ListPageShell
title={t("list.title")}
description={t("list.description")}
icon={<ClipboardList className="size-6" />}
actions={
<Button asChild>
<Link href="/shell/teacher/attendance/sheet">
{t("list.newSheet")}
</Link>
</Button>
}
filters={
<>
<FilterSearchInput
placeholder={t("list.searchPlaceholder")}
value={q}
onChange={(v) => updateQuery("q", v)}
/>
<input
type="text"
value={classId}
onChange={(e) => updateQuery("classId", e.target.value)}
placeholder={t("list.classPlaceholder")}
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("list.classPlaceholder")}
/>
<input
type="date"
value={date}
onChange={(e) => updateQuery("date", e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("list.dateFilter")}
/>
<select
value={statusFilter}
onChange={(e) => updateQuery("status", e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("list.statusFilter")}
>
<option value="">{t("list.statusAll")}</option>
<option value="present">{t("list.statusPresent")}</option>
<option value="absent">{t("list.statusAbsent")}</option>
<option value="late">{t("list.statusLate")}</option>
<option value="leave">{t("list.statusLeave")}</option>
</select>
</>
}
loading={loading}
loadingNode={<ListPageSkeleton rows={5} />}
empty={filteredItems.length === 0 && !loading}
emptyNode={emptyNode}
errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("list.total", { count: filteredItems.length })}</span>
</div>
}
>
<AttendanceTable items={filteredItems} />
</ListPageShell>
);
}
/**
* 考勤记录列表表格(纯展示组件,对齐 §8.2 排版规范)。
*/
function AttendanceTable({
items,
}: {
items: AttendanceRecord[];
}): React.ReactElement {
const t = useTranslations("attendance");
return (
<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("list.colStudentName")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colClassName")}
</th>
<th className="p-3 text-left font-medium">{t("list.colDate")}</th>
<th className="p-3 text-left font-medium">{t("list.colStatus")}</th>
<th className="p-3 text-left font-medium">{t("list.colRemark")}</th>
<th className="p-3 text-left font-medium">
{t("list.colRecordedBy")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colUpdatedAt")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((r) => (
<tr key={r.id} className="hover:bg-muted/30">
<td className="p-3 font-medium">{r.studentName}</td>
<td className="p-3 text-muted-foreground">{r.className}</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatAttendanceDay(r.date)}
</td>
<td className="p-3">
<AttendanceStatusBadge status={r.status} />
</td>
<td className="p-3 text-xs text-muted-foreground">
{r.remark ?? "-"}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{r.recordedBy}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatAttendanceDate(r.updatedAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/**
* 考勤状态徽章(按状态色阶展示)。
*/
function AttendanceStatusBadge({
status,
}: {
status: string;
}): React.ReactElement {
const label = formatAttendanceStatus(status);
const cls = attendanceStatusToBadgeClass(status);
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label}
</span>
);
}

View File

@@ -0,0 +1,206 @@
"use client";
/**
* 考勤报表页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* 数据契约:
* - 报表查询 attendanceReport(classId, range):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/classes_contract.md#attendance-report
*
* 三态规范§11.3 DoD
* - loadingDetailPageSkeleton
* - errorerrorNode 局部降级
* - emptydata 为 null 时显示空态节点
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { FileText } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useAttendanceReport, type AttendanceReportItem } from "@/lib/api";
import { EmptyState } from "@/shared/components/ui/empty-state";
import {
DetailPageShell,
DetailPageSkeleton,
DetailSection,
DetailField,
} from "@/shared/components/page-templates";
import {
attendanceRateToColorClass,
formatAttendanceRate,
} from "@/features/teacher/attendance/transformations";
/**
* 报表客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function AttendanceReportClient(): React.ReactElement {
const t = useTranslations("attendance");
const tCommon = useTranslations("common");
const searchParams = useSearchParams();
const classId = searchParams.get("classId") ?? "cls-001";
const startDate = searchParams.get("startDate") ?? undefined;
const endDate = searchParams.get("endDate") ?? undefined;
// @contract-pendingMSW 兜底
const { data, loading, error } = useAttendanceReport(
classId,
startDate,
endDate,
);
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("report.mswNotice")}
</p>
</div>
) : undefined;
const emptyNode =
!loading && !error && !data ? (
<EmptyState
icon={FileText}
title={t("report.notFound")}
action={{
label: t("report.backToList"),
href: "/shell/teacher/attendance",
}}
/>
) : undefined;
return (
<DetailPageShell
title={t("report.title")}
description={
data
? t("report.subtitle", {
className: data.className,
range: data.range,
})
: undefined
}
icon={<FileText className="size-6" />}
backHref="/shell/teacher/attendance"
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
emptyNode={emptyNode}
>
{data ? <ReportSummarySection data={data} /> : null}
{data ? <ReportItemsSection items={data.items} /> : null}
</DetailPageShell>
);
}
/**
* 报表汇总区。
*/
function ReportSummarySection({
data,
}: {
data: NonNullable<ReturnType<typeof useAttendanceReport>["data"]>;
}): React.ReactElement {
const t = useTranslations("attendance");
const summary = data.summary;
return (
<DetailSection title={t("report.sectionSummary")}>
<DetailField
label={t("report.fieldTotal")}
value={String(summary.total)}
/>
<DetailField
label={t("report.fieldPresent")}
value={String(summary.present)}
/>
<DetailField
label={t("report.fieldAbsent")}
value={String(summary.absent)}
/>
<DetailField label={t("report.fieldLate")} value={String(summary.late)} />
<DetailField
label={t("report.fieldLeave")}
value={String(summary.leave)}
/>
<DetailField
label={t("report.fieldAttendanceRate")}
value={
<span
className={`font-medium ${attendanceRateToColorClass(summary.attendanceRate)}`}
>
{formatAttendanceRate(summary.attendanceRate)}
</span>
}
/>
</DetailSection>
);
}
/**
* 报表明细区(按学生聚合)。
*/
function ReportItemsSection({
items,
}: {
items: AttendanceReportItem[];
}): React.ReactElement {
const t = useTranslations("attendance");
return (
<DetailSection title={t("report.sectionItems")}>
<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("report.colStudentName")}
</th>
<th className="p-3 text-center font-medium">
{t("report.colPresent")}
</th>
<th className="p-3 text-center font-medium">
{t("report.colAbsent")}
</th>
<th className="p-3 text-center font-medium">
{t("report.colLate")}
</th>
<th className="p-3 text-center font-medium">
{t("report.colLeave")}
</th>
<th className="p-3 text-right font-medium">
{t("report.colAttendanceRate")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((item) => (
<tr key={item.studentId} className="hover:bg-muted/30">
<td className="p-3 font-medium">{item.studentName}</td>
<td className="p-3 text-center text-muted-foreground">
{item.present}
</td>
<td className="p-3 text-center text-muted-foreground">
{item.absent}
</td>
<td className="p-3 text-center text-muted-foreground">
{item.late}
</td>
<td className="p-3 text-center text-muted-foreground">
{item.leave}
</td>
<td
className={`p-3 text-right font-medium ${attendanceRateToColorClass(item.attendanceRate)}`}
>
{formatAttendanceRate(item.attendanceRate)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</DetailSection>
);
}

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

View File

@@ -0,0 +1,297 @@
"use client";
/**
* 考勤统计页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* 数据契约:
* - 统计查询 attendanceStats(...):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/classes_contract.md#attendance-stats
*
* 三态规范§11.3 DoD
* - loadingDetailPageSkeleton
* - errorerrorNode 局部降级
* - emptydata 为 null 时显示空态节点
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { BarChart3 } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import {
useAttendanceStats,
type AttendanceClassRankingItem,
type AttendanceStatusDistribution,
type AttendanceTrendItem,
} from "@/lib/api";
import { EmptyState } from "@/shared/components/ui/empty-state";
import {
DetailPageShell,
DetailPageSkeleton,
DetailSection,
DetailField,
} from "@/shared/components/page-templates";
import {
attendanceRateToBarClass,
attendanceRateToColorClass,
attendanceStatusToBadgeClass,
formatAttendanceDate,
formatAttendanceRate,
formatAttendanceStatus,
} from "@/features/teacher/attendance/transformations";
/**
* 统计客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function AttendanceStatsClient(): React.ReactElement {
const t = useTranslations("attendance");
const tCommon = useTranslations("common");
const searchParams = useSearchParams();
const classId = searchParams.get("classId") ?? undefined;
const startDate = searchParams.get("startDate") ?? undefined;
const endDate = searchParams.get("endDate") ?? undefined;
// @contract-pendingMSW 兜底
const { data, loading, error } = useAttendanceStats({
classId,
startDate,
endDate,
});
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("stats.mswNotice")}
</p>
</div>
) : undefined;
const emptyNode =
!loading && !error && !data ? (
<EmptyState
icon={BarChart3}
title={t("stats.notFound")}
action={{
label: t("stats.backToList"),
href: "/shell/teacher/attendance",
}}
/>
) : undefined;
return (
<DetailPageShell
title={t("stats.title")}
description={
data
? t("stats.subtitle", {
rate: formatAttendanceRate(data.overallAttendanceRate),
})
: undefined
}
icon={<BarChart3 className="size-6" />}
backHref="/shell/teacher/attendance"
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
emptyNode={emptyNode}
>
{data ? <StatsOverviewSection data={data} /> : null}
{data ? (
<StatsDistributionSection items={data.statusDistribution} />
) : null}
{data ? <StatsTrendSection items={data.trend} /> : null}
{data ? <StatsRankingSection items={data.classRanking} /> : null}
</DetailPageShell>
);
}
type StatsData = NonNullable<ReturnType<typeof useAttendanceStats>["data"]>;
/**
* 概览区。
*/
function StatsOverviewSection({
data,
}: {
data: StatsData;
}): React.ReactElement {
const t = useTranslations("attendance");
return (
<DetailSection title={t("stats.sectionOverview")}>
<DetailField
label={t("stats.fieldOverallRate")}
value={
<span
className={`font-medium ${attendanceRateToColorClass(data.overallAttendanceRate)}`}
>
{formatAttendanceRate(data.overallAttendanceRate)}
</span>
}
/>
<DetailField
label={t("stats.fieldTotalRecords")}
value={String(data.totalRecords)}
/>
</DetailSection>
);
}
/**
* 状态分布区。
*/
function StatsDistributionSection({
items,
}: {
items: AttendanceStatusDistribution[];
}): React.ReactElement {
const t = useTranslations("attendance");
return (
<DetailSection title={t("stats.sectionDistribution")}>
{items.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("stats.noDistribution")}
</p>
) : (
<div className="space-y-2">
{items.map((item) => (
<div
key={item.status}
className="flex items-center justify-between gap-4"
>
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${attendanceStatusToBadgeClass(item.status)}`}
>
{formatAttendanceStatus(item.status)}
</span>
<div className="flex flex-1 items-center gap-3">
<div className="h-2 flex-1 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${Math.round(item.ratio * 100)}%` }}
aria-hidden="true"
/>
</div>
<span className="w-20 text-right font-mono text-xs text-muted-foreground">
{item.count} · {formatAttendanceRate(item.ratio)}
</span>
</div>
</div>
))}
</div>
)}
</DetailSection>
);
}
/**
* 趋势区。
*/
function StatsTrendSection({
items,
}: {
items: AttendanceTrendItem[];
}): React.ReactElement {
const t = useTranslations("attendance");
if (items.length === 0) {
return (
<DetailSection title={t("stats.sectionTrend")}>
<p className="text-sm text-muted-foreground">{t("stats.noTrend")}</p>
</DetailSection>
);
}
const maxRate = Math.max(
...items.map((i) => Math.min(1, i.attendanceRate)),
0.01,
);
return (
<DetailSection title={t("stats.sectionTrend")}>
<div className="flex h-40 items-end gap-1">
{items.map((item) => {
const heightPct = Math.round(
(Math.min(1, item.attendanceRate) / maxRate) * 100,
);
return (
<div
key={item.date}
className="flex flex-1 flex-col items-center gap-1"
title={`${formatAttendanceDate(item.date)}: ${formatAttendanceRate(item.attendanceRate)}`}
>
<div
className={`w-full rounded-t ${attendanceRateToBarClass(item.attendanceRate)}`}
style={{ height: `${Math.max(2, heightPct)}%` }}
aria-label={`${formatAttendanceDate(item.date)} ${formatAttendanceRate(item.attendanceRate)}`}
/>
<span className="w-full truncate text-center text-xs text-muted-foreground">
{formatAttendanceDate(item.date).slice(5, 10)}
</span>
</div>
);
})}
</div>
</DetailSection>
);
}
/**
* 班级排名区。
*/
function StatsRankingSection({
items,
}: {
items: AttendanceClassRankingItem[];
}): React.ReactElement {
const t = useTranslations("attendance");
if (items.length === 0) {
return (
<DetailSection title={t("stats.sectionRanking")}>
<p className="text-sm text-muted-foreground">{t("stats.noRanking")}</p>
</DetailSection>
);
}
return (
<DetailSection title={t("stats.sectionRanking")}>
<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("stats.colRank")}
</th>
<th className="p-3 text-left font-medium">
{t("stats.colClassName")}
</th>
<th className="p-3 text-center font-medium">
{t("stats.colTotalStudents")}
</th>
<th className="p-3 text-right font-medium">
{t("stats.colAttendanceRate")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((item, idx) => (
<tr key={item.classId} className="hover:bg-muted/30">
<td className="p-3 font-mono text-xs text-muted-foreground">
{idx + 1}
</td>
<td className="p-3 font-medium">{item.className}</td>
<td className="p-3 text-center text-muted-foreground">
{item.totalStudents}
</td>
<td
className={`p-3 text-right font-medium ${attendanceRateToColorClass(item.attendanceRate)}`}
>
{formatAttendanceRate(item.attendanceRate)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</DetailSection>
);
}

View File

@@ -0,0 +1,184 @@
/**
* Attendance 数据变换工具ARCHITECTURE.md §11.3 DoD - 纯函数单测)
*
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
* 关联ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
*/
/** 考勤状态中文标签映射(对齐 ATTENDANCE_STATUS 枚举) */
export const ATTENDANCE_STATUS_LABEL: Record<string, string> = {
present: "出勤",
absent: "缺勤",
late: "迟到",
leave: "请假",
};
/**
* 将考勤状态映射为中文标签。未知状态回退为原始值。
*/
export function formatAttendanceStatus(status: string): string {
return ATTENDANCE_STATUS_LABEL[status] ?? status;
}
/**
* 根据考勤状态返回 Tailwind 徽章语义类名。
*/
export function attendanceStatusToBadgeClass(status: string): string {
switch (status) {
case "present":
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
case "absent":
return "bg-destructive/10 text-destructive";
case "late":
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
case "leave":
return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
default:
return "bg-muted text-muted-foreground";
}
}
/**
* 格式化 ISO 日期字符串为本地化展示zh-CN含年月日时分
* 输入无效时返回占位符。
*/
export function formatAttendanceDate(
isoDate: string | null | undefined,
): string {
if (!isoDate) return "--";
const d = new Date(isoDate);
if (Number.isNaN(d.getTime())) return "--";
return d.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* 格式化日期为纯日期展示zh-CN仅年月日
* 输入无效时返回占位符。
*/
export function formatAttendanceDay(
isoDate: string | null | undefined,
): string {
if (!isoDate) return "--";
const d = new Date(isoDate);
if (Number.isNaN(d.getTime())) return "--";
return d.toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}
/**
* 格式化出勤率为百分比字符串。
* 输入 0~1 的浮点数,返回 "85.0%" 形式;输入无效返回 "--"。
*/
export function formatAttendanceRate(rate: number | null | undefined): string {
if (rate == null || !Number.isFinite(rate)) return "--";
const pct = rate <= 1 ? rate * 100 : rate;
return `${pct.toFixed(1)}%`;
}
/**
* 根据出勤率返回 Tailwind 文本语义类名。
* - >= 0.95emerald优秀
* - >= 0.9primary达标
* - >= 0.8amber待改善
* - < 0.8destructive需关注
*/
export function attendanceRateToColorClass(
rate: number | null | undefined,
): string {
if (rate == null || !Number.isFinite(rate)) return "text-muted-foreground";
const r = rate <= 1 ? rate : rate / 100;
if (r >= 0.95) return "text-emerald-600";
if (r >= 0.9) return "text-primary";
if (r >= 0.8) return "text-amber-600";
return "text-destructive";
}
/**
* 根据出勤率返回 Tailwind 柱状图背景语义类名(静态映射,禁止动态拼接)。
* - >= 0.95emerald优秀
* - >= 0.9primary达标
* - >= 0.8amber待改善
* - < 0.8destructive需关注
*
* 关联project_rules §3.9 / §3.10 禁止字符串拼接动态类名
*/
export function attendanceRateToBarClass(
rate: number | null | undefined,
): string {
if (rate == null || !Number.isFinite(rate)) return "bg-muted";
const r = rate <= 1 ? rate : rate / 100;
if (r >= 0.95) return "bg-emerald-500/70";
if (r >= 0.9) return "bg-primary/70";
if (r >= 0.8) return "bg-amber-500/70";
return "bg-destructive/70";
}
/**
* 将星期几0=周日 … 6=周六)映射为中文标签。
*/
export function formatWeekday(weekday: number): string {
const labels = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
const idx = Math.trunc(weekday);
if (idx < 0 || idx > 6) return "--";
return labels[idx] ?? "--";
}
/**
* 格式化课表节次为展示字符串("第 1 节")。
* 输入无效返回 "--"。
*/
export function formatSchedulePeriod(period: number): string {
if (!Number.isFinite(period)) return "--";
return `${period}`;
}
/**
* 判断考勤状态是否为出勤present
*/
export function isAttendancePresent(status: string): boolean {
return status === "present";
}
/**
* 判断考勤状态是否为异常(缺勤/迟到)。
*/
export function isAttendanceAbnormal(status: string): boolean {
return status === "absent" || status === "late";
}
/**
* 统计给定状态列表中各状态的数量。
* 返回 { present, absent, late, leave } 计数对象。
*/
export function countAttendanceStatus(statuses: readonly string[]): {
present: number;
absent: number;
late: number;
leave: number;
} {
const counts = { present: 0, absent: 0, late: 0, leave: 0 };
for (const s of statuses) {
if (s in counts) {
counts[s as keyof typeof counts] += 1;
}
}
return counts;
}
/**
* 计算出勤率present / total
* total 为 0 时返回 0。
*/
export function computeAttendanceRate(present: number, total: number): number {
if (!Number.isFinite(total) || total <= 0) return 0;
return Math.min(1, present / total);
}

View File

@@ -0,0 +1,291 @@
/**
* Classes 数据变换工具单测ARCHITECTURE.md §11.3 DoD
*
* 关联ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
*/
import { describe, expect, it } from "vitest";
import type { ClassInfo, ClassScheduleItem } from "@/lib/api";
import {
formatClassDate,
formatSchedulePeriod,
formatScheduleTime,
formatStudentCount,
formatSubjectCount,
formatWeekday,
groupScheduleByWeekday,
hasDescription,
hasHeadTeacher,
sortScheduleByDay,
toClassListItem,
truncateClassName,
} from "../transformations";
const sampleClassInfo: ClassInfo = {
id: "cls-001",
name: "高一1班",
gradeId: "g-10",
headTeacherId: "usr-teacher-001",
description: "理科实验班",
createdAt: "2026-07-01T00:00:00Z",
updatedAt: "2026-07-10T00:00:00Z",
};
describe("toClassListItem", () => {
it("extracts list fields from ClassInfo", () => {
const item = toClassListItem(sampleClassInfo);
expect(item.id).toBe("cls-001");
expect(item.name).toBe("高一1班");
expect(item.gradeId).toBe("g-10");
expect(item.headTeacherId).toBe("usr-teacher-001");
expect(item.headTeacherName).toBeNull();
expect(item.studentCount).toBe(0);
expect(item.subjectCount).toBe(0);
expect(item).not.toHaveProperty("description", undefined);
});
it("preserves null headTeacherId", () => {
const cls: ClassInfo = { ...sampleClassInfo, headTeacherId: null };
const item = toClassListItem(cls);
expect(item.headTeacherId).toBeNull();
});
});
describe("formatClassDate", () => {
it("formats valid ISO date string", () => {
const result = formatClassDate("2026-07-22T10:30:00Z");
expect(result).toContain("2026");
expect(result).toContain("07");
});
it("returns placeholder for null/undefined/empty", () => {
expect(formatClassDate(null)).toBe("--");
expect(formatClassDate(undefined)).toBe("--");
expect(formatClassDate("")).toBe("--");
});
it("returns placeholder for invalid date", () => {
expect(formatClassDate("not-a-date")).toBe("--");
});
});
describe("truncateClassName", () => {
it("returns name unchanged when within limit", () => {
expect(truncateClassName("高一1班", 10)).toBe("高一1班");
});
it("truncates and appends ellipsis when over limit", () => {
const long = "a".repeat(50);
const result = truncateClassName(long, 40);
expect(result.endsWith("...")).toBe(true);
expect(result.length).toBe(43);
});
it("collapses whitespace", () => {
expect(truncateClassName("高一\n1 班", 40)).toBe("高一 1 班");
});
it("uses default maxLen of 40", () => {
const long = "b".repeat(50);
const result = truncateClassName(long);
expect(result.endsWith("...")).toBe(true);
});
});
describe("formatStudentCount", () => {
it("formats valid count", () => {
expect(formatStudentCount(0)).toBe("0 人");
expect(formatStudentCount(38)).toBe("38 人");
});
it("returns 0 人 for invalid input", () => {
expect(formatStudentCount(-1)).toBe("0 人");
expect(formatStudentCount(Number.NaN)).toBe("0 人");
expect(formatStudentCount(Number.POSITIVE_INFINITY)).toBe("0 人");
});
});
describe("formatSubjectCount", () => {
it("formats valid count", () => {
expect(formatSubjectCount(0)).toBe("0 科");
expect(formatSubjectCount(9)).toBe("9 科");
});
it("returns 0 科 for invalid input", () => {
expect(formatSubjectCount(-1)).toBe("0 科");
expect(formatSubjectCount(Number.NaN)).toBe("0 科");
});
});
describe("formatWeekday", () => {
it("maps 0-6 to Chinese weekday labels", () => {
expect(formatWeekday(0)).toBe("周日");
expect(formatWeekday(1)).toBe("周一");
expect(formatWeekday(6)).toBe("周六");
});
it("returns placeholder for out-of-range", () => {
expect(formatWeekday(-1)).toBe("--");
expect(formatWeekday(7)).toBe("--");
});
});
describe("formatSchedulePeriod", () => {
it("formats integer period", () => {
expect(formatSchedulePeriod(1)).toBe("第 1 节");
expect(formatSchedulePeriod(8)).toBe("第 8 节");
});
it("returns placeholder for non-finite", () => {
expect(formatSchedulePeriod(Number.NaN)).toBe("--");
});
});
describe("formatScheduleTime", () => {
it("formats time range", () => {
expect(formatScheduleTime("08:00", "08:45")).toBe("08:00-08:45");
});
it("returns placeholder for missing values", () => {
expect(formatScheduleTime(null, "08:45")).toBe("--");
expect(formatScheduleTime("08:00", null)).toBe("--");
expect(formatScheduleTime(undefined, undefined)).toBe("--");
});
});
describe("sortScheduleByDay", () => {
const items: ClassScheduleItem[] = [
{
id: "s2",
weekday: 2,
period: 2,
subjectId: "sub-math",
subjectName: "数学",
teacherId: "t1",
teacherName: "张老师",
classroom: "101",
startTime: "09:00",
endTime: "09:45",
},
{
id: "s1",
weekday: 1,
period: 1,
subjectId: "sub-chinese",
subjectName: "语文",
teacherId: "t2",
teacherName: "李老师",
classroom: "102",
startTime: "08:00",
endTime: "08:45",
},
{
id: "s3",
weekday: 1,
period: 2,
subjectId: "sub-english",
subjectName: "英语",
teacherId: "t3",
teacherName: "王老师",
classroom: "103",
startTime: "09:00",
endTime: "09:45",
},
];
it("sorts by weekday then period", () => {
const sorted = sortScheduleByDay(items);
expect(sorted[0]?.id).toBe("s1");
expect(sorted[1]?.id).toBe("s3");
expect(sorted[2]?.id).toBe("s2");
});
it("returns empty array for null/undefined", () => {
expect(sortScheduleByDay(null)).toEqual([]);
expect(sortScheduleByDay(undefined)).toEqual([]);
});
it("does not mutate input", () => {
const copy = [...items];
sortScheduleByDay(items);
expect(items.map((i) => i.id)).toEqual(copy.map((i) => i.id));
});
});
describe("groupScheduleByWeekday", () => {
const items: ClassScheduleItem[] = [
{
id: "s1",
weekday: 1,
period: 1,
subjectId: "sub-chinese",
subjectName: "语文",
teacherId: "t2",
teacherName: "李老师",
classroom: "102",
startTime: "08:00",
endTime: "08:45",
},
{
id: "s2",
weekday: 3,
period: 1,
subjectId: "sub-math",
subjectName: "数学",
teacherId: "t1",
teacherName: "张老师",
classroom: "101",
startTime: "08:00",
endTime: "08:45",
},
];
it("returns 7 groups", () => {
const groups = groupScheduleByWeekday(items);
expect(groups).toHaveLength(7);
});
it("groups items by weekday", () => {
const groups = groupScheduleByWeekday(items);
expect(groups[1]).toHaveLength(1);
expect(groups[1]?.[0]?.id).toBe("s1");
expect(groups[3]).toHaveLength(1);
expect(groups[3]?.[0]?.id).toBe("s2");
expect(groups[0]).toHaveLength(0);
});
it("returns 7 empty groups for null/undefined", () => {
const groups = groupScheduleByWeekday(null);
expect(groups).toHaveLength(7);
expect(groups.every((g) => g.length === 0)).toBe(true);
});
});
describe("hasHeadTeacher", () => {
it("returns true when headTeacherId is set", () => {
expect(hasHeadTeacher(sampleClassInfo)).toBe(true);
});
it("returns false when headTeacherId is null", () => {
expect(hasHeadTeacher({ ...sampleClassInfo, headTeacherId: null })).toBe(
false,
);
});
});
describe("hasDescription", () => {
it("returns true when description is set", () => {
expect(hasDescription(sampleClassInfo)).toBe(true);
});
it("returns false when description is null", () => {
expect(hasDescription({ ...sampleClassInfo, description: null })).toBe(
false,
);
});
it("returns false when description is empty string", () => {
expect(hasDescription({ ...sampleClassInfo, description: "" })).toBe(false);
});
});

View File

@@ -0,0 +1,336 @@
"use client";
/**
* 班级详情页 - 客户端组件ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2
*
* 数据契约(混合):
* - 单查 classInfo(id: ID!):✅ schema 真实字段classes 子图)
* - 学生名单 classStudents(classId):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 任课老师 classTeachers(classId):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/classes_contract.md#class-students
*
* 三态规范§11.3 DoD
* - loadingDetailPageSkeleton
* - errorerrorNode 局部降级
* - notFounddata 为 null 时显示空态节点
*
* 关联ARCHITECTURE.md §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { Users, ChevronLeft } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import {
useClassInfo,
useClassStudents,
useClassTeachers,
type ClassStudent,
type ClassTeacher,
} from "@/lib/api";
import { EmptyState } from "@/shared/components/ui/empty-state";
import {
DetailPageShell,
DetailPageSkeleton,
DetailSection,
DetailField,
} from "@/shared/components/page-templates";
import {
formatClassDate,
hasDescription,
hasHeadTeacher,
} from "@/features/teacher/classes/transformations";
import { formatStudentDate } from "@/features/teacher/students/transformations";
/**
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function ClassDetailClient(): React.ReactElement {
const t = useTranslations("classes");
const tCommon = useTranslations("common");
const params = useParams<{ id: string }>();
const classId = params?.id ?? "";
// ✅ 真实查询classInfo(id: ID!)schema 已就绪
const { data, loading, error } = useClassInfo(classId);
// @contract-pending学生名单schema 无 classStudents(classId) → MSW 兜底
const {
data: studentsData,
loading: studentsLoading,
error: studentsError,
} = useClassStudents(classId, { enabled: Boolean(data) });
// @contract-pending任课老师schema 无 classTeachers(classId) → MSW 兜底
const {
data: teachersData,
loading: teachersLoading,
error: teachersError,
} = useClassTeachers(classId, { enabled: Boolean(data) });
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>
</div>
) : undefined;
const emptyNode =
!loading && !error && !data ? (
<EmptyState
icon={Users}
title={t("detail.notFound")}
action={{
label: t("detail.backToList"),
href: "/shell/teacher/classes",
}}
/>
) : undefined;
return (
<DetailPageShell
title={data?.name ?? t("detail.title")}
description={
data
? t("detail.createdAtPrefix", {
date: formatClassDate(data.createdAt),
})
: undefined
}
icon={<Users className="size-6" />}
backHref="/shell/teacher/classes"
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
emptyNode={emptyNode}
>
{data ? <ClassDetailBody classInfo={data} /> : null}
{data ? (
<StudentsSection
students={studentsData?.items}
loading={studentsLoading}
error={studentsError}
mswNotice={t("detail.studentsMswNotice")}
/>
) : null}
{data ? (
<TeachersSection
teachers={teachersData?.items}
loading={teachersLoading}
error={teachersError}
mswNotice={t("detail.teachersMswNotice")}
/>
) : null}
</DetailPageShell>
);
}
type ClassInfoData = NonNullable<ReturnType<typeof useClassInfo>["data"]>;
/**
* 详情基本信息区(对齐 §7.3 详情页模板)。
*/
function ClassDetailBody({
classInfo,
}: {
classInfo: ClassInfoData;
}): React.ReactElement {
const t = useTranslations("classes");
return (
<DetailSection title={t("detail.sectionBasic")}>
<DetailField label={t("detail.fieldName")} value={classInfo.name} />
<DetailField label={t("detail.fieldGradeId")} value={classInfo.gradeId} />
<DetailField
label={t("detail.fieldHeadTeacherId")}
value={
hasHeadTeacher(classInfo)
? (classInfo.headTeacherId ?? "--")
: t("detail.noHeadTeacher")
}
/>
<DetailField
label={t("detail.fieldDescription")}
value={
hasDescription(classInfo)
? (classInfo.description ?? "--")
: t("detail.noDescription")
}
/>
<DetailField
label={t("detail.fieldCreatedAt")}
value={formatClassDate(classInfo.createdAt)}
/>
<DetailField
label={t("detail.fieldUpdatedAt")}
value={formatClassDate(classInfo.updatedAt)}
/>
</DetailSection>
);
}
/**
* 学生名单区(@contract-pendingMSW 兜底)。
*/
function StudentsSection({
students,
loading,
error,
mswNotice,
}: {
students: ClassStudent[] | undefined;
loading: boolean;
error: unknown;
mswNotice: string;
}): React.ReactElement {
const t = useTranslations("classes");
const tCommon = useTranslations("common");
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-4 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
<p className="mt-2 text-xs text-muted-foreground">{mswNotice}</p>
</div>
) : null;
return (
<DetailSection title={t("detail.sectionStudents")}>
{errorNode}
{!error && loading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-8 animate-pulse rounded bg-muted/50"
aria-hidden="true"
/>
))}
</div>
) : null}
{!error && !loading && (!students || students.length === 0) ? (
<Link
href="/shell/teacher/classes"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ChevronLeft className="size-4" />
{t("detail.noStudents")}
</Link>
) : null}
{!error && !loading && students && students.length > 0 ? (
<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("detail.colStudentNo")}
</th>
<th className="p-3 text-left font-medium">
{t("detail.colStudentName")}
</th>
<th className="p-3 text-left font-medium">
{t("detail.colEnrolledAt")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{students.map((s) => (
<tr key={s.id} className="hover:bg-muted/30">
<td className="p-3 font-mono text-xs text-muted-foreground">
{s.studentNo}
</td>
<td className="p-3 font-medium">{s.name}</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatStudentDate(s.enrolledAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</DetailSection>
);
}
/**
* 任课老师区(@contract-pendingMSW 兜底)。
*/
function TeachersSection({
teachers,
loading,
error,
mswNotice,
}: {
teachers: ClassTeacher[] | undefined;
loading: boolean;
error: unknown;
mswNotice: string;
}): React.ReactElement {
const t = useTranslations("classes");
const tCommon = useTranslations("common");
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-4 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
<p className="mt-2 text-xs text-muted-foreground">{mswNotice}</p>
</div>
) : null;
return (
<DetailSection title={t("detail.sectionTeachers")}>
{errorNode}
{!error && loading ? (
<div className="space-y-2">
{[1, 2].map((i) => (
<div
key={i}
className="h-8 animate-pulse rounded bg-muted/50"
aria-hidden="true"
/>
))}
</div>
) : null}
{!error && !loading && (!teachers || teachers.length === 0) ? (
<p className="text-sm text-muted-foreground">
{t("detail.noTeachers")}
</p>
) : null}
{!error && !loading && teachers && teachers.length > 0 ? (
<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("detail.colTeacherName")}
</th>
<th className="p-3 text-left font-medium">
{t("detail.colSubjectName")}
</th>
<th className="p-3 text-left font-medium">
{t("detail.colTeacherRole")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{teachers.map((tc) => (
<tr key={tc.id} className="hover:bg-muted/30">
<td className="p-3 font-medium">{tc.name}</td>
<td className="p-3 text-muted-foreground">
{tc.subjectName}
</td>
<td className="p-3 text-xs text-muted-foreground">
{tc.role}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</DetailSection>
);
}

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

View File

@@ -0,0 +1,224 @@
"use client";
/**
* 班级管理列表页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* 数据契约:
* - 列表查询 classes(...):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 单查 classInfo(id):✅ 真实字段(本页未使用,详情页使用)
* - 契约工单docs/architecture/issues/contracts/classes_contract.md#classes-list
*
* URL 状态:?gradeId=&subjectId=&q=
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState + 行动按钮)
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { Users } from "lucide-react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useTranslations } from "next-intl";
import { useClasses, type ClassListItem } from "@/lib/api";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import {
formatClassDate,
formatStudentCount,
formatSubjectCount,
truncateClassName,
} from "@/features/teacher/classes/transformations";
/**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
* useSearchParams 要求 Suspense 边界Next.js 15 强制)。
*/
export function ClassesListClient(): React.ReactElement {
const t = useTranslations("classes");
const tCommon = useTranslations("common");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const gradeId = searchParams.get("gradeId") ?? "";
const subjectId = searchParams.get("subjectId") ?? "";
const q = searchParams.get("q") ?? "";
// @contract-pendingMSW 兜底
const { data, loading, error } = useClasses({
gradeId: gradeId || undefined,
subjectId: subjectId || undefined,
q: q || undefined,
});
const filteredItems = useMemo<ClassListItem[]>(() => {
const items = data?.items ?? [];
return items;
}, [data]);
const updateQuery = (key: string, value: string): void => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
startTransition(() => {
router.push(`/shell/teacher/classes?${params.toString()}`);
});
};
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("list.mswNotice")}
</p>
</div>
) : undefined;
const emptyNode = (
<EmptyState
icon={Users}
title={t("list.emptyTitle")}
description={t("list.emptyDescription")}
action={{
label: t("list.emptyAction"),
href: "/shell/teacher/classes",
}}
/>
);
return (
<ListPageShell
title={t("list.title")}
description={t("list.description")}
icon={<Users className="size-6" />}
filters={
<>
<FilterSearchInput
placeholder={t("list.searchPlaceholder")}
value={q}
onChange={(v) => updateQuery("q", v)}
/>
<input
type="text"
value={gradeId}
onChange={(e) => updateQuery("gradeId", e.target.value)}
placeholder={t("list.gradePlaceholder")}
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("list.gradePlaceholder")}
/>
<input
type="text"
value={subjectId}
onChange={(e) => updateQuery("subjectId", e.target.value)}
placeholder={t("list.subjectPlaceholder")}
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("list.subjectPlaceholder")}
/>
</>
}
loading={loading}
loadingNode={<ListPageSkeleton rows={5} />}
empty={filteredItems.length === 0 && !loading}
emptyNode={emptyNode}
errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("list.total", { count: filteredItems.length })}</span>
</div>
}
>
<ClassesTable items={filteredItems} />
</ListPageShell>
);
}
/**
* 班级列表表格(纯展示组件,对齐 §8.2 排版规范)。
*/
function ClassesTable({
items,
}: {
items: ClassListItem[];
}): React.ReactElement {
const t = useTranslations("classes");
return (
<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("list.colName")}</th>
<th className="p-3 text-left font-medium">{t("list.colGrade")}</th>
<th className="p-3 text-left font-medium">
{t("list.colHeadTeacher")}
</th>
<th className="p-3 text-center font-medium">
{t("list.colStudentCount")}
</th>
<th className="p-3 text-center font-medium">
{t("list.colSubjectCount")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colDescription")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colUpdatedAt")}
</th>
<th className="p-3 text-right font-medium">
{t("list.colActions")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((cls) => (
<tr key={cls.id} className="hover:bg-muted/30">
<td className="max-w-xs p-3">
<Link
href={`/shell/teacher/classes/${cls.id}`}
className="font-medium hover:underline"
>
{truncateClassName(cls.name)}
</Link>
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{cls.gradeId}
</td>
<td className="p-3 text-xs text-muted-foreground">
{cls.headTeacherName ?? "--"}
</td>
<td className="p-3 text-center text-xs text-muted-foreground">
{formatStudentCount(cls.studentCount)}
</td>
<td className="p-3 text-center text-xs text-muted-foreground">
{formatSubjectCount(cls.subjectCount)}
</td>
<td className="max-w-xs p-3 text-xs text-muted-foreground">
{cls.description ?? "--"}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatClassDate(cls.updatedAt)}
</td>
<td className="p-3 text-right">
<Link
href={`/shell/teacher/classes/${cls.id}`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t("list.viewDetail")}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,153 @@
/**
* Classes 数据变换工具ARCHITECTURE.md §11.3 DoD - 纯函数单测)
*
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
* 关联ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
*/
import type { ClassInfo, ClassListItem, ClassScheduleItem } from "@/lib/api";
/**
* 将班级详情schema ClassInfo映射为列表项视图模型。
*
* schema ClassInfo 无 studentCount/subjectCount/headTeacherName 字段,
* 列表项中的这些字段在列表查询MSW中提供详情→列表裁剪时置 0/null。
*/
export function toClassListItem(cls: ClassInfo): ClassListItem {
return {
id: cls.id,
name: cls.name,
gradeId: cls.gradeId,
headTeacherId: cls.headTeacherId,
headTeacherName: null,
description: cls.description,
studentCount: 0,
subjectCount: 0,
createdAt: cls.createdAt,
updatedAt: cls.updatedAt,
};
}
/**
* 格式化 ISO 日期字符串为本地化展示zh-CN含年月日时分
* 输入无效时返回占位符。
*/
export function formatClassDate(isoDate: string | null | undefined): string {
if (!isoDate) return "--";
const d = new Date(isoDate);
if (Number.isNaN(d.getTime())) return "--";
return d.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* 截断班级名称用于列表展示。
* - 超过 maxLen 字符时截断并加省略号
* - maxLen 默认 40
*/
export function truncateClassName(name: string, maxLen = 40): string {
const text = name.replace(/\s+/g, " ").trim();
if (text.length <= maxLen) return text;
return `${text.slice(0, maxLen)}...`;
}
/**
* 格式化学生数为展示字符串。
* 输入无效返回 "0 人"。
*/
export function formatStudentCount(count: number): string {
if (!Number.isFinite(count) || count < 0) return "0 人";
return `${count}`;
}
/**
* 格式化科目数为展示字符串。
* 输入无效返回 "0 科"。
*/
export function formatSubjectCount(count: number): string {
if (!Number.isFinite(count) || count < 0) return "0 科";
return `${count}`;
}
/**
* 将星期几0=周日 … 6=周六)映射为中文标签。
*/
export function formatWeekday(weekday: number): string {
const labels = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
const idx = Math.trunc(weekday);
if (idx < 0 || idx > 6) return "--";
return labels[idx] ?? "--";
}
/**
* 格式化节次为展示字符串("第 1 节")。
* 输入无效返回 "--"。
*/
export function formatSchedulePeriod(period: number): string {
if (!Number.isFinite(period)) return "--";
return `${period}`;
}
/**
* 格式化时间段为展示字符串("08:00-08:45")。
* 输入无效返回 "--"。
*/
export function formatScheduleTime(
startTime: string | null | undefined,
endTime: string | null | undefined,
): string {
if (!startTime || !endTime) return "--";
return `${startTime}-${endTime}`;
}
/**
* 按星期 + 节次升序排序课表条目(稳定排序)。
* 输入为 null/undefined 时返回空数组。
*/
export function sortScheduleByDay(
items: ClassScheduleItem[] | null | undefined,
): ClassScheduleItem[] {
if (!items) return [];
return [...items].sort((a, b) => {
if (a.weekday !== b.weekday) return a.weekday - b.weekday;
return a.period - b.period;
});
}
/**
* 按星期分组课表条目,返回 7 组(周一至周日)。
* 输入为 null/undefined 时返回 7 个空数组。
*/
export function groupScheduleByWeekday(
items: ClassScheduleItem[] | null | undefined,
): ClassScheduleItem[][] {
const groups: ClassScheduleItem[][] = [[], [], [], [], [], [], []];
if (!items) return groups;
for (const item of items) {
const idx = Math.trunc(item.weekday);
if (idx >= 0 && idx <= 6) {
const group = groups[idx];
if (group) group.push(item);
}
}
return groups;
}
/**
* 判断班级是否有班主任headTeacherId 非空)。
*/
export function hasHeadTeacher(cls: ClassInfo): boolean {
return Boolean(cls.headTeacherId);
}
/**
* 判断班级是否有描述。
*/
export function hasDescription(cls: ClassInfo): boolean {
return Boolean(cls.description);
}

View File

@@ -0,0 +1,171 @@
/**
* Students 数据变换工具单测ARCHITECTURE.md §11.3 DoD
*
* 关联ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
*/
import { describe, expect, it } from "vitest";
import type { Student } from "@/lib/api";
import {
GENDER_LABEL,
formatGender,
formatStudentCount,
formatStudentDate,
formatStudentDay,
genderToBadgeClass,
hasStudentNo,
toStudentListItem,
truncateStudentName,
} from "../transformations";
const sampleStudent: Student = {
id: "stu-001",
studentNo: "2026001",
name: "张明",
gender: "male",
classId: "cls-001",
className: "高一1班",
gradeId: "g-10",
enrolledAt: "2026-09-01T00:00:00Z",
};
describe("formatGender", () => {
it("maps known genders to Chinese labels", () => {
expect(formatGender("male")).toBe("男");
expect(formatGender("female")).toBe("女");
expect(formatGender("unknown")).toBe("未知");
});
it("returns original value for unknown gender", () => {
expect(formatGender("other")).toBe("other");
expect(formatGender("")).toBe("");
});
it("GENDER_LABEL covers 3 standard genders", () => {
expect(Object.keys(GENDER_LABEL)).toHaveLength(3);
});
});
describe("formatStudentDate", () => {
it("formats valid ISO date string with time", () => {
const result = formatStudentDate("2026-07-22T10:30:00Z");
expect(result).toContain("2026");
expect(result).toContain("07");
});
it("returns placeholder for null/undefined/empty", () => {
expect(formatStudentDate(null)).toBe("--");
expect(formatStudentDate(undefined)).toBe("--");
expect(formatStudentDate("")).toBe("--");
});
it("returns placeholder for invalid date", () => {
expect(formatStudentDate("not-a-date")).toBe("--");
});
});
describe("formatStudentDay", () => {
it("formats valid ISO date string as date only", () => {
const result = formatStudentDay("2026-09-01T00:00:00Z");
expect(result).toContain("2026");
expect(result).toContain("09");
});
it("returns placeholder for null/undefined/empty", () => {
expect(formatStudentDay(null)).toBe("--");
expect(formatStudentDay(undefined)).toBe("--");
expect(formatStudentDay("")).toBe("--");
});
});
describe("truncateStudentName", () => {
it("returns name unchanged when within limit", () => {
expect(truncateStudentName("张明", 30)).toBe("张明");
});
it("truncates and appends ellipsis when over limit", () => {
const long = "a".repeat(40);
const result = truncateStudentName(long, 30);
expect(result.endsWith("...")).toBe(true);
expect(result.length).toBe(33);
});
it("collapses whitespace", () => {
expect(truncateStudentName("张\n明 三", 30)).toBe("张 明 三");
});
it("uses default maxLen of 30", () => {
const long = "b".repeat(40);
const result = truncateStudentName(long);
expect(result.endsWith("...")).toBe(true);
});
});
describe("toStudentListItem", () => {
it("extracts all fields from student", () => {
const item = toStudentListItem(sampleStudent);
expect(item.id).toBe("stu-001");
expect(item.studentNo).toBe("2026001");
expect(item.name).toBe("张明");
expect(item.gender).toBe("male");
expect(item.classId).toBe("cls-001");
expect(item.className).toBe("高一1班");
expect(item.gradeId).toBe("g-10");
expect(item.enrolledAt).toBe("2026-09-01T00:00:00Z");
});
it("returns isomorphic shape", () => {
const item = toStudentListItem(sampleStudent);
expect(Object.keys(item).sort()).toEqual(Object.keys(sampleStudent).sort());
});
});
describe("genderToBadgeClass", () => {
it("returns blue class for male", () => {
expect(genderToBadgeClass("male")).toContain("blue");
});
it("returns pink class for female", () => {
expect(genderToBadgeClass("female")).toContain("pink");
});
it("returns muted for unknown", () => {
expect(genderToBadgeClass("unknown")).toBe(
"bg-muted text-muted-foreground",
);
});
it("returns muted for unrecognized gender", () => {
expect(genderToBadgeClass("other")).toBe("bg-muted text-muted-foreground");
});
});
describe("hasStudentNo", () => {
it("returns true for non-empty string", () => {
expect(hasStudentNo("2026001")).toBe(true);
});
it("returns false for null/undefined", () => {
expect(hasStudentNo(null)).toBe(false);
expect(hasStudentNo(undefined)).toBe(false);
});
it("returns false for empty/whitespace string", () => {
expect(hasStudentNo("")).toBe(false);
expect(hasStudentNo(" ")).toBe(false);
});
});
describe("formatStudentCount", () => {
it("formats valid count", () => {
expect(formatStudentCount(0)).toBe("0 人");
expect(formatStudentCount(38)).toBe("38 人");
});
it("returns 0 人 for invalid input", () => {
expect(formatStudentCount(-1)).toBe("0 人");
expect(formatStudentCount(Number.NaN)).toBe("0 人");
expect(formatStudentCount(Number.POSITIVE_INFINITY)).toBe("0 人");
});
});

View File

@@ -0,0 +1,211 @@
"use client";
/**
* 学生管理列表页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2
*
* 数据契约:
* - 列表查询 students(...):❌ schema 无此字段 → MSW 兜底(@contract-pending
* - 契约工单docs/architecture/issues/contracts/classes_contract.md#students
*
* URL 状态:?classId=&gradeId=&q=
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState + 行动按钮)
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
*/
import { GraduationCap } from "lucide-react";
import { useSearchParams, useRouter } from "next/navigation";
import { useMemo, useTransition } from "react";
import { useTranslations } from "next-intl";
import { useStudents, type StudentListItem } from "@/lib/api";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import {
formatGender,
formatStudentDay,
genderToBadgeClass,
truncateStudentName,
} from "@/features/teacher/students/transformations";
/**
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
* useSearchParams 要求 Suspense 边界Next.js 15 强制)。
*/
export function StudentsListClient(): React.ReactElement {
const t = useTranslations("students");
const tCommon = useTranslations("common");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const classId = searchParams.get("classId") ?? "";
const gradeId = searchParams.get("gradeId") ?? "";
const q = searchParams.get("q") ?? "";
// @contract-pendingMSW 兜底
const { data, loading, error } = useStudents({
classId: classId || undefined,
gradeId: gradeId || undefined,
q: q || undefined,
});
const filteredItems = useMemo<StudentListItem[]>(() => {
const items = data?.items ?? [];
return items;
}, [data]);
const updateQuery = (key: string, value: string): void => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
startTransition(() => {
router.push(`/shell/teacher/students?${params.toString()}`);
});
};
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("list.mswNotice")}
</p>
</div>
) : undefined;
const emptyNode = (
<EmptyState
icon={GraduationCap}
title={t("list.emptyTitle")}
description={t("list.emptyDescription")}
action={{
label: t("list.emptyAction"),
href: "/shell/teacher/students",
}}
/>
);
return (
<ListPageShell
title={t("list.title")}
description={t("list.description")}
icon={<GraduationCap className="size-6" />}
filters={
<>
<FilterSearchInput
placeholder={t("list.searchPlaceholder")}
value={q}
onChange={(v) => updateQuery("q", v)}
/>
<input
type="text"
value={classId}
onChange={(e) => updateQuery("classId", e.target.value)}
placeholder={t("list.classPlaceholder")}
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("list.classPlaceholder")}
/>
<input
type="text"
value={gradeId}
onChange={(e) => updateQuery("gradeId", e.target.value)}
placeholder={t("list.gradePlaceholder")}
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("list.gradePlaceholder")}
/>
</>
}
loading={loading}
loadingNode={<ListPageSkeleton rows={5} />}
empty={filteredItems.length === 0 && !loading}
emptyNode={emptyNode}
errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("list.total", { count: filteredItems.length })}</span>
</div>
}
>
<StudentsTable items={filteredItems} />
</ListPageShell>
);
}
/**
* 学生列表表格(纯展示组件,对齐 §8.2 排版规范)。
*/
function StudentsTable({
items,
}: {
items: StudentListItem[];
}): React.ReactElement {
const t = useTranslations("students");
return (
<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("list.colStudentNo")}
</th>
<th className="p-3 text-left font-medium">{t("list.colName")}</th>
<th className="p-3 text-left font-medium">{t("list.colGender")}</th>
<th className="p-3 text-left font-medium">
{t("list.colClassName")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colGradeId")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colEnrolledAt")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((s) => (
<tr key={s.id} className="hover:bg-muted/30">
<td className="p-3 font-mono text-xs text-muted-foreground">
{s.studentNo}
</td>
<td className="p-3 font-medium">{truncateStudentName(s.name)}</td>
<td className="p-3">
<GenderBadge gender={s.gender} />
</td>
<td className="p-3 text-muted-foreground">{s.className}</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{s.gradeId}
</td>
<td className="p-3 font-mono text-xs text-muted-foreground">
{formatStudentDay(s.enrolledAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/**
* 性别徽章(按性别色阶展示)。
*/
function GenderBadge({ gender }: { gender: string }): React.ReactElement {
const label = formatGender(gender);
const cls = genderToBadgeClass(gender);
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label}
</span>
);
}

View File

@@ -0,0 +1,114 @@
/**
* Students 数据变换工具ARCHITECTURE.md §11.3 DoD - 纯函数单测)
*
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
* 关联ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
*/
import type { Student, StudentListItem } from "@/lib/api";
/** 性别中文标签映射 */
export const GENDER_LABEL: Record<string, string> = {
male: "男",
female: "女",
unknown: "未知",
};
/**
* 将性别代码映射为中文标签。未知值回退为原始值。
*/
export function formatGender(gender: string): string {
return GENDER_LABEL[gender] ?? gender;
}
/**
* 格式化 ISO 日期字符串为本地化展示zh-CN含年月日时分
* 输入无效时返回占位符。
*/
export function formatStudentDate(isoDate: string | null | undefined): string {
if (!isoDate) return "--";
const d = new Date(isoDate);
if (Number.isNaN(d.getTime())) return "--";
return d.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* 格式化日期为纯日期展示zh-CN仅年月日。用于入学日期等。
* 输入无效时返回占位符。
*/
export function formatStudentDay(isoDate: string | null | undefined): string {
if (!isoDate) return "--";
const d = new Date(isoDate);
if (Number.isNaN(d.getTime())) return "--";
return d.toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}
/**
* 截断学生姓名用于列表展示(通常无需截断,保留以对齐模块模式)。
* - 超过 maxLen 字符时截断并加省略号
* - maxLen 默认 30
*/
export function truncateStudentName(name: string, maxLen = 30): string {
const text = name.replace(/\s+/g, " ").trim();
if (text.length <= maxLen) return text;
return `${text.slice(0, maxLen)}...`;
}
/**
* 从学生实体中提取列表项视图模型StudentListItem 与 Student 同构)。
* 保留为显式函数以对齐 questions/textbooks 模式,便于未来字段裁剪。
*/
export function toStudentListItem(student: Student): StudentListItem {
return {
id: student.id,
studentNo: student.studentNo,
name: student.name,
gender: student.gender,
classId: student.classId,
className: student.className,
gradeId: student.gradeId,
enrolledAt: student.enrolledAt,
};
}
/**
* 根据性别返回 Tailwind 徽章语义类名。
*/
export function genderToBadgeClass(gender: string): string {
switch (gender) {
case "male":
return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
case "female":
return "bg-pink-500/10 text-pink-600 dark:text-pink-400";
case "unknown":
return "bg-muted text-muted-foreground";
default:
return "bg-muted text-muted-foreground";
}
}
/**
* 判断学号是否有效(非空且非空白)。
*/
export function hasStudentNo(studentNo: string | null | undefined): boolean {
return Boolean(studentNo && studentNo.trim().length > 0);
}
/**
* 格式化学生数为展示字符串。
* 输入无效返回 "0 人"。
*/
export function formatStudentCount(count: number): string {
if (!Number.isFinite(count) || count < 0) return "0 人";
return `${count}`;
}

View File

@@ -0,0 +1,384 @@
"use client";
/**
* Attendance domain APIARCHITECTURE.md §5.1 / §5.3 / §9.1 教师域考勤模块)
*
* 全部操作(@contract-pending
* 1. useAttendanceRecords列表查询❌ schema 无 attendanceRecords(...) → MSW 兜底
* 2. useAttendanceSheet点名表查询❌ schema 无 attendanceSheet(...) → MSW 兜底
* 3. useAttendanceReport报表查询❌ schema 无 attendanceReport(...) → MSW 兜底
* 4. useAttendanceStats统计查询❌ schema 无 attendanceStats(...) → MSW 兜底
* 5. useSaveAttendanceSheetmutation❌ schema 无 Mutation → MSW 兜底
*
* 契约工单docs/architecture/issues/contracts/classes_contract.md#attendance
* 后端补齐后:重跑 normalize + codegen → 关闭 skipDocumentsValidation → 切换 fetcher → 删 mock
*
* 关联ARCHITECTURE.md §5.3 契约纪律 / §5.4 MSW 兜底 / §9.1 / §11.4 契约工单
*/
import type { FetchPolicy } from "@apollo/client";
import { useWidgetMutation } from "../useWidgetMutation";
import { useWidgetQuery } from "../useWidgetQuery";
import { ApiError } from "./errors";
import {
GET_ATTENDANCE_RECORDS_DOC,
GET_ATTENDANCE_REPORT_DOC,
GET_ATTENDANCE_SHEET_DOC,
GET_ATTENDANCE_STATS_DOC,
SAVE_ATTENDANCE_SHEET_DOC,
} from "./operations/attendance.graphql";
import type { UseQueryResult } from "./types";
// ===== 数据类型 =====
/** 考勤状态枚举(对齐业务约定) */
export const ATTENDANCE_STATUS = {
PRESENT: "present",
ABSENT: "absent",
LATE: "late",
LEAVE: "leave",
} as const;
/** 考勤记录实体(列表项,@contract-pendingMSW 提供形状) */
export interface AttendanceRecord {
id: string;
studentId: string;
studentName: string;
classId: string;
className: string;
date: string;
status: string;
remark: string | null;
recordedBy: string;
createdAt: string;
updatedAt: string;
}
/** 点名表条目(单个学生当日出勤) */
export interface AttendanceSheetEntry {
studentId: string;
studentName: string;
status: string;
remark: string | null;
}
/** 点名表整体(某班级某日全部学生出勤) */
export interface AttendanceSheet {
classId: string;
className: string;
date: string;
entries: AttendanceSheetEntry[];
}
/** 考勤报表汇总 */
export interface AttendanceReportSummary {
total: number;
present: number;
absent: number;
late: number;
leave: number;
attendanceRate: number;
}
/** 考勤报表行项(按学生聚合) */
export interface AttendanceReportItem {
studentId: string;
studentName: string;
present: number;
absent: number;
late: number;
leave: number;
attendanceRate: number;
}
/** 考勤报表整体 */
export interface AttendanceReport {
classId: string;
className: string;
range: string;
summary: AttendanceReportSummary;
items: AttendanceReportItem[];
}
/** 状态分布项 */
export interface AttendanceStatusDistribution {
status: string;
count: number;
ratio: number;
}
/** 趋势项 */
export interface AttendanceTrendItem {
date: string;
attendanceRate: number;
}
/** 班级排名项 */
export interface AttendanceClassRankingItem {
classId: string;
className: string;
attendanceRate: number;
totalStudents: number;
}
/** 考勤统计整体 */
export interface AttendanceStats {
classId: string | null;
overallAttendanceRate: number;
totalRecords: number;
statusDistribution: AttendanceStatusDistribution[];
trend: AttendanceTrendItem[];
classRanking: AttendanceClassRankingItem[];
}
// ===== 响应类型(@contract-pending 假契约形状MSW 返回此结构) =====
interface AttendanceRecordsResponse {
attendanceRecords: {
items: AttendanceRecord[];
total: number;
};
}
interface AttendanceSheetResponse {
attendanceSheet: AttendanceSheet | null;
}
interface AttendanceReportResponse {
attendanceReport: AttendanceReport | null;
}
interface AttendanceStatsResponse {
attendanceStats: AttendanceStats | null;
}
interface SaveAttendanceSheetResponse {
saveAttendanceSheet: {
classId: string;
date: string;
savedCount: number;
} | null;
}
// ===== 筛选与输入类型 =====
export interface AttendanceRecordsFilter {
classId?: string;
date?: string;
status?: string;
q?: string;
limit?: number;
offset?: number;
}
export interface AttendanceStatsFilter {
classId?: string;
startDate?: string;
endDate?: string;
}
/** 点名表保存输入条目 */
export interface AttendanceEntryInput {
studentId: string;
status: string;
remark?: string;
}
export interface SaveAttendanceSheetInput {
classId: string;
date: string;
entries: AttendanceEntryInput[];
}
// ===== 查询选项 =====
export interface AttendanceQueryOptions {
enabled?: boolean;
pollInterval?: number;
fetchPolicy?: FetchPolicy;
}
// ===== Hooks =====
/**
* 查询考勤记录列表(@contract-pendingMSW 兜底)。
*
* schema 无 attendanceRecords(...) 根字段,由 MSW handlers 返回 mock 数据。
* 后端补齐列表查询后切换到真实 fetcher页面无需改动。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 列表页 / §11.4 契约工单
*/
export function useAttendanceRecords(
filter: AttendanceRecordsFilter,
options?: AttendanceQueryOptions,
): UseQueryResult<{ items: AttendanceRecord[]; total: number }> {
const result = useWidgetQuery<
AttendanceRecordsResponse,
{
classId?: string;
date?: string;
status?: string;
q?: string;
limit?: number;
offset?: number;
}
>(
GET_ATTENDANCE_RECORDS_DOC,
{
classId: filter.classId,
date: filter.date,
status: filter.status,
q: filter.q,
limit: filter.limit,
offset: filter.offset,
},
{
enabled: options?.enabled ?? true,
fetchPolicy: options?.fetchPolicy,
pollInterval: options?.pollInterval,
},
);
return {
data: result.data?.attendanceRecords,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询某班级某日点名表(@contract-pendingMSW 兜底)。
*
* schema 无 attendanceSheet(classId, date) 根字段,由 MSW handlers 返回 mock 数据。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 点名表页 / §11.4 契约工单
*/
export function useAttendanceSheet(
classId: string,
date: string,
options?: AttendanceQueryOptions,
): UseQueryResult<AttendanceSheet | null> {
const result = useWidgetQuery<
AttendanceSheetResponse,
{ classId: string; date: string }
>(
GET_ATTENDANCE_SHEET_DOC,
{ classId, date },
{
...options,
enabled: options?.enabled ?? (classId.length > 0 && date.length > 0),
},
);
return {
data: result.data?.attendanceSheet ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询某班级考勤报表(@contract-pendingMSW 兜底)。
*
* schema 无 attendanceReport(classId, range) 根字段,由 MSW handlers 返回 mock 数据。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 报表页 / §11.4 契约工单
*/
export function useAttendanceReport(
classId: string,
startDate?: string,
endDate?: string,
options?: AttendanceQueryOptions,
): UseQueryResult<AttendanceReport | null> {
const result = useWidgetQuery<
AttendanceReportResponse,
{ classId: string; startDate?: string; endDate?: string }
>(
GET_ATTENDANCE_REPORT_DOC,
{ classId, startDate, endDate },
{
...options,
enabled: options?.enabled ?? classId.length > 0,
},
);
return {
data: result.data?.attendanceReport ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询考勤统计(@contract-pendingMSW 兜底)。
*
* schema 无 attendanceStats(...) 根字段,由 MSW handlers 返回 mock 数据。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 统计页 / §11.4 契约工单
*/
export function useAttendanceStats(
filter: AttendanceStatsFilter,
options?: AttendanceQueryOptions,
): UseQueryResult<AttendanceStats | null> {
const result = useWidgetQuery<
AttendanceStatsResponse,
{ classId?: string; startDate?: string; endDate?: string }
>(
GET_ATTENDANCE_STATS_DOC,
{
classId: filter.classId,
startDate: filter.startDate,
endDate: filter.endDate,
},
{
enabled: options?.enabled ?? true,
fetchPolicy: options?.fetchPolicy,
pollInterval: options?.pollInterval,
},
);
return {
data: result.data?.attendanceStats ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 保存点名表 mutation@contract-pendingMSW 兜底)。
*
* schema 无 Mutation 类型,由 MSW handlers 返回 mock 数据。
* 后端补齐 mutation 后切换到真实 fetcher。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 / §11.4 契约工单
*/
export function useSaveAttendanceSheet(): {
run: (input: SaveAttendanceSheetInput) => Promise<{
classId: string;
date: string;
savedCount: number;
}>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<
SaveAttendanceSheetResponse,
{ input: SaveAttendanceSheetInput }
>(SAVE_ATTENDANCE_SHEET_DOC);
const run = async (
input: SaveAttendanceSheetInput,
): Promise<{ classId: string; date: string; savedCount: number }> => {
const data = await rawRun({ input });
if (!data?.saveAttendanceSheet) {
throw new ApiError("Failed to save attendance sheet", "INTERNAL_ERROR");
}
return data.saveAttendanceSheet;
};
return { run, loading, error };
}

View File

@@ -0,0 +1,320 @@
"use client";
/**
* Classes domain APIARCHITECTURE.md §5.1 / §5.3 / §9.1 教师域班级模块)
*
* 混合契约:
* 1. useClassInfo按 id 单查):✅ 真实查询 classInfo(id: ID!)schema 已就绪
* 2. useClasses列表查询❌ schema 无 classes(...) 根字段 → MSW 兜底(@contract-pending
* 3. useClassSchedule课表❌ schema 无 classSchedule(classId) → MSW 兜底(@contract-pending
* 4. useClassStudents学生名单❌ schema 无 classStudents(classId) → MSW 兜底(@contract-pending
* 5. useClassTeachers任课老师❌ schema 无 classTeachers(classId) → MSW 兜底(@contract-pending
*
* 契约工单docs/architecture/issues/contracts/classes_contract.md
* 后端补齐后:重跑 normalize + codegen → 关闭 skipDocumentsValidation → 切换 fetcher → 删 mock
*
* 关联ARCHITECTURE.md §5.3 契约纪律 / §5.4 MSW 兜底 / §5.5 后端已就绪查询 / §9.1 / §11.4 契约工单
*/
import type { FetchPolicy } from "@apollo/client";
import { useWidgetQuery } from "../useWidgetQuery";
import {
GET_CLASS_INFO_DOC,
GET_CLASS_SCHEDULE_DOC,
GET_CLASS_STUDENTS_DOC,
GET_CLASS_TEACHERS_DOC,
GET_CLASSES_DOC,
} from "./operations/classes.graphql";
import type { UseQueryResult } from "./types";
// ===== 数据类型(对齐 schema ClassInfo 类型)=====
/**
* 班级实体(对齐 combined-schema.graphql ClassInfo 类型classes 子图)
*
* 字段命名 camelCase与 schema 一致)。
* schema ClassInfo: id / name / gradeId / headTeacherId / description / createdAt / updatedAt
*/
export interface ClassInfo {
id: string;
name: string;
gradeId: string;
headTeacherId: string | null;
description: string | null;
createdAt: string;
updatedAt: string;
}
/**
* 班级列表项(@contract-pending 扩展字段MSW 提供)
*
* schema ClassInfo 无 headTeacherName/studentCount/subjectCount 字段,
* 列表项中的这些字段由 MSW mock 扩展提供,后端补齐列表契约时同步对齐。
*/
export interface ClassListItem {
id: string;
name: string;
gradeId: string;
headTeacherId: string | null;
/** @contract-pending 列表扩展字段MSW 提供,后端补齐后对齐 */
headTeacherName: string | null;
description: string | null;
/** @contract-pending 列表扩展字段MSW 提供,后端补齐后对齐 */
studentCount: number;
/** @contract-pending 列表扩展字段MSW 提供,后端补齐后对齐 */
subjectCount: number;
createdAt: string;
updatedAt: string;
}
/** 课表条目 */
export interface ClassScheduleItem {
id: string;
weekday: number;
period: number;
subjectId: string;
subjectName: string;
teacherId: string;
teacherName: string;
classroom: string | null;
startTime: string;
endTime: string;
}
/** 班级课表整体(@contract-pending */
export interface ClassSchedule {
classId: string;
className: string;
weekRange: string;
items: ClassScheduleItem[];
}
/** 班级学生名单项(@contract-pending */
export interface ClassStudent {
id: string;
studentNo: string;
name: string;
gender: string;
classId: string;
className: string;
gradeId: string;
enrolledAt: string;
}
/** 班级任课老师项(@contract-pending */
export interface ClassTeacher {
id: string;
name: string;
subjectId: string;
subjectName: string;
role: string;
}
// ===== 响应类型 =====
/** 单查响应(真实 schema */
interface ClassInfoResponse {
classInfo: ClassInfo | null;
}
/** 列表查询响应(@contract-pending 假契约形状MSW 返回此结构) */
interface ClassesListResponse {
classes: {
items: ClassListItem[];
total: number;
};
}
/** 课表查询响应(@contract-pending */
interface ClassScheduleResponse {
classSchedule: ClassSchedule | null;
}
/** 学生名单响应(@contract-pending */
interface ClassStudentsResponse {
classStudents: {
items: ClassStudent[];
total: number;
};
}
/** 任课老师响应(@contract-pending */
interface ClassTeachersResponse {
classTeachers: {
items: ClassTeacher[];
total: number;
};
}
// ===== 筛选类型 =====
export interface ClassesListFilter {
gradeId?: string;
subjectId?: string;
q?: string;
limit?: number;
offset?: number;
}
// ===== 查询选项 =====
export interface ClassQueryOptions {
enabled?: boolean;
pollInterval?: number;
fetchPolicy?: FetchPolicy;
}
// ===== Hooks =====
/**
* 按 id 查询班级详情(真实 schema✅ 契约已就绪)。
*
* 关联ARCHITECTURE.md §5.5 后端已就绪查询 / §9.1 详情页
*/
export function useClassInfo(
id: string,
options?: ClassQueryOptions,
): UseQueryResult<ClassInfo | null> {
const result = useWidgetQuery<ClassInfoResponse, { id: string }>(
GET_CLASS_INFO_DOC,
{ id },
{
...options,
enabled: options?.enabled ?? id.length > 0,
},
);
return {
data: result.data?.classInfo ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询班级列表(@contract-pendingMSW 兜底)。
*
* schema 无 classes(...) 根字段,由 MSW handlers 返回 mock 数据。
* 后端补齐列表查询后切换到真实 fetcher页面无需改动。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 列表页 / §11.4 契约工单
*/
export function useClasses(
filter: ClassesListFilter,
options?: ClassQueryOptions,
): UseQueryResult<{ items: ClassListItem[]; total: number }> {
const result = useWidgetQuery<
ClassesListResponse,
{
gradeId?: string;
subjectId?: string;
q?: string;
limit?: number;
offset?: number;
}
>(
GET_CLASSES_DOC,
{
gradeId: filter.gradeId,
subjectId: filter.subjectId,
q: filter.q,
limit: filter.limit,
offset: filter.offset,
},
{
enabled: options?.enabled ?? true,
fetchPolicy: options?.fetchPolicy,
pollInterval: options?.pollInterval,
},
);
return {
data: result.data?.classes,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询班级课表(@contract-pendingMSW 兜底)。
*
* schema 无 classSchedule(classId) 根字段ClassInfo 类型也无 schedule 字段。
* 详情页课表通过 MSW 返回 mock 数据,后端补齐后切换 fetcher。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 课表页 / §11.4 契约工单
*/
export function useClassSchedule(
classId: string,
options?: ClassQueryOptions,
): UseQueryResult<ClassSchedule | null> {
const result = useWidgetQuery<ClassScheduleResponse, { classId: string }>(
GET_CLASS_SCHEDULE_DOC,
{ classId },
{
...options,
enabled: options?.enabled ?? classId.length > 0,
},
);
return {
data: result.data?.classSchedule ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询班级学生名单(@contract-pendingMSW 兜底)。
*
* schema 无 classStudents(classId) 根字段ClassInfo 类型也无 students 字段。
* 详情页学生名单通过 MSW 返回 mock 数据,后端补齐后切换 fetcher。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 详情页 / §11.4 契约工单
*/
export function useClassStudents(
classId: string,
options?: ClassQueryOptions,
): UseQueryResult<{ items: ClassStudent[]; total: number }> {
const result = useWidgetQuery<ClassStudentsResponse, { classId: string }>(
GET_CLASS_STUDENTS_DOC,
{ classId },
{
...options,
enabled: options?.enabled ?? classId.length > 0,
},
);
return {
data: result.data?.classStudents,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询班级任课老师(@contract-pendingMSW 兜底)。
*
* schema 无 classTeachers(classId) 根字段ClassInfo 类型也无 teachers 字段。
* 详情页任课老师通过 MSW 返回 mock 数据,后端补齐后切换 fetcher。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 详情页 / §11.4 契约工单
*/
export function useClassTeachers(
classId: string,
options?: ClassQueryOptions,
): UseQueryResult<{ items: ClassTeacher[]; total: number }> {
const result = useWidgetQuery<ClassTeachersResponse, { classId: string }>(
GET_CLASS_TEACHERS_DOC,
{ classId },
{
...options,
enabled: options?.enabled ?? classId.length > 0,
},
);
return {
data: result.data?.classTeachers,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}

View File

@@ -20,6 +20,9 @@ export * from "./grades";
export * from "./lesson-plans";
export * from "./questions";
export * from "./textbooks";
export * from "./attendance";
export * from "./classes";
export * from "./students";
export * from "./student";
export * from "./parent";
export * from "./admin";

View File

@@ -0,0 +1,159 @@
// Attendance domain GraphQL documents (ARCHITECTURE.md §5.3 契约纪律 / §9.1)
//
// 拆分原则:
// - 全部操作:❌ schema 无 attendance / attendanceRecords / attendanceSheet / attendanceReport /
// attendanceStats 根字段,也无 Mutation 类型
// → 走 MSW 兜底(@contract-pending等待后端补齐契约
//
// 契约工单docs/architecture/issues/contracts/classes_contract.md#attendance
// 关联ARCHITECTURE.md §5.3 / §5.4 / §9.1 / §11.4
import { gql } from "@apollo/client";
// ── 假契约查询(@contract-pending─────────────────────────────
// 列表查询schema 无 attendanceRecords(...) 根字段
// 页面通过 MSW 兜底获取列表数据,后端补齐后切换 fetcher 指向真实查询
// 契约工单classes_contract.md#attendance-records-list
export const GET_ATTENDANCE_RECORDS_DOC = gql`
query GetAttendanceRecords(
$classId: ID
$date: String
$status: String
$q: String
$limit: Int
$offset: Int
) {
attendanceRecords(
classId: $classId
date: $date
status: $status
q: $q
limit: $limit
offset: $offset
) {
items {
id
studentId
studentName
classId
className
date
status
remark
recordedBy
createdAt
updatedAt
}
total
}
}
`;
// ── 点名表查询(@contract-pending─────────────────────────────
// schema 无 attendanceSheet(classId, date) 根字段
// 点名表页通过 MSW 兜底获取某班级某日全部学生出勤,后端补齐后切换 fetcher
// 契约工单classes_contract.md#attendance-sheet
export const GET_ATTENDANCE_SHEET_DOC = gql`
query GetAttendanceSheet($classId: ID!, $date: String!) {
attendanceSheet(classId: $classId, date: $date) {
classId
className
date
entries {
studentId
studentName
status
remark
}
}
}
`;
// ── 考勤报表查询(@contract-pending─────────────────────────────
// schema 无 attendanceReport(classId, range) 根字段
// 报表页通过 MSW 兜底获取按班级的考勤报表,后端补齐后切换 fetcher
// 契约工单classes_contract.md#attendance-report
export const GET_ATTENDANCE_REPORT_DOC = gql`
query GetAttendanceReport(
$classId: ID!
$startDate: String
$endDate: String
) {
attendanceReport(
classId: $classId
startDate: $startDate
endDate: $endDate
) {
classId
className
range
summary {
total
present
absent
late
leave
attendanceRate
}
items {
studentId
studentName
present
absent
late
leave
attendanceRate
}
}
}
`;
// ── 考勤统计查询(@contract-pending─────────────────────────────
// schema 无 attendanceStats(...) 根字段
// 统计页通过 MSW 兜底获取考勤聚合统计,后端补齐后切换 fetcher
// 契约工单classes_contract.md#attendance-stats
export const GET_ATTENDANCE_STATS_DOC = gql`
query GetAttendanceStats($classId: ID, $startDate: String, $endDate: String) {
attendanceStats(
classId: $classId
startDate: $startDate
endDate: $endDate
) {
classId
overallAttendanceRate
totalRecords
statusDistribution {
status
count
ratio
}
trend {
date
attendanceRate
}
classRanking {
classId
className
attendanceRate
totalStudents
}
}
}
`;
// ── 假契约变更(@contract-pending─────────────────────────────
// 保存点名表schema 无 Mutation 类型
// 页面通过 MSW 兜底提交,后端补齐 mutation 后切换 fetcher
// 契约工单classes_contract.md#save-attendance-sheet-mutation
export const SAVE_ATTENDANCE_SHEET_DOC = gql`
mutation SaveAttendanceSheet(
$classId: ID!
$date: String!
$entries: [AttendanceEntryInput!]!
) {
saveAttendanceSheet(classId: $classId, date: $date, entries: $entries) {
classId
date
savedCount
}
}
`;

View File

@@ -0,0 +1,134 @@
// Classes domain GraphQL documents (ARCHITECTURE.md §5.3 契约纪律 / §9.1)
//
// 拆分原则:
// - GetClassInfo按 id 单查):✅ combined-schema 中真实存在classInfo(id: ID!): ClassInfo
// - GetClasses列表查询❌ schema 无 classes(...) 根字段
// → 走 MSW 兜底(@contract-pending等待后端补齐列表契约
// - GetClassSchedule课表❌ schema 无 classSchedule(classId) 根字段
// → 走 MSW 兜底(@contract-pending
// - GetClassStudents / GetClassTeachers❌ schema 无对应根字段
// → 走 MSW 兜底(@contract-pendingClassInfo 类型无 students/teachers 字段
//
// 契约工单docs/architecture/issues/contracts/classes_contract.md
// 关联ARCHITECTURE.md §5.3 / §5.4 / §5.5 / §9.1 / §11.4
import { gql } from "@apollo/client";
// ── 真实查询classInfo(id) 单查 ─────────────────────────────────
// 字段全部对齐 combined-schema.graphql 中 ClassInfo 类型classes 子图)
// ClassInfo: id / name / gradeId / headTeacherId / description / createdAt / updatedAt
export const GET_CLASS_INFO_DOC = gql`
query GetClassInfo($id: ID!) {
classInfo(id: $id) {
id
name
gradeId
headTeacherId
description
createdAt
updatedAt
}
}
`;
// ── 假契约查询(@contract-pending─────────────────────────────
// 列表查询schema 无 classes(...) 根字段
// 页面通过 MSW 兜底获取列表数据,后端补齐后切换 fetcher 指向真实查询
// 契约工单classes_contract.md#classes-list
export const GET_CLASSES_DOC = gql`
query GetClasses(
$gradeId: String
$subjectId: String
$q: String
$limit: Int
$offset: Int
) {
classes(
gradeId: $gradeId
subjectId: $subjectId
q: $q
limit: $limit
offset: $offset
) {
items {
id
name
gradeId
headTeacherId
headTeacherName
description
studentCount
subjectCount
createdAt
updatedAt
}
total
}
}
`;
// ── 班级课表查询(@contract-pending───────────────────────────
// schema 无 classSchedule(classId) 根字段ClassInfo 类型也无 schedule 字段
// 详情页课表通过 MSW 返回 mock 数据,后端补齐后切换 fetcher
// 契约工单classes_contract.md#class-schedule
export const GET_CLASS_SCHEDULE_DOC = gql`
query GetClassSchedule($classId: ID!) {
classSchedule(classId: $classId) {
classId
className
weekRange
items {
id
weekday
period
subjectId
subjectName
teacherId
teacherName
classroom
startTime
endTime
}
}
}
`;
// ── 班级学生名单查询(@contract-pending───────────────────────
// schema 无 classStudents(classId) 根字段ClassInfo 类型也无 students 字段
// 详情页学生名单通过 MSW 返回 mock 数据,后端补齐后切换 fetcher
// 契约工单classes_contract.md#class-students
export const GET_CLASS_STUDENTS_DOC = gql`
query GetClassStudents($classId: ID!) {
classStudents(classId: $classId) {
items {
id
studentNo
name
gender
classId
className
gradeId
enrolledAt
}
total
}
}
`;
// ── 班级任课老师查询(@contract-pending───────────────────────
// schema 无 classTeachers(classId) 根字段ClassInfo 类型也无 teachers 字段
// 详情页任课老师通过 MSW 返回 mock 数据,后端补齐后切换 fetcher
// 契约工单classes_contract.md#class-teachers
export const GET_CLASS_TEACHERS_DOC = gql`
query GetClassTeachers($classId: ID!) {
classTeachers(classId: $classId) {
items {
id
name
subjectId
subjectName
role
}
total
}
}
`;

View File

@@ -10,6 +10,9 @@ export * from "./grades.graphql";
export * from "./lesson-plans.graphql";
export * from "./questions.graphql";
export * from "./textbooks.graphql";
export * from "./attendance.graphql";
export * from "./classes.graphql";
export * from "./students.graphql";
export * from "./student.graphql";
export * from "./parent.graphql";
export * from "./admin.graphql";

View File

@@ -0,0 +1,62 @@
// Students domain GraphQL documents (ARCHITECTURE.md §5.3 契约纪律 / §9.1)
//
// 拆分原则:
// - 全部操作:❌ schema 无 students(...) 列表 / student(id) 单查根字段
// → 走 MSW 兜底(@contract-pending等待后端补齐契约
//
// 契约工单docs/architecture/issues/contracts/classes_contract.md#students
// 关联ARCHITECTURE.md §5.3 / §5.4 / §9.1 / §11.4
import { gql } from "@apollo/client";
// ── 假契约查询(@contract-pending─────────────────────────────
// 列表查询schema 无 students(...) 根字段
// 页面通过 MSW 兜底获取列表数据,后端补齐后切换 fetcher 指向真实查询
// 契约工单classes_contract.md#students-list
export const GET_STUDENTS_DOC = gql`
query GetStudents(
$classId: ID
$gradeId: String
$q: String
$limit: Int
$offset: Int
) {
students(
classId: $classId
gradeId: $gradeId
q: $q
limit: $limit
offset: $offset
) {
items {
id
studentNo
name
gender
classId
className
gradeId
enrolledAt
}
total
}
}
`;
// ── 单查(@contract-pending─────────────────────────────────────
// schema 无 student(id) 根字段
// 详情/弹窗通过 MSW 兜底获取单条学生,后端补齐后切换 fetcher
// 契约工单classes_contract.md#student-by-id
export const GET_STUDENT_DOC = gql`
query GetStudent($id: ID!) {
student(id: $id) {
id
studentNo
name
gender
classId
className
gradeId
enrolledAt
}
}
`;

View File

@@ -0,0 +1,148 @@
"use client";
/**
* Students domain APIARCHITECTURE.md §5.1 / §5.3 / §9.1 教师域学生模块)
*
* 全部操作(@contract-pending
* 1. useStudents列表查询❌ schema 无 students(...) → MSW 兜底
* 2. useStudent单查❌ schema 无 student(id) → MSW 兜底
*
* 契约工单docs/architecture/issues/contracts/classes_contract.md#students
* 后端补齐后:重跑 normalize + codegen → 关闭 skipDocumentsValidation → 切换 fetcher → 删 mock
*
* 关联ARCHITECTURE.md §5.3 契约纪律 / §5.4 MSW 兜底 / §9.1 / §11.4 契约工单
*/
import type { FetchPolicy } from "@apollo/client";
import { useWidgetQuery } from "../useWidgetQuery";
import {
GET_STUDENT_DOC,
GET_STUDENTS_DOC,
} from "./operations/students.graphql";
import type { UseQueryResult } from "./types";
// ===== 数据类型(@contract-pendingMSW 提供形状) =====
/**
* 学生实体(列表项与单查同构,@contract-pending
*
* schema 无 Student 类型,字段形状由 MSW mock 定义。
* 后端补齐后对齐真实 schema。
*/
export interface Student {
id: string;
studentNo: string;
name: string;
gender: string;
classId: string;
className: string;
gradeId: string;
enrolledAt: string;
}
/** 列表项(与 Student 同构) */
export type StudentListItem = Student;
// ===== 响应类型(@contract-pending 假契约形状MSW 返回此结构) =====
interface StudentsListResponse {
students: {
items: StudentListItem[];
total: number;
};
}
interface StudentResponse {
student: Student | null;
}
// ===== 筛选类型 =====
export interface StudentsListFilter {
classId?: string;
gradeId?: string;
q?: string;
limit?: number;
offset?: number;
}
// ===== 查询选项 =====
export interface StudentQueryOptions {
enabled?: boolean;
pollInterval?: number;
fetchPolicy?: FetchPolicy;
}
// ===== Hooks =====
/**
* 查询学生列表(@contract-pendingMSW 兜底)。
*
* schema 无 students(...) 根字段,由 MSW handlers 返回 mock 数据。
* 后端补齐列表查询后切换到真实 fetcher页面无需改动。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 列表页 / §11.4 契约工单
*/
export function useStudents(
filter: StudentsListFilter,
options?: StudentQueryOptions,
): UseQueryResult<{ items: StudentListItem[]; total: number }> {
const result = useWidgetQuery<
StudentsListResponse,
{
classId?: string;
gradeId?: string;
q?: string;
limit?: number;
offset?: number;
}
>(
GET_STUDENTS_DOC,
{
classId: filter.classId,
gradeId: filter.gradeId,
q: filter.q,
limit: filter.limit,
offset: filter.offset,
},
{
enabled: options?.enabled ?? true,
fetchPolicy: options?.fetchPolicy,
pollInterval: options?.pollInterval,
},
);
return {
data: result.data?.students,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 按 id 查询单个学生(@contract-pendingMSW 兜底)。
*
* schema 无 student(id) 根字段,由 MSW handlers 返回 mock 数据。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 / §11.4 契约工单
*/
export function useStudent(
id: string,
options?: StudentQueryOptions,
): UseQueryResult<Student | null> {
const result = useWidgetQuery<StudentResponse, { id: string }>(
GET_STUDENT_DOC,
{ id },
{
...options,
enabled: options?.enabled ?? id.length > 0,
},
);
return {
data: result.data?.student ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}

View File

@@ -143,7 +143,77 @@
}
},
"classes": {
"title": "Classes"
"title": "Classes",
"list": {
"title": "Classes",
"description": "View and manage all classes",
"searchPlaceholder": "Search class name/teacher...",
"gradePlaceholder": "Grade ID",
"subjectPlaceholder": "Subject ID",
"total": "{count} records",
"emptyTitle": "No classes",
"emptyDescription": "Adjust filters to retry",
"emptyAction": "Refresh",
"mswNotice": "List contract pending. Ensure NEXT_PUBLIC_MSW=1 is enabled.",
"colName": "Class",
"colGrade": "Grade",
"colHeadTeacher": "Head teacher",
"colStudentCount": "Students",
"colSubjectCount": "Subjects",
"colDescription": "Description",
"colUpdatedAt": "Updated",
"colActions": "Actions",
"viewDetail": "View detail →"
},
"detail": {
"title": "Class detail",
"notFound": "Class not found, may be deleted",
"backToList": "Back to list",
"createdAtPrefix": "Created on {date}",
"sectionBasic": "Basic info",
"fieldName": "Name",
"fieldGradeId": "Grade ID",
"fieldHeadTeacherId": "Head teacher ID",
"noHeadTeacher": "Not set",
"fieldDescription": "Description",
"noDescription": "No description",
"fieldCreatedAt": "Created at",
"fieldUpdatedAt": "Updated at",
"sectionStudents": "Students",
"studentsMswNotice": "Students contract pending (@contract-pending).",
"noStudents": "No students",
"colStudentNo": "Student no",
"colStudentName": "Name",
"colEnrolledAt": "Enrolled at",
"sectionTeachers": "Teachers",
"teachersMswNotice": "Teachers contract pending (@contract-pending).",
"noTeachers": "No teachers",
"colTeacherName": "Name",
"colSubjectName": "Subject",
"colTeacherRole": "Role"
},
"schedule": {
"title": "Class schedule",
"subtitle": "{className} · {weekRange}",
"notFound": "Schedule not found",
"backToList": "Back to list",
"mswNotice": "Schedule contract pending. Ensure NEXT_PUBLIC_MSW=1 is enabled.",
"sectionOverview": "Overview",
"fieldClassName": "Class",
"fieldWeekRange": "Week range",
"fieldTotalLessons": "Total lessons",
"sectionByDay": "By weekday",
"colPeriod": "Period",
"colSubject": "Subject",
"colTeacher": "Teacher",
"colClassroom": "Classroom",
"colTime": "Time"
},
"error": {
"title": "Classes module error",
"unknown": "Unknown classes module error",
"retry": "Retry"
}
},
"exams": {
"title": "Exams",
@@ -652,7 +722,98 @@
"title": "AI Report"
},
"attendance": {
"title": "Attendance"
"title": "Attendance",
"list": {
"title": "Attendance",
"description": "View and manage all attendance records",
"newSheet": "New Sheet",
"searchPlaceholder": "Search student name/no...",
"classPlaceholder": "Class ID",
"dateFilter": "Date filter",
"statusFilter": "Status filter",
"statusAll": "All statuses",
"statusPresent": "Present",
"statusAbsent": "Absent",
"statusLate": "Late",
"statusLeave": "Leave",
"total": "{count} records",
"emptyTitle": "No attendance records",
"emptyDescription": "Adjust filters, or create today's sheet",
"emptyAction": "New Sheet",
"mswNotice": "Attendance records contract pending. Ensure NEXT_PUBLIC_MSW=1 is enabled.",
"colStudentName": "Student",
"colClassName": "Class",
"colDate": "Date",
"colStatus": "Status",
"colRemark": "Remark",
"colRecordedBy": "Recorded by",
"colUpdatedAt": "Updated"
},
"sheet": {
"title": "Attendance Sheet",
"description": "Record class attendance for a date",
"submit": "Save",
"cancel": "Cancel",
"classId": "Class ID",
"classIdPlaceholder": "e.g. cls-001",
"date": "Date",
"colStudentName": "Student",
"colStatus": "Status",
"colRemark": "Remark",
"mswNotice": "Sheet and save mutation are @contract-pending via MSW. Will switch to real mutation once contract is ready.",
"errorClassRequired": "Class ID is required",
"errorDateRequired": "Date is required",
"errorNoEntries": "No entries to save",
"success": "Sheet saved successfully",
"error": "Save failed",
"loadFailed": "Load failed: {message}"
},
"report": {
"title": "Attendance Report",
"subtitle": "{className} · {range}",
"notFound": "Report not found, may not be generated yet",
"backToList": "Back to list",
"mswNotice": "Report contract pending. Ensure NEXT_PUBLIC_MSW=1 is enabled.",
"sectionSummary": "Summary",
"fieldTotal": "Total records",
"fieldPresent": "Present",
"fieldAbsent": "Absent",
"fieldLate": "Late",
"fieldLeave": "Leave",
"fieldAttendanceRate": "Attendance rate",
"sectionItems": "Student details",
"colStudentName": "Student",
"colPresent": "Present",
"colAbsent": "Absent",
"colLate": "Late",
"colLeave": "Leave",
"colAttendanceRate": "Attendance rate"
},
"stats": {
"title": "Attendance Stats",
"subtitle": "Overall rate: {rate}",
"notFound": "Stats not found",
"backToList": "Back to list",
"mswNotice": "Stats contract pending. Ensure NEXT_PUBLIC_MSW=1 is enabled.",
"sectionOverview": "Overview",
"fieldOverallRate": "Overall rate",
"fieldTotalRecords": "Total records",
"sectionDistribution": "Status distribution",
"noDistribution": "No distribution data",
"sectionTrend": "Trend",
"noTrend": "No trend data",
"sectionRanking": "Class ranking",
"noRanking": "No ranking data",
"colRank": "Rank",
"colClassName": "Class",
"colTotalStudents": "Students",
"colAttendanceRate": "Attendance rate"
},
"error": {
"title": "Attendance module error",
"unknown": "Unknown attendance module error",
"retry": "Retry"
}
},
"questions": {
"title": "Questions",
@@ -893,6 +1054,29 @@
"title": "Settings"
},
"students": {
"title": "Students"
"title": "Students",
"list": {
"title": "Students",
"description": "View and manage all students",
"searchPlaceholder": "Search name/no...",
"classPlaceholder": "Class ID",
"gradePlaceholder": "Grade ID",
"total": "{count} records",
"emptyTitle": "No students",
"emptyDescription": "Adjust filters to retry",
"emptyAction": "Refresh",
"mswNotice": "Students list contract pending. Ensure NEXT_PUBLIC_MSW=1 is enabled.",
"colStudentNo": "Student no",
"colName": "Name",
"colGender": "Gender",
"colClassName": "Class",
"colGradeId": "Grade",
"colEnrolledAt": "Enrolled at"
},
"error": {
"title": "Students module error",
"unknown": "Unknown students module error",
"retry": "Retry"
}
}
}

View File

@@ -143,7 +143,77 @@
}
},
"classes": {
"title": "班级管理"
"title": "班级管理",
"list": {
"title": "班级管理",
"description": "查看和管理所有班级",
"searchPlaceholder": "搜索班级名称/班主任...",
"gradePlaceholder": "年级 ID",
"subjectPlaceholder": "科目 ID",
"total": "共 {count} 条",
"emptyTitle": "暂无班级",
"emptyDescription": "调整筛选条件后重试",
"emptyAction": "刷新",
"mswNotice": "列表查询契约待补齐,请确认 NEXT_PUBLIC_MSW=1 已启用。",
"colName": "班级",
"colGrade": "年级",
"colHeadTeacher": "班主任",
"colStudentCount": "学生数",
"colSubjectCount": "科目数",
"colDescription": "描述",
"colUpdatedAt": "更新时间",
"colActions": "操作",
"viewDetail": "查看详情 →"
},
"detail": {
"title": "班级详情",
"notFound": "未找到班级,可能已被删除",
"backToList": "返回列表",
"createdAtPrefix": "创建于 {date}",
"sectionBasic": "基本信息",
"fieldName": "名称",
"fieldGradeId": "年级 ID",
"fieldHeadTeacherId": "班主任 ID",
"noHeadTeacher": "未设置",
"fieldDescription": "描述",
"noDescription": "暂无描述",
"fieldCreatedAt": "创建时间",
"fieldUpdatedAt": "更新时间",
"sectionStudents": "学生名单",
"studentsMswNotice": "学生名单查询契约待补齐(@contract-pending。",
"noStudents": "暂无学生",
"colStudentNo": "学号",
"colStudentName": "姓名",
"colEnrolledAt": "入学时间",
"sectionTeachers": "任课老师",
"teachersMswNotice": "任课老师查询契约待补齐(@contract-pending。",
"noTeachers": "暂无任课老师",
"colTeacherName": "姓名",
"colSubjectName": "科目",
"colTeacherRole": "角色"
},
"schedule": {
"title": "班级课表",
"subtitle": "{className} · {weekRange}",
"notFound": "未找到课表数据",
"backToList": "返回列表",
"mswNotice": "课表查询契约待补齐,请确认 NEXT_PUBLIC_MSW=1 已启用。",
"sectionOverview": "概览",
"fieldClassName": "班级",
"fieldWeekRange": "周次",
"fieldTotalLessons": "总课时",
"sectionByDay": "按星期分组",
"colPeriod": "节次",
"colSubject": "科目",
"colTeacher": "教师",
"colClassroom": "教室",
"colTime": "时间"
},
"error": {
"title": "班级模块出错了",
"unknown": "班级模块发生未知错误",
"retry": "重试"
}
},
"exams": {
"title": "考试管理",
@@ -652,7 +722,98 @@
"title": "AI 学情报告"
},
"attendance": {
"title": "考勤管理"
"title": "考勤管理",
"list": {
"title": "考勤管理",
"description": "查看和管理所有考勤记录",
"newSheet": "新建点名表",
"searchPlaceholder": "搜索学生姓名/学号...",
"classPlaceholder": "班级 ID",
"dateFilter": "日期筛选",
"statusFilter": "状态筛选",
"statusAll": "全部状态",
"statusPresent": "出勤",
"statusAbsent": "缺勤",
"statusLate": "迟到",
"statusLeave": "请假",
"total": "共 {count} 条",
"emptyTitle": "暂无考勤记录",
"emptyDescription": "调整筛选条件后重试,或新建今日点名表",
"emptyAction": "新建点名表",
"mswNotice": "考勤记录查询契约待补齐,请确认 NEXT_PUBLIC_MSW=1 已启用。",
"colStudentName": "学生",
"colClassName": "班级",
"colDate": "日期",
"colStatus": "状态",
"colRemark": "备注",
"colRecordedBy": "记录人",
"colUpdatedAt": "更新时间"
},
"sheet": {
"title": "点名表",
"description": "记录某班级某日学生出勤",
"submit": "保存",
"cancel": "取消",
"classId": "班级 ID",
"classIdPlaceholder": "例如 cls-001",
"date": "日期",
"colStudentName": "学生",
"colStatus": "状态",
"colRemark": "备注",
"mswNotice": "点名表与保存 mutation 契约为 @contract-pending当前通过 MSW 兜底。后端补齐后切换为真实提交。",
"errorClassRequired": "请填写班级 ID",
"errorDateRequired": "请选择日期",
"errorNoEntries": "无考勤条目可保存",
"success": "点名表保存成功",
"error": "保存失败",
"loadFailed": "加载失败:{message}"
},
"report": {
"title": "考勤报表",
"subtitle": "{className} · {range}",
"notFound": "未找到考勤报表,可能尚未生成",
"backToList": "返回列表",
"mswNotice": "报表查询契约待补齐,请确认 NEXT_PUBLIC_MSW=1 已启用。",
"sectionSummary": "汇总统计",
"fieldTotal": "总记录数",
"fieldPresent": "出勤",
"fieldAbsent": "缺勤",
"fieldLate": "迟到",
"fieldLeave": "请假",
"fieldAttendanceRate": "出勤率",
"sectionItems": "学生明细",
"colStudentName": "学生",
"colPresent": "出勤",
"colAbsent": "缺勤",
"colLate": "迟到",
"colLeave": "请假",
"colAttendanceRate": "出勤率"
},
"stats": {
"title": "考勤统计",
"subtitle": "整体出勤率:{rate}",
"notFound": "未找到考勤统计数据",
"backToList": "返回列表",
"mswNotice": "统计查询契约待补齐,请确认 NEXT_PUBLIC_MSW=1 已启用。",
"sectionOverview": "概览",
"fieldOverallRate": "整体出勤率",
"fieldTotalRecords": "记录总数",
"sectionDistribution": "状态分布",
"noDistribution": "暂无状态分布数据",
"sectionTrend": "趋势",
"noTrend": "暂无趋势数据",
"sectionRanking": "班级排名",
"noRanking": "暂无班级排名数据",
"colRank": "排名",
"colClassName": "班级",
"colTotalStudents": "学生数",
"colAttendanceRate": "出勤率"
},
"error": {
"title": "考勤模块出错了",
"unknown": "考勤模块发生未知错误",
"retry": "重试"
}
},
"questions": {
"title": "题库",
@@ -893,6 +1054,29 @@
"title": "个人设置"
},
"students": {
"title": "学生"
"title": "学生",
"list": {
"title": "学生",
"description": "查看和管理所有学生",
"searchPlaceholder": "搜索姓名/学号...",
"classPlaceholder": "班级 ID",
"gradePlaceholder": "年级 ID",
"total": "共 {count} 条",
"emptyTitle": "暂无学生",
"emptyDescription": "调整筛选条件后重试",
"emptyAction": "刷新",
"mswNotice": "学生列表查询契约待补齐,请确认 NEXT_PUBLIC_MSW=1 已启用。",
"colStudentNo": "学号",
"colName": "姓名",
"colGender": "性别",
"colClassName": "班级",
"colGradeId": "年级",
"colEnrolledAt": "入学时间"
},
"error": {
"title": "学生模块出错了",
"unknown": "学生模块发生未知错误",
"retry": "重试"
}
}
}

View File

@@ -1609,6 +1609,607 @@ const mockLessonPlanHeatmap = {
})(),
};
// ── Attendance 域(教师域 B2 迁移,@contract-pending 全 MSW──
// schema 无 attendanceRecords/attendanceSheet/attendanceReport/attendanceStats 根字段,
// 也无 Mutation 类型 → 全部走 MSW 兜底
// 契约工单docs/architecture/issues/contracts/classes_contract.md#attendance
const mockAttendanceRecords = [
{
id: "att-001",
studentId: "stu-001",
studentName: "张明",
classId: "cls-001",
className: "高三(1)班",
date: "2026-07-22",
status: "present",
remark: null,
recordedBy: "usr-teacher-001",
createdAt: "2026-07-22T08:00:00Z",
updatedAt: "2026-07-22T08:00:00Z",
},
{
id: "att-002",
studentId: "stu-002",
studentName: "李华",
classId: "cls-001",
className: "高三(1)班",
date: "2026-07-22",
status: "late",
remark: "迟到 10 分钟",
recordedBy: "usr-teacher-001",
createdAt: "2026-07-22T08:00:00Z",
updatedAt: "2026-07-22T08:05:00Z",
},
{
id: "att-003",
studentId: "stu-003",
studentName: "王芳",
classId: "cls-001",
className: "高三(1)班",
date: "2026-07-22",
status: "absent",
remark: "病假",
recordedBy: "usr-teacher-001",
createdAt: "2026-07-22T08:00:00Z",
updatedAt: "2026-07-22T08:00:00Z",
},
{
id: "att-004",
studentId: "stu-004",
studentName: "赵六",
classId: "cls-001",
className: "高三(1)班",
date: "2026-07-21",
status: "leave",
remark: "事假",
recordedBy: "usr-teacher-001",
createdAt: "2026-07-21T08:00:00Z",
updatedAt: "2026-07-21T08:00:00Z",
},
{
id: "att-005",
studentId: "stu-005",
studentName: "钱七",
classId: "cls-001",
className: "高三(1)班",
date: "2026-07-21",
status: "present",
remark: null,
recordedBy: "usr-teacher-001",
createdAt: "2026-07-21T08:00:00Z",
updatedAt: "2026-07-21T08:00:00Z",
},
];
const mockAttendanceSheet = {
classId: "cls-001",
className: "高三(1)班",
date: "2026-07-22",
entries: [
{
studentId: "stu-001",
studentName: "张明",
status: "present",
remark: null,
},
{
studentId: "stu-002",
studentName: "李华",
status: "present",
remark: null,
},
{
studentId: "stu-003",
studentName: "王芳",
status: "absent",
remark: "病假",
},
{
studentId: "stu-004",
studentName: "赵六",
status: "present",
remark: null,
},
{
studentId: "stu-005",
studentName: "钱七",
status: "present",
remark: null,
},
],
};
const mockAttendanceReport = {
classId: "cls-001",
className: "高三(1)班",
range: "2026-07-01 ~ 2026-07-22",
summary: {
total: 190,
present: 168,
absent: 12,
late: 7,
leave: 3,
attendanceRate: 0.884,
},
items: [
{
studentId: "stu-001",
studentName: "张明",
present: 22,
absent: 0,
late: 0,
leave: 0,
attendanceRate: 1.0,
},
{
studentId: "stu-002",
studentName: "李华",
present: 20,
absent: 1,
late: 1,
leave: 0,
attendanceRate: 0.909,
},
{
studentId: "stu-003",
studentName: "王芳",
present: 18,
absent: 3,
late: 1,
leave: 0,
attendanceRate: 0.818,
},
{
studentId: "stu-004",
studentName: "赵六",
present: 19,
absent: 2,
late: 0,
leave: 1,
attendanceRate: 0.864,
},
{
studentId: "stu-005",
studentName: "钱七",
present: 21,
absent: 0,
late: 1,
leave: 0,
attendanceRate: 0.955,
},
],
};
const mockAttendanceStats = {
classId: null,
overallAttendanceRate: 0.884,
totalRecords: 950,
statusDistribution: [
{ status: "present", count: 840, ratio: 0.884 },
{ status: "absent", count: 60, ratio: 0.063 },
{ status: "late", count: 35, ratio: 0.037 },
{ status: "leave", count: 15, ratio: 0.016 },
],
trend: [
{ date: "2026-07-15", attendanceRate: 0.92 },
{ date: "2026-07-16", attendanceRate: 0.88 },
{ date: "2026-07-17", attendanceRate: 0.85 },
{ date: "2026-07-18", attendanceRate: 0.9 },
{ date: "2026-07-19", attendanceRate: 0.87 },
{ date: "2026-07-20", attendanceRate: 0.89 },
{ date: "2026-07-21", attendanceRate: 0.86 },
{ date: "2026-07-22", attendanceRate: 0.91 },
],
classRanking: [
{
classId: "cls-002",
className: "高三(2)班",
attendanceRate: 0.95,
totalStudents: 40,
},
{
classId: "cls-001",
className: "高三(1)班",
attendanceRate: 0.884,
totalStudents: 38,
},
{
classId: "cls-003",
className: "高三(3)班",
attendanceRate: 0.82,
totalStudents: 42,
},
],
};
// ── Classes 域(教师域 B2 迁移,@contract-pending 列表 + 真实单查)──
// 用于 /shell/teacher/classes 列表页 MSW 兜底
// ClassListItem 字段(对齐 lib/api/classes.ts 接口):
// id/name/gradeId/headTeacherId/headTeacherName/description/studentCount/subjectCount/createdAt/updatedAt
// 其中 headTeacherName/studentCount/subjectCount 为 MSW 扩展字段schema ClassInfo 无此三字段)
const mockClasses = [
{
id: "cls-001",
name: "高三(1)班",
gradeId: "g-12",
headTeacherId: "usr-teacher-001",
headTeacherName: "张老师",
description: "理科实验班",
studentCount: 38,
subjectCount: 6,
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-07-20T00:00:00Z",
},
{
id: "cls-002",
name: "高三(2)班",
gradeId: "g-12",
headTeacherId: "usr-teacher-002",
headTeacherName: "李老师",
description: "文科实验班",
studentCount: 40,
subjectCount: 5,
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-07-18T00:00:00Z",
},
{
id: "cls-003",
name: "高三(3)班",
gradeId: "g-12",
headTeacherId: "usr-teacher-003",
headTeacherName: "王老师",
description: null,
studentCount: 42,
subjectCount: 6,
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-07-15T00:00:00Z",
},
{
id: "cls-004",
name: "高二(1)班",
gradeId: "g-11",
headTeacherId: "usr-teacher-004",
headTeacherName: "赵老师",
description: "理科普通班",
studentCount: 45,
subjectCount: 6,
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-07-10T00:00:00Z",
},
];
// mockClassInfoDetailclassInfo(id) 单查 dev 兜底数据,对齐 schema ClassInfo 类型
// schema ClassInfo: id / name / gradeId / headTeacherId / description / createdAt / updatedAt
const mockClassInfoDetail = {
id: "cls-001",
name: "高三(1)班",
gradeId: "g-12",
headTeacherId: "usr-teacher-001",
description: "理科实验班",
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-07-20T00:00:00Z",
};
// mockClassStudents按 classId 索引的学生名单(@contract-pending
const mockClassStudents: Record<
string,
Array<{
id: string;
studentNo: string;
name: string;
gender: string;
classId: string;
className: string;
gradeId: string;
enrolledAt: string;
}>
> = {
"cls-001": [
{
id: "stu-001",
studentNo: "2026001",
name: "张明",
gender: "male",
classId: "cls-001",
className: "高三(1)班",
gradeId: "g-12",
enrolledAt: "2026-09-01T00:00:00Z",
},
{
id: "stu-002",
studentNo: "2026002",
name: "李华",
gender: "female",
classId: "cls-001",
className: "高三(1)班",
gradeId: "g-12",
enrolledAt: "2026-09-01T00:00:00Z",
},
{
id: "stu-003",
studentNo: "2026003",
name: "王芳",
gender: "female",
classId: "cls-001",
className: "高三(1)班",
gradeId: "g-12",
enrolledAt: "2026-09-01T00:00:00Z",
},
{
id: "stu-004",
studentNo: "2026004",
name: "赵六",
gender: "male",
classId: "cls-001",
className: "高三(1)班",
gradeId: "g-12",
enrolledAt: "2026-09-01T00:00:00Z",
},
{
id: "stu-005",
studentNo: "2026005",
name: "钱七",
gender: "male",
classId: "cls-001",
className: "高三(1)班",
gradeId: "g-12",
enrolledAt: "2026-09-01T00:00:00Z",
},
],
"cls-002": [
{
id: "stu-101",
studentNo: "2026101",
name: "孙八",
gender: "male",
classId: "cls-002",
className: "高三(2)班",
gradeId: "g-12",
enrolledAt: "2026-09-01T00:00:00Z",
},
{
id: "stu-102",
studentNo: "2026102",
name: "周九",
gender: "female",
classId: "cls-002",
className: "高三(2)班",
gradeId: "g-12",
enrolledAt: "2026-09-01T00:00:00Z",
},
],
};
// mockClassTeachers按 classId 索引的任课老师(@contract-pending
const mockClassTeachers: Record<
string,
Array<{
id: string;
name: string;
subjectId: string;
subjectName: string;
role: string;
}>
> = {
"cls-001": [
{
id: "usr-teacher-001",
name: "张老师",
subjectId: "sub-math",
subjectName: "数学",
role: "班主任",
},
{
id: "usr-teacher-002",
name: "李老师",
subjectId: "sub-physics",
subjectName: "物理",
role: "任课教师",
},
{
id: "usr-teacher-003",
name: "王老师",
subjectId: "sub-chemistry",
subjectName: "化学",
role: "任课教师",
},
],
"cls-002": [
{
id: "usr-teacher-004",
name: "赵老师",
subjectId: "sub-chinese",
subjectName: "语文",
role: "班主任",
},
],
};
// mockClassSchedule按 classId 索引的课表(@contract-pending
const mockClassSchedule: Record<
string,
{
classId: string;
className: string;
weekRange: string;
items: Array<{
id: string;
weekday: number;
period: number;
subjectId: string;
subjectName: string;
teacherId: string;
teacherName: string;
classroom: string | null;
startTime: string;
endTime: string;
}>;
}
> = {
"cls-001": {
classId: "cls-001",
className: "高三(1)班",
weekRange: "2026-07-20 ~ 2026-07-26",
items: [
{
id: "sch-001",
weekday: 1,
period: 1,
subjectId: "sub-math",
subjectName: "数学",
teacherId: "usr-teacher-001",
teacherName: "张老师",
classroom: "301",
startTime: "08:00",
endTime: "08:45",
},
{
id: "sch-002",
weekday: 1,
period: 2,
subjectId: "sub-physics",
subjectName: "物理",
teacherId: "usr-teacher-002",
teacherName: "李老师",
classroom: "301",
startTime: "08:55",
endTime: "09:40",
},
{
id: "sch-003",
weekday: 2,
period: 1,
subjectId: "sub-chemistry",
subjectName: "化学",
teacherId: "usr-teacher-003",
teacherName: "王老师",
classroom: "实验室1",
startTime: "08:00",
endTime: "08:45",
},
{
id: "sch-004",
weekday: 3,
period: 3,
subjectId: "sub-math",
subjectName: "数学",
teacherId: "usr-teacher-001",
teacherName: "张老师",
classroom: "301",
startTime: "10:00",
endTime: "10:45",
},
{
id: "sch-005",
weekday: 5,
period: 1,
subjectId: "sub-physics",
subjectName: "物理",
teacherId: "usr-teacher-002",
teacherName: "李老师",
classroom: "301",
startTime: "08:00",
endTime: "08:45",
},
],
},
"cls-002": {
classId: "cls-002",
className: "高三(2)班",
weekRange: "2026-07-20 ~ 2026-07-26",
items: [
{
id: "sch-101",
weekday: 1,
period: 1,
subjectId: "sub-chinese",
subjectName: "语文",
teacherId: "usr-teacher-004",
teacherName: "赵老师",
classroom: "302",
startTime: "08:00",
endTime: "08:45",
},
],
},
};
// ── Students 域(教师域 B2 迁移,@contract-pending 全 MSW──
// schema 无 students(...) 根字段,全部走 MSW 兜底
// 契约工单docs/architecture/issues/contracts/classes_contract.md#students
const mockStudents = [
{
id: "stu-001",
studentNo: "2026001",
name: "张明",
gender: "male",
classId: "cls-001",
className: "高三(1)班",
gradeId: "g-12",
enrolledAt: "2026-09-01T00:00:00Z",
},
{
id: "stu-002",
studentNo: "2026002",
name: "李华",
gender: "female",
classId: "cls-001",
className: "高三(1)班",
gradeId: "g-12",
enrolledAt: "2026-09-01T00:00:00Z",
},
{
id: "stu-003",
studentNo: "2026003",
name: "王芳",
gender: "female",
classId: "cls-001",
className: "高三(1)班",
gradeId: "g-12",
enrolledAt: "2026-09-01T00:00:00Z",
},
{
id: "stu-004",
studentNo: "2026004",
name: "赵六",
gender: "male",
classId: "cls-001",
className: "高三(1)班",
gradeId: "g-12",
enrolledAt: "2026-09-01T00:00:00Z",
},
{
id: "stu-005",
studentNo: "2026005",
name: "钱七",
gender: "male",
classId: "cls-001",
className: "高三(1)班",
gradeId: "g-12",
enrolledAt: "2026-09-01T00:00:00Z",
},
{
id: "stu-101",
studentNo: "2026101",
name: "孙八",
gender: "male",
classId: "cls-002",
className: "高三(2)班",
gradeId: "g-12",
enrolledAt: "2026-09-01T00:00:00Z",
},
{
id: "stu-102",
studentNo: "2026102",
name: "周九",
gender: "female",
classId: "cls-002",
className: "高三(2)班",
gradeId: "g-12",
enrolledAt: "2026-09-01T00:00:00Z",
},
];
// ── GraphQL Response ───────────────────────────────────────────
/**
@@ -2395,6 +2996,182 @@ export function graphqlResponse(
case "GetLessonPlanHeatmap":
return { data: { lessonPlanHeatmap: mockLessonPlanHeatmap } };
// ── Attendance 域(教师域 B2 迁移,@contract-pending 全 MSW──
// schema 无 attendanceRecords/attendanceSheet/attendanceReport/attendanceStats 根字段,
// 也无 Mutation 类型 → 全部走 MSW 兜底
// 契约工单docs/architecture/issues/contracts/classes_contract.md#attendance
//
// GetAttendanceRecords($classId, $date, $status, $q):列表查询
case "GetAttendanceRecords": {
const classId = variables?.classId as string | undefined;
const date = variables?.date as string | undefined;
const status = variables?.status as string | undefined;
const q = variables?.q as string | undefined;
let filtered = [...mockAttendanceRecords];
if (classId) filtered = filtered.filter((r) => r.classId === classId);
if (date) filtered = filtered.filter((r) => r.date === date);
if (status) filtered = filtered.filter((r) => r.status === status);
if (q) {
const ql = q.toLowerCase();
filtered = filtered.filter(
(r) =>
r.studentName.toLowerCase().includes(ql) ||
r.studentId.toLowerCase().includes(ql),
);
}
return {
data: {
attendanceRecords: { items: filtered, total: filtered.length },
},
};
}
// GetAttendanceSheet($classId, $date):点名表查询
case "GetAttendanceSheet": {
const classId = (variables?.classId as string) ?? "";
const date = (variables?.date as string) ?? "";
// 任意 classId/date 都返回同一条 mock 点名表dev 兜底)
void classId;
void date;
return { data: { attendanceSheet: mockAttendanceSheet } };
}
// GetAttendanceReport($classId, $startDate, $endDate):报表查询
case "GetAttendanceReport": {
return { data: { attendanceReport: mockAttendanceReport } };
}
// GetAttendanceStats($classId, $startDate, $endDate):统计查询
case "GetAttendanceStats": {
return { data: { attendanceStats: mockAttendanceStats } };
}
// SaveAttendanceSheet($classId, $date, $entries)mutation 兜底
case "SaveAttendanceSheet": {
const input = (variables ?? {}) as {
classId?: string;
date?: string;
entries?: unknown[];
};
const classId = input.classId ?? "cls-001";
const date = input.date ?? "2026-07-22";
const savedCount = Array.isArray(input.entries)
? input.entries.length
: 0;
return {
data: {
saveAttendanceSheet: { classId, date, savedCount },
},
};
}
// ── Classes 域(教师域 B2 迁移,@contract-pending 列表 + 真实单查)──
// GetClassInfo($id):按 id 单查(真实 schemaMSW 也兜底以便 dev 全链路可用)
case "GetClassInfo": {
const clsId = (variables?.id as string | undefined) ?? "";
const listMatch = mockClasses.find((c) => c.id === clsId);
// 真实 schema 返回 ClassInfo 字段(不含 headTeacherName/studentCount/subjectCount
return {
data: {
classInfo: listMatch
? {
id: listMatch.id,
name: listMatch.name,
gradeId: listMatch.gradeId,
headTeacherId: listMatch.headTeacherId,
description: listMatch.description,
createdAt: listMatch.createdAt,
updatedAt: listMatch.updatedAt,
}
: { ...mockClassInfoDetail, id: clsId },
},
};
}
// GetClasses($gradeId, $subjectId, $q):列表查询(@contract-pending
case "GetClasses": {
const gradeId = variables?.gradeId as string | undefined;
const subjectId = variables?.subjectId as string | undefined;
const q = variables?.q as string | undefined;
let filtered = [...mockClasses];
if (gradeId) filtered = filtered.filter((c) => c.gradeId === gradeId);
if (subjectId)
// subjectCount 不为 0 即视为该班级开设此科目mock 兜底近似匹配)
filtered = filtered.filter((c) => c.subjectCount > 0);
if (q) {
const ql = q.toLowerCase();
filtered = filtered.filter(
(c) =>
c.name.toLowerCase().includes(ql) ||
(c.headTeacherName?.toLowerCase().includes(ql) ?? false),
);
}
return {
data: {
classes: { items: filtered, total: filtered.length },
},
};
}
// GetClassSchedule($classId):课表查询(@contract-pending
case "GetClassSchedule": {
const classId = (variables?.classId as string) ?? "";
const schedule = mockClassSchedule[classId];
return {
data: {
classSchedule: schedule ?? null,
},
};
}
// GetClassStudents($classId):学生名单查询(@contract-pending
case "GetClassStudents": {
const classId = (variables?.classId as string) ?? "";
const items = mockClassStudents[classId] ?? [];
return {
data: {
classStudents: { items, total: items.length },
},
};
}
// GetClassTeachers($classId):任课老师查询(@contract-pending
case "GetClassTeachers": {
const classId = (variables?.classId as string) ?? "";
const items = mockClassTeachers[classId] ?? [];
return {
data: {
classTeachers: { items, total: items.length },
},
};
}
// ── Students 域(教师域 B2 迁移,@contract-pending 全 MSW──
// GetStudents($classId, $gradeId, $q):列表查询
case "GetStudents": {
const classId = variables?.classId as string | undefined;
const gradeId = variables?.gradeId as string | undefined;
const q = variables?.q as string | undefined;
let filtered = [...mockStudents];
if (classId) filtered = filtered.filter((s) => s.classId === classId);
if (gradeId) filtered = filtered.filter((s) => s.gradeId === gradeId);
if (q) {
const ql = q.toLowerCase();
filtered = filtered.filter(
(s) =>
s.name.toLowerCase().includes(ql) ||
s.studentNo.toLowerCase().includes(ql),
);
}
return {
data: {
students: { items: filtered, total: filtered.length },
},
};
}
// GetStudent($id):按 id 单查(@contract-pending
case "GetStudent": {
const stuId = (variables?.id as string | undefined) ?? "";
const found = mockStudents.find((s) => s.id === stuId);
return {
data: {
student: found ?? null,
},
};
}
// ── 通用 ──
case "GetNotificationsList":
return {

View File

@@ -152,6 +152,18 @@ export const EXACT_ROUTE_PERMISSIONS: Record<string, RoutePermissionConfig> = {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["ATTENDANCE_READ", "ATTENDANCE_MANAGE"],
},
// P2 迁移B2 教师域):班级管理列表页根路由(无尾斜杠)
// schema STUDENT_* 权限点不存在students 路由复用 CLASS_READ/CLASS_MANAGE
"/shell/teacher/classes": {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["CLASS_READ", "CLASS_MANAGE"],
},
// P2 迁移B2 教师域):学生管理列表页根路由(无尾斜杠)
// 无 STUDENT_* 权限点,复用 CLASS_READ/CLASS_MANAGE学生隶属班级域
"/shell/teacher/students": {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["CLASS_READ", "CLASS_MANAGE"],
},
"/shell/teacher/diagnostics": {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["DIAGNOSTIC_READ", "DIAGNOSTIC_MANAGE"],
@@ -268,6 +280,22 @@ export const PREFIX_ROUTE_PERMISSIONS: Array<{
anyOfPermissions: ["ATTENDANCE_READ", "ATTENDANCE_MANAGE"],
},
},
// P2 迁移B2 教师域):班级管理子路由(含 /shell/teacher/classes/[id] /schedule
{
prefix: "/shell/teacher/classes/",
config: {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["CLASS_READ", "CLASS_MANAGE"],
},
},
// P2 迁移B2 教师域):学生管理子路由(预留,未来 /shell/teacher/students/[id]
{
prefix: "/shell/teacher/students/",
config: {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["CLASS_READ", "CLASS_MANAGE"],
},
},
// 班级管理
{
prefix: "/shell/admin/classes/",