diff --git a/apps/portal-shell/scripts/check-page-count.ts b/apps/portal-shell/scripts/check-page-count.ts index 6454b17..dae4c7c 100644 --- a/apps/portal-shell/scripts/check-page-count.ts +++ b/apps/portal-shell/scripts/check-page-count.ts @@ -19,9 +19,10 @@ interface Baseline { categories: Record; } -// 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", diff --git a/apps/portal-shell/src/app/shell/teacher/attendance/error.tsx b/apps/portal-shell/src/app/shell/teacher/attendance/error.tsx new file mode 100644 index 0000000..4aa2f79 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/attendance/error.tsx @@ -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 ( +
+

+ {t("error.title")} +

+

+ {error.message || t("error.unknown")} +

+ +
+ ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/attendance/loading.tsx b/apps/portal-shell/src/app/shell/teacher/attendance/loading.tsx new file mode 100644 index 0000000..264535a --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/attendance/loading.tsx @@ -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 ; +} diff --git a/apps/portal-shell/src/app/shell/teacher/attendance/page.tsx b/apps/portal-shell/src/app/shell/teacher/attendance/page.tsx new file mode 100644 index 0000000..f39bc32 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/attendance/page.tsx @@ -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 要求)。 + * 业务逻辑在 AttendanceListClient(client 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 ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/attendance/report/page.tsx b/apps/portal-shell/src/app/shell/teacher/attendance/report/page.tsx new file mode 100644 index 0000000..f127d78 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/attendance/report/page.tsx @@ -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 要求)。 + * 业务逻辑在 AttendanceReportClient(client 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 ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/attendance/sheet/page.tsx b/apps/portal-shell/src/app/shell/teacher/attendance/sheet/page.tsx new file mode 100644 index 0000000..9e52a09 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/attendance/sheet/page.tsx @@ -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 要求)。 + * 业务逻辑在 AttendanceSheetClient(client 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 ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/attendance/stats/page.tsx b/apps/portal-shell/src/app/shell/teacher/attendance/stats/page.tsx new file mode 100644 index 0000000..d6aec84 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/attendance/stats/page.tsx @@ -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 要求)。 + * 业务逻辑在 AttendanceStatsClient(client 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 ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/classes/[id]/page.tsx b/apps/portal-shell/src/app/shell/teacher/classes/[id]/page.tsx new file mode 100644 index 0000000..00a2096 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/classes/[id]/page.tsx @@ -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 要求)。 + * 业务逻辑在 ClassDetailClient(client 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 ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/classes/error.tsx b/apps/portal-shell/src/app/shell/teacher/classes/error.tsx new file mode 100644 index 0000000..436e0a6 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/classes/error.tsx @@ -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 ( +
+

+ {t("error.title")} +

+

+ {error.message || t("error.unknown")} +

+ +
+ ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/classes/loading.tsx b/apps/portal-shell/src/app/shell/teacher/classes/loading.tsx new file mode 100644 index 0000000..5cd5630 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/classes/loading.tsx @@ -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 ; +} diff --git a/apps/portal-shell/src/app/shell/teacher/classes/page.tsx b/apps/portal-shell/src/app/shell/teacher/classes/page.tsx new file mode 100644 index 0000000..271e787 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/classes/page.tsx @@ -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 要求)。 + * 业务逻辑在 ClassesListClient(client 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 ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/classes/schedule/page.tsx b/apps/portal-shell/src/app/shell/teacher/classes/schedule/page.tsx new file mode 100644 index 0000000..4b1d740 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/classes/schedule/page.tsx @@ -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 要求)。 + * 业务逻辑在 ClassScheduleClient(client 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 ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/students/error.tsx b/apps/portal-shell/src/app/shell/teacher/students/error.tsx new file mode 100644 index 0000000..f4a381a --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/students/error.tsx @@ -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 ( +
+

+ {t("error.title")} +

+

+ {error.message || t("error.unknown")} +

+ +
+ ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/students/loading.tsx b/apps/portal-shell/src/app/shell/teacher/students/loading.tsx new file mode 100644 index 0000000..bf98b12 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/students/loading.tsx @@ -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 ; +} diff --git a/apps/portal-shell/src/app/shell/teacher/students/page.tsx b/apps/portal-shell/src/app/shell/teacher/students/page.tsx new file mode 100644 index 0000000..4bba0d3 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/students/page.tsx @@ -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 要求)。 + * 业务逻辑在 StudentsListClient(client 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 ( + }> + + + ); +} diff --git a/apps/portal-shell/src/features/teacher/attendance/__tests__/transformations.test.ts b/apps/portal-shell/src/features/teacher/attendance/__tests__/transformations.test.ts new file mode 100644 index 0000000..a6ac392 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/attendance/__tests__/transformations.test.ts @@ -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); + }); +}); diff --git a/apps/portal-shell/src/features/teacher/attendance/attendance-list-client.tsx b/apps/portal-shell/src/features/teacher/attendance/attendance-list-client.tsx new file mode 100644 index 0000000..4f2f828 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/attendance/attendance-list-client.tsx @@ -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 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState + 行动按钮) + * + * 关联: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 包裹在 中 + * (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-pending:MSW 兜底 + const { data, loading, error } = useAttendanceRecords({ + classId: classId || undefined, + date: date || undefined, + status: statusFilter || undefined, + q: q || undefined, + }); + + const filteredItems = useMemo(() => { + 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 ? ( +
+

+ {tCommon("error.loadFailed", { message: String(error) })} +

+

+ {t("list.mswNotice")} +

+
+ ) : undefined; + + const emptyNode = ( + + ); + + return ( + } + actions={ + + } + filters={ + <> + updateQuery("q", v)} + /> + 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")} + /> + updateQuery("date", e.target.value)} + className="h-9 rounded-md border border-input bg-background px-3 text-sm" + aria-label={t("list.dateFilter")} + /> + + + } + loading={loading} + loadingNode={} + empty={filteredItems.length === 0 && !loading} + emptyNode={emptyNode} + errorNode={errorNode} + pagination={ +
+ {t("list.total", { count: filteredItems.length })} +
+ } + > + +
+ ); +} + +/** + * 考勤记录列表表格(纯展示组件,对齐 §8.2 排版规范)。 + */ +function AttendanceTable({ + items, +}: { + items: AttendanceRecord[]; +}): React.ReactElement { + const t = useTranslations("attendance"); + return ( +
+ + + + + + + + + + + + + + {items.map((r) => ( + + + + + + + + + + ))} + +
+ {t("list.colStudentName")} + + {t("list.colClassName")} + {t("list.colDate")}{t("list.colStatus")}{t("list.colRemark")} + {t("list.colRecordedBy")} + + {t("list.colUpdatedAt")} +
{r.studentName}{r.className} + {formatAttendanceDay(r.date)} + + + + {r.remark ?? "-"} + + {r.recordedBy} + + {formatAttendanceDate(r.updatedAt)} +
+
+ ); +} + +/** + * 考勤状态徽章(按状态色阶展示)。 + */ +function AttendanceStatusBadge({ + status, +}: { + status: string; +}): React.ReactElement { + const label = formatAttendanceStatus(status); + const cls = attendanceStatusToBadgeClass(status); + return ( + + {label} + + ); +} diff --git a/apps/portal-shell/src/features/teacher/attendance/attendance-report-client.tsx b/apps/portal-shell/src/features/teacher/attendance/attendance-report-client.tsx new file mode 100644 index 0000000..6efc3e3 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/attendance/attendance-report-client.tsx @@ -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): + * - loading:DetailPageSkeleton + * - error:errorNode 局部降级 + * - empty:data 为 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 包裹在 中。 + */ +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-pending:MSW 兜底 + const { data, loading, error } = useAttendanceReport( + classId, + startDate, + endDate, + ); + + const errorNode = error ? ( +
+

+ {tCommon("error.loadFailed", { message: String(error) })} +

+

+ {t("report.mswNotice")} +

+
+ ) : undefined; + + const emptyNode = + !loading && !error && !data ? ( + + ) : undefined; + + return ( + } + backHref="/shell/teacher/attendance" + loading={loading} + loadingNode={} + errorNode={errorNode} + emptyNode={emptyNode} + > + {data ? : null} + {data ? : null} + + ); +} + +/** + * 报表汇总区。 + */ +function ReportSummarySection({ + data, +}: { + data: NonNullable["data"]>; +}): React.ReactElement { + const t = useTranslations("attendance"); + const summary = data.summary; + return ( + + + + + + + + {formatAttendanceRate(summary.attendanceRate)} + + } + /> + + ); +} + +/** + * 报表明细区(按学生聚合)。 + */ +function ReportItemsSection({ + items, +}: { + items: AttendanceReportItem[]; +}): React.ReactElement { + const t = useTranslations("attendance"); + return ( + +
+ + + + + + + + + + + + + {items.map((item) => ( + + + + + + + + + ))} + +
+ {t("report.colStudentName")} + + {t("report.colPresent")} + + {t("report.colAbsent")} + + {t("report.colLate")} + + {t("report.colLeave")} + + {t("report.colAttendanceRate")} +
{item.studentName} + {item.present} + + {item.absent} + + {item.late} + + {item.leave} + + {formatAttendanceRate(item.attendanceRate)} +
+
+
+ ); +} diff --git a/apps/portal-shell/src/features/teacher/attendance/attendance-sheet-client.tsx b/apps/portal-shell/src/features/teacher/attendance/attendance-sheet-client.tsx new file mode 100644 index 0000000..ff684e4 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/attendance/attendance-sheet-client.tsx @@ -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): + * - loading:FormPageSkeleton(由 server page Suspense 兜底) + * - error:errorSummary 表单级错误 + * - success:notify.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 包裹在 中。 + */ +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>( + {}, + ); + const [submitting, setSubmitting] = useState(false); + + // @contract-pending:MSW 兜底 + 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 = {}; + 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 => { + 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 ? ( +

+ {t("sheet.loadFailed", { message: String(error) })} +

+ ) : undefined; + + return ( + } + backHref="/shell/teacher/attendance" + loading={loading && !data} + submitting={submitting} + submitLabel={t("sheet.submit")} + cancelLabel={t("sheet.cancel")} + errorSummary={errorSummary} + onSubmit={handleSubmit} + > +
+
+ + +
+ + {data ? ( +
+
+ + + + + + + + + + {data.entries.map((entry) => { + const current = entries[entry.studentId]; + const status = current?.status ?? entry.status; + return ( + + + + + + ); + })} + +
+ {t("sheet.colStudentName")} + + {t("sheet.colStatus")} + + {t("sheet.colRemark")} +
{entry.studentName} +
+ {STATUS_OPTIONS.map((opt) => ( + + ))} +
+
+ {current?.remark ?? entry.remark ?? "-"} +
+
+

+ {t("sheet.mswNotice")} +

+
+ ) : null} +
+
+ ); +} diff --git a/apps/portal-shell/src/features/teacher/attendance/attendance-stats-client.tsx b/apps/portal-shell/src/features/teacher/attendance/attendance-stats-client.tsx new file mode 100644 index 0000000..18b92a7 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/attendance/attendance-stats-client.tsx @@ -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): + * - loading:DetailPageSkeleton + * - error:errorNode 局部降级 + * - empty:data 为 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 包裹在 中。 + */ +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-pending:MSW 兜底 + const { data, loading, error } = useAttendanceStats({ + classId, + startDate, + endDate, + }); + + const errorNode = error ? ( +
+

+ {tCommon("error.loadFailed", { message: String(error) })} +

+

+ {t("stats.mswNotice")} +

+
+ ) : undefined; + + const emptyNode = + !loading && !error && !data ? ( + + ) : undefined; + + return ( + } + backHref="/shell/teacher/attendance" + loading={loading} + loadingNode={} + errorNode={errorNode} + emptyNode={emptyNode} + > + {data ? : null} + {data ? ( + + ) : null} + {data ? : null} + {data ? : null} + + ); +} + +type StatsData = NonNullable["data"]>; + +/** + * 概览区。 + */ +function StatsOverviewSection({ + data, +}: { + data: StatsData; +}): React.ReactElement { + const t = useTranslations("attendance"); + return ( + + + {formatAttendanceRate(data.overallAttendanceRate)} + + } + /> + + + ); +} + +/** + * 状态分布区。 + */ +function StatsDistributionSection({ + items, +}: { + items: AttendanceStatusDistribution[]; +}): React.ReactElement { + const t = useTranslations("attendance"); + return ( + + {items.length === 0 ? ( +

+ {t("stats.noDistribution")} +

+ ) : ( +
+ {items.map((item) => ( +
+ + {formatAttendanceStatus(item.status)} + +
+
+ + + {item.count} · {formatAttendanceRate(item.ratio)} + +
+
+ ))} +
+ )} + + ); +} + +/** + * 趋势区。 + */ +function StatsTrendSection({ + items, +}: { + items: AttendanceTrendItem[]; +}): React.ReactElement { + const t = useTranslations("attendance"); + if (items.length === 0) { + return ( + +

{t("stats.noTrend")}

+
+ ); + } + const maxRate = Math.max( + ...items.map((i) => Math.min(1, i.attendanceRate)), + 0.01, + ); + return ( + +
+ {items.map((item) => { + const heightPct = Math.round( + (Math.min(1, item.attendanceRate) / maxRate) * 100, + ); + return ( +
+
+ + {formatAttendanceDate(item.date).slice(5, 10)} + +
+ ); + })} +
+ + ); +} + +/** + * 班级排名区。 + */ +function StatsRankingSection({ + items, +}: { + items: AttendanceClassRankingItem[]; +}): React.ReactElement { + const t = useTranslations("attendance"); + if (items.length === 0) { + return ( + +

{t("stats.noRanking")}

+
+ ); + } + return ( + +
+ + + + + + + + + + + {items.map((item, idx) => ( + + + + + + + ))} + +
+ {t("stats.colRank")} + + {t("stats.colClassName")} + + {t("stats.colTotalStudents")} + + {t("stats.colAttendanceRate")} +
+ {idx + 1} + {item.className} + {item.totalStudents} + + {formatAttendanceRate(item.attendanceRate)} +
+
+
+ ); +} diff --git a/apps/portal-shell/src/features/teacher/attendance/transformations.ts b/apps/portal-shell/src/features/teacher/attendance/transformations.ts new file mode 100644 index 0000000..7908a5f --- /dev/null +++ b/apps/portal-shell/src/features/teacher/attendance/transformations.ts @@ -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 = { + 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.95:emerald(优秀) + * - >= 0.9:primary(达标) + * - >= 0.8:amber(待改善) + * - < 0.8:destructive(需关注) + */ +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.95:emerald(优秀) + * - >= 0.9:primary(达标) + * - >= 0.8:amber(待改善) + * - < 0.8:destructive(需关注) + * + * 关联: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); +} diff --git a/apps/portal-shell/src/features/teacher/classes/__tests__/transformations.test.ts b/apps/portal-shell/src/features/teacher/classes/__tests__/transformations.test.ts new file mode 100644 index 0000000..ce3b8ab --- /dev/null +++ b/apps/portal-shell/src/features/teacher/classes/__tests__/transformations.test.ts @@ -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("高一\n(1) 班", 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); + }); +}); diff --git a/apps/portal-shell/src/features/teacher/classes/class-detail-client.tsx b/apps/portal-shell/src/features/teacher/classes/class-detail-client.tsx new file mode 100644 index 0000000..3b2e6eb --- /dev/null +++ b/apps/portal-shell/src/features/teacher/classes/class-detail-client.tsx @@ -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): + * - loading:DetailPageSkeleton + * - error:errorNode 局部降级 + * - notFound:data 为 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 包裹在 中。 + */ +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 ? ( +
+

+ {tCommon("error.loadFailed", { message: String(error) })} +

+
+ ) : undefined; + + const emptyNode = + !loading && !error && !data ? ( + + ) : undefined; + + return ( + } + backHref="/shell/teacher/classes" + loading={loading} + loadingNode={} + errorNode={errorNode} + emptyNode={emptyNode} + > + {data ? : null} + {data ? ( + + ) : null} + {data ? ( + + ) : null} + + ); +} + +type ClassInfoData = NonNullable["data"]>; + +/** + * 详情基本信息区(对齐 §7.3 详情页模板)。 + */ +function ClassDetailBody({ + classInfo, +}: { + classInfo: ClassInfoData; +}): React.ReactElement { + const t = useTranslations("classes"); + return ( + + + + + + + + + ); +} + +/** + * 学生名单区(@contract-pending,MSW 兜底)。 + */ +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 ? ( +
+

+ {tCommon("error.loadFailed", { message: String(error) })} +

+

{mswNotice}

+
+ ) : null; + + return ( + + {errorNode} + {!error && loading ? ( +
+ {[1, 2, 3].map((i) => ( + + ) : null} + {!error && !loading && (!students || students.length === 0) ? ( + + + {t("detail.noStudents")} + + ) : null} + {!error && !loading && students && students.length > 0 ? ( +
+ + + + + + + + + + {students.map((s) => ( + + + + + + ))} + +
+ {t("detail.colStudentNo")} + + {t("detail.colStudentName")} + + {t("detail.colEnrolledAt")} +
+ {s.studentNo} + {s.name} + {formatStudentDate(s.enrolledAt)} +
+
+ ) : null} + + ); +} + +/** + * 任课老师区(@contract-pending,MSW 兜底)。 + */ +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 ? ( +
+

+ {tCommon("error.loadFailed", { message: String(error) })} +

+

{mswNotice}

+
+ ) : null; + + return ( + + {errorNode} + {!error && loading ? ( +
+ {[1, 2].map((i) => ( + + ) : null} + {!error && !loading && (!teachers || teachers.length === 0) ? ( +

+ {t("detail.noTeachers")} +

+ ) : null} + {!error && !loading && teachers && teachers.length > 0 ? ( +
+ + + + + + + + + + {teachers.map((tc) => ( + + + + + + ))} + +
+ {t("detail.colTeacherName")} + + {t("detail.colSubjectName")} + + {t("detail.colTeacherRole")} +
{tc.name} + {tc.subjectName} + + {tc.role} +
+
+ ) : null} + + ); +} diff --git a/apps/portal-shell/src/features/teacher/classes/class-schedule-client.tsx b/apps/portal-shell/src/features/teacher/classes/class-schedule-client.tsx new file mode 100644 index 0000000..5549889 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/classes/class-schedule-client.tsx @@ -0,0 +1,198 @@ +"use client"; + +/** + * 班级课表页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2) + * + * 数据契约: + * - 课表查询 classSchedule(classId):❌ schema 无此字段 → MSW 兜底(@contract-pending) + * - 契约工单:docs/architecture/issues/contracts/classes_contract.md#class-schedule + * + * URL 状态:?classId=(可选,指定班级课表;未指定时返回默认课表) + * + * 三态规范(§11.3 DoD): + * - loading:DetailPageSkeleton + * - error:errorNode 局部降级 + * - empty:data 为 null 时显示空态节点 + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +import { Calendar } from "lucide-react"; +import { useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; + +import { useClassSchedule, type ClassScheduleItem } from "@/lib/api"; +import { EmptyState } from "@/shared/components/ui/empty-state"; +import { + DetailPageShell, + DetailPageSkeleton, + DetailSection, + DetailField, +} from "@/shared/components/page-templates"; +import { + formatSchedulePeriod, + formatScheduleTime, + formatWeekday, + groupScheduleByWeekday, + sortScheduleByDay, +} from "@/features/teacher/classes/transformations"; + +/** + * 课表客户端主体。需由 server page 包裹在 中。 + */ +export function ClassScheduleClient(): React.ReactElement { + const t = useTranslations("classes"); + const tCommon = useTranslations("common"); + const searchParams = useSearchParams(); + + const classId = searchParams.get("classId") ?? "cls-001"; + + // @contract-pending:MSW 兜底 + const { data, loading, error } = useClassSchedule(classId); + + const errorNode = error ? ( +
+

+ {tCommon("error.loadFailed", { message: String(error) })} +

+

+ {t("schedule.mswNotice")} +

+
+ ) : undefined; + + const emptyNode = + !loading && !error && !data ? ( + + ) : undefined; + + return ( + } + backHref="/shell/teacher/classes" + loading={loading} + loadingNode={} + errorNode={errorNode} + emptyNode={emptyNode} + > + {data ? : null} + {data ? : null} + + ); +} + +type ScheduleData = NonNullable["data"]>; + +/** + * 课表概览区。 + */ +function ScheduleOverviewSection({ + data, +}: { + data: ScheduleData; +}): React.ReactElement { + const t = useTranslations("classes"); + return ( + + + + + + ); +} + +/** + * 按星期分组的课表区。 + */ +function ScheduleByDaySection({ + items, +}: { + items: ClassScheduleItem[]; +}): React.ReactElement { + const t = useTranslations("classes"); + const sorted = sortScheduleByDay(items); + const groups = groupScheduleByWeekday(sorted); + + return ( + +
+ {groups.map((group, idx) => { + if (group.length === 0) return null; + return ( +
+

+ {formatWeekday(idx)} +

+
+ + + + + + + + + + + + {group.map((item) => ( + + + + + + + + ))} + +
+ {t("schedule.colPeriod")} + + {t("schedule.colSubject")} + + {t("schedule.colTeacher")} + + {t("schedule.colClassroom")} + + {t("schedule.colTime")} +
+ {formatSchedulePeriod(item.period)} + {item.subjectName} + {item.teacherName} + + {item.classroom ?? "--"} + + {formatScheduleTime(item.startTime, item.endTime)} +
+
+
+ ); + })} +
+
+ ); +} diff --git a/apps/portal-shell/src/features/teacher/classes/classes-list-client.tsx b/apps/portal-shell/src/features/teacher/classes/classes-list-client.tsx new file mode 100644 index 0000000..4f6b85d --- /dev/null +++ b/apps/portal-shell/src/features/teacher/classes/classes-list-client.tsx @@ -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 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState + 行动按钮) + * + * 关联: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 包裹在 中 + * (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-pending:MSW 兜底 + const { data, loading, error } = useClasses({ + gradeId: gradeId || undefined, + subjectId: subjectId || undefined, + q: q || undefined, + }); + + const filteredItems = useMemo(() => { + 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 ? ( +
+

+ {tCommon("error.loadFailed", { message: String(error) })} +

+

+ {t("list.mswNotice")} +

+
+ ) : undefined; + + const emptyNode = ( + + ); + + return ( + } + filters={ + <> + updateQuery("q", v)} + /> + 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")} + /> + 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={} + empty={filteredItems.length === 0 && !loading} + emptyNode={emptyNode} + errorNode={errorNode} + pagination={ +
+ {t("list.total", { count: filteredItems.length })} +
+ } + > + +
+ ); +} + +/** + * 班级列表表格(纯展示组件,对齐 §8.2 排版规范)。 + */ +function ClassesTable({ + items, +}: { + items: ClassListItem[]; +}): React.ReactElement { + const t = useTranslations("classes"); + return ( +
+ + + + + + + + + + + + + + + {items.map((cls) => ( + + + + + + + + + + + ))} + +
{t("list.colName")}{t("list.colGrade")} + {t("list.colHeadTeacher")} + + {t("list.colStudentCount")} + + {t("list.colSubjectCount")} + + {t("list.colDescription")} + + {t("list.colUpdatedAt")} + + {t("list.colActions")} +
+ + {truncateClassName(cls.name)} + + + {cls.gradeId} + + {cls.headTeacherName ?? "--"} + + {formatStudentCount(cls.studentCount)} + + {formatSubjectCount(cls.subjectCount)} + + {cls.description ?? "--"} + + {formatClassDate(cls.updatedAt)} + + + {t("list.viewDetail")} + +
+
+ ); +} diff --git a/apps/portal-shell/src/features/teacher/classes/transformations.ts b/apps/portal-shell/src/features/teacher/classes/transformations.ts new file mode 100644 index 0000000..f4a30a9 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/classes/transformations.ts @@ -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); +} diff --git a/apps/portal-shell/src/features/teacher/students/__tests__/transformations.test.ts b/apps/portal-shell/src/features/teacher/students/__tests__/transformations.test.ts new file mode 100644 index 0000000..266a9d6 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/students/__tests__/transformations.test.ts @@ -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 人"); + }); +}); diff --git a/apps/portal-shell/src/features/teacher/students/students-list-client.tsx b/apps/portal-shell/src/features/teacher/students/students-list-client.tsx new file mode 100644 index 0000000..7fe0acd --- /dev/null +++ b/apps/portal-shell/src/features/teacher/students/students-list-client.tsx @@ -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 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState + 行动按钮) + * + * 关联: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 包裹在 中 + * (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-pending:MSW 兜底 + const { data, loading, error } = useStudents({ + classId: classId || undefined, + gradeId: gradeId || undefined, + q: q || undefined, + }); + + const filteredItems = useMemo(() => { + 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 ? ( +
+

+ {tCommon("error.loadFailed", { message: String(error) })} +

+

+ {t("list.mswNotice")} +

+
+ ) : undefined; + + const emptyNode = ( + + ); + + return ( + } + filters={ + <> + updateQuery("q", v)} + /> + 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")} + /> + 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={} + empty={filteredItems.length === 0 && !loading} + emptyNode={emptyNode} + errorNode={errorNode} + pagination={ +
+ {t("list.total", { count: filteredItems.length })} +
+ } + > + +
+ ); +} + +/** + * 学生列表表格(纯展示组件,对齐 §8.2 排版规范)。 + */ +function StudentsTable({ + items, +}: { + items: StudentListItem[]; +}): React.ReactElement { + const t = useTranslations("students"); + return ( +
+ + + + + + + + + + + + + {items.map((s) => ( + + + + + + + + + ))} + +
+ {t("list.colStudentNo")} + {t("list.colName")}{t("list.colGender")} + {t("list.colClassName")} + + {t("list.colGradeId")} + + {t("list.colEnrolledAt")} +
+ {s.studentNo} + {truncateStudentName(s.name)} + + {s.className} + {s.gradeId} + + {formatStudentDay(s.enrolledAt)} +
+
+ ); +} + +/** + * 性别徽章(按性别色阶展示)。 + */ +function GenderBadge({ gender }: { gender: string }): React.ReactElement { + const label = formatGender(gender); + const cls = genderToBadgeClass(gender); + return ( + + {label} + + ); +} diff --git a/apps/portal-shell/src/features/teacher/students/transformations.ts b/apps/portal-shell/src/features/teacher/students/transformations.ts new file mode 100644 index 0000000..6f94e44 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/students/transformations.ts @@ -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 = { + 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} 人`; +} diff --git a/apps/portal-shell/src/lib/api/attendance.ts b/apps/portal-shell/src/lib/api/attendance.ts new file mode 100644 index 0000000..f078d6a --- /dev/null +++ b/apps/portal-shell/src/lib/api/attendance.ts @@ -0,0 +1,384 @@ +"use client"; + +/** + * Attendance domain API(ARCHITECTURE.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. useSaveAttendanceSheet(mutation):❌ 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-pending,MSW 提供形状) */ +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-pending,MSW 兜底)。 + * + * 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-pending,MSW 兜底)。 + * + * 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 { + 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-pending,MSW 兜底)。 + * + * 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 { + 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-pending,MSW 兜底)。 + * + * schema 无 attendanceStats(...) 根字段,由 MSW handlers 返回 mock 数据。 + * + * 关联:ARCHITECTURE.md §5.4 / §9.1 统计页 / §11.4 契约工单 + */ +export function useAttendanceStats( + filter: AttendanceStatsFilter, + options?: AttendanceQueryOptions, +): UseQueryResult { + 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-pending,MSW 兜底)。 + * + * 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 }; +} diff --git a/apps/portal-shell/src/lib/api/classes.ts b/apps/portal-shell/src/lib/api/classes.ts new file mode 100644 index 0000000..2951cbf --- /dev/null +++ b/apps/portal-shell/src/lib/api/classes.ts @@ -0,0 +1,320 @@ +"use client"; + +/** + * Classes domain API(ARCHITECTURE.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 { + const result = useWidgetQuery( + 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-pending,MSW 兜底)。 + * + * 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-pending,MSW 兜底)。 + * + * schema 无 classSchedule(classId) 根字段,ClassInfo 类型也无 schedule 字段。 + * 详情页课表通过 MSW 返回 mock 数据,后端补齐后切换 fetcher。 + * + * 关联:ARCHITECTURE.md §5.4 / §9.1 课表页 / §11.4 契约工单 + */ +export function useClassSchedule( + classId: string, + options?: ClassQueryOptions, +): UseQueryResult { + const result = useWidgetQuery( + 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-pending,MSW 兜底)。 + * + * 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( + 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-pending,MSW 兜底)。 + * + * 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( + 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, + }; +} diff --git a/apps/portal-shell/src/lib/api/index.ts b/apps/portal-shell/src/lib/api/index.ts index 2f4c90e..9945097 100644 --- a/apps/portal-shell/src/lib/api/index.ts +++ b/apps/portal-shell/src/lib/api/index.ts @@ -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"; diff --git a/apps/portal-shell/src/lib/api/operations/attendance.graphql.ts b/apps/portal-shell/src/lib/api/operations/attendance.graphql.ts new file mode 100644 index 0000000..b11a9d9 --- /dev/null +++ b/apps/portal-shell/src/lib/api/operations/attendance.graphql.ts @@ -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 + } + } +`; diff --git a/apps/portal-shell/src/lib/api/operations/classes.graphql.ts b/apps/portal-shell/src/lib/api/operations/classes.graphql.ts new file mode 100644 index 0000000..f0afa40 --- /dev/null +++ b/apps/portal-shell/src/lib/api/operations/classes.graphql.ts @@ -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-pending);ClassInfo 类型无 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 + } + } +`; diff --git a/apps/portal-shell/src/lib/api/operations/index.ts b/apps/portal-shell/src/lib/api/operations/index.ts index 17833c4..721dc96 100644 --- a/apps/portal-shell/src/lib/api/operations/index.ts +++ b/apps/portal-shell/src/lib/api/operations/index.ts @@ -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"; diff --git a/apps/portal-shell/src/lib/api/operations/students.graphql.ts b/apps/portal-shell/src/lib/api/operations/students.graphql.ts new file mode 100644 index 0000000..c97826a --- /dev/null +++ b/apps/portal-shell/src/lib/api/operations/students.graphql.ts @@ -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 + } + } +`; diff --git a/apps/portal-shell/src/lib/api/students.ts b/apps/portal-shell/src/lib/api/students.ts new file mode 100644 index 0000000..cf3e5ea --- /dev/null +++ b/apps/portal-shell/src/lib/api/students.ts @@ -0,0 +1,148 @@ +"use client"; + +/** + * Students domain API(ARCHITECTURE.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-pending,MSW 提供形状) ===== + +/** + * 学生实体(列表项与单查同构,@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-pending,MSW 兜底)。 + * + * 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-pending,MSW 兜底)。 + * + * schema 无 student(id) 根字段,由 MSW handlers 返回 mock 数据。 + * + * 关联:ARCHITECTURE.md §5.4 / §9.1 / §11.4 契约工单 + */ +export function useStudent( + id: string, + options?: StudentQueryOptions, +): UseQueryResult { + const result = useWidgetQuery( + 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, + }; +} diff --git a/apps/portal-shell/src/messages/en.json b/apps/portal-shell/src/messages/en.json index c078df0..3cc3d1f 100644 --- a/apps/portal-shell/src/messages/en.json +++ b/apps/portal-shell/src/messages/en.json @@ -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" + } } } diff --git a/apps/portal-shell/src/messages/zh-CN.json b/apps/portal-shell/src/messages/zh-CN.json index d761ded..9c267e9 100644 --- a/apps/portal-shell/src/messages/zh-CN.json +++ b/apps/portal-shell/src/messages/zh-CN.json @@ -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": "重试" + } } } diff --git a/apps/portal-shell/src/mocks/graphql-data.ts b/apps/portal-shell/src/mocks/graphql-data.ts index 3a6d424..236f229 100644 --- a/apps/portal-shell/src/mocks/graphql-data.ts +++ b/apps/portal-shell/src/mocks/graphql-data.ts @@ -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", + }, +]; + +// mockClassInfoDetail:classInfo(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 单查(真实 schema,MSW 也兜底以便 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 { diff --git a/apps/portal-shell/src/shared/lib/route-permissions.ts b/apps/portal-shell/src/shared/lib/route-permissions.ts index 362a2b9..1588dda 100644 --- a/apps/portal-shell/src/shared/lib/route-permissions.ts +++ b/apps/portal-shell/src/shared/lib/route-permissions.ts @@ -152,6 +152,18 @@ export const EXACT_ROUTE_PERMISSIONS: Record = { 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/",