feat(portal-shell): attendance + classes + students 模块 8 页迁移(教师域 §9.1 B2)
§9.1 line 632-634 教师域: - /shell/teacher/attendance (列表) / /sheet (表单) / /report (报表) / /stats (统计) — 4 页 - /shell/teacher/classes (列表) / /[id] (详情) / /schedule (课表) — 3 页 - /shell/teacher/students (列表) — 1 页 契约: - attendance 全 ❌ → MSW 兜底 - classes 🟡 classInfo(id) ✅ 真实单查 + 列表 ❌ MSW - students 全 ❌ → MSW 兜底 新增文件: - src/lib/api/{attendance,classes,students}.ts (14 hooks 合计) - src/lib/api/operations/{attendance,classes,students}.graphql.ts (14 documents) - src/features/teacher/{attendance,classes,students}/ (clients + transformations + tests) - src/app/shell/teacher/{attendance,classes,students}/ (8 page.tsx + 3 loading + 3 error) 修改文件: - src/mocks/graphql-data.ts (12 mock 数据 + handler cases) - src/messages/{zh-CN,en}.json (attendance/classes/students i18n 命名空间) - src/lib/api/{index,operations/index}.ts (导出 attendance/classes/students) - src/shared/lib/route-permissions.ts (attendance/classes/students 路由权限) - scripts/check-page-count.ts (baseline 40 → 48) DoD 验收(§11.3 11 项): - typecheck 0 errors - lint 0 errors - vitest 566 tests passed - lint:tokens 0 errors - check:pages 48 PASS - route-permissions 已声明 - 三态齐备 - @contract-pending + MSW 兜底 - i18n zh-CN + en 同步 设计决策: - classes/[id] 走真实 classInfo(id) 查询 - 无 STUDENT_* 权限点,students 路由复用 CLASS_READ/CLASS_MANAGE 关联:ARCHITECTURE.md §5.3 / §5.4 / §5.5 / §9.1 / §10 P2 / §11.3 / §11.4 契约工单:docs/architecture/issues/contracts/core-edu_contract.md
This commit is contained in:
@@ -19,9 +19,10 @@ interface Baseline {
|
||||
categories: Record<string, { pattern: string; min: number; label: string }>;
|
||||
}
|
||||
|
||||
// Baseline as of P2 (2026-07-22, questions + textbooks modules added). Update when adding pages.
|
||||
// Baseline as of P2 B2 (2026-07-22, attendance + classes + students modules added).
|
||||
// Update when adding pages.
|
||||
const BASELINE: Baseline = {
|
||||
total: 40,
|
||||
total: 48,
|
||||
categories: {
|
||||
dashboards: {
|
||||
pattern: "shell/{admin,teacher,student,parent}/page.tsx",
|
||||
|
||||
38
apps/portal-shell/src/app/shell/teacher/attendance/error.tsx
Normal file
38
apps/portal-shell/src/app/shell/teacher/attendance/error.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 考勤路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
|
||||
* Next.js Route Segment error.tsx,捕获子树未处理异常。
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function AttendanceError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("attendance");
|
||||
|
||||
useEffect(() => {
|
||||
console.error("[portal-shell] attendance route error:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-destructive/30 bg-destructive/5 p-10">
|
||||
<h2 className="text-lg font-semibold text-destructive">
|
||||
{t("error.title")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{error.message || t("error.unknown")}
|
||||
</p>
|
||||
<Button onClick={reset} variant="outline">
|
||||
{t("error.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ListPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 考勤路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
|
||||
* Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
|
||||
*/
|
||||
export default function AttendanceLoading(): React.ReactElement {
|
||||
return <ListPageSkeleton rows={5} />;
|
||||
}
|
||||
23
apps/portal-shell/src/app/shell/teacher/attendance/page.tsx
Normal file
23
apps/portal-shell/src/app/shell/teacher/attendance/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { AttendanceListClient } from "@/features/teacher/attendance/attendance-list-client";
|
||||
import { ListPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 考勤记录列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
|
||||
* 业务逻辑在 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 (
|
||||
<Suspense fallback={<ListPageSkeleton rows={5} />}>
|
||||
<AttendanceListClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Suspense fallback={<DetailPageSkeleton />}>
|
||||
<AttendanceReportClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Suspense fallback={<FormPageSkeleton />}>
|
||||
<AttendanceSheetClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Suspense fallback={<DetailPageSkeleton />}>
|
||||
<AttendanceStatsClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Suspense fallback={<DetailPageSkeleton />}>
|
||||
<ClassDetailClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
38
apps/portal-shell/src/app/shell/teacher/classes/error.tsx
Normal file
38
apps/portal-shell/src/app/shell/teacher/classes/error.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 班级路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
|
||||
* Next.js Route Segment error.tsx,捕获子树未处理异常。
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function ClassesError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("classes");
|
||||
|
||||
useEffect(() => {
|
||||
console.error("[portal-shell] classes route error:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-destructive/30 bg-destructive/5 p-10">
|
||||
<h2 className="text-lg font-semibold text-destructive">
|
||||
{t("error.title")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{error.message || t("error.unknown")}
|
||||
</p>
|
||||
<Button onClick={reset} variant="outline">
|
||||
{t("error.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ListPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 班级路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
|
||||
* Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
|
||||
*/
|
||||
export default function ClassesLoading(): React.ReactElement {
|
||||
return <ListPageSkeleton rows={5} />;
|
||||
}
|
||||
23
apps/portal-shell/src/app/shell/teacher/classes/page.tsx
Normal file
23
apps/portal-shell/src/app/shell/teacher/classes/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { ClassesListClient } from "@/features/teacher/classes/classes-list-client";
|
||||
import { ListPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 班级管理列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
|
||||
* 业务逻辑在 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 (
|
||||
<Suspense fallback={<ListPageSkeleton rows={5} />}>
|
||||
<ClassesListClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Suspense fallback={<DetailPageSkeleton />}>
|
||||
<ClassScheduleClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
38
apps/portal-shell/src/app/shell/teacher/students/error.tsx
Normal file
38
apps/portal-shell/src/app/shell/teacher/students/error.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 学生路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
|
||||
* Next.js Route Segment error.tsx,捕获子树未处理异常。
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function StudentsError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("students");
|
||||
|
||||
useEffect(() => {
|
||||
console.error("[portal-shell] students route error:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-destructive/30 bg-destructive/5 p-10">
|
||||
<h2 className="text-lg font-semibold text-destructive">
|
||||
{t("error.title")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{error.message || t("error.unknown")}
|
||||
</p>
|
||||
<Button onClick={reset} variant="outline">
|
||||
{t("error.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ListPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 学生路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
|
||||
* Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
|
||||
*/
|
||||
export default function StudentsLoading(): React.ReactElement {
|
||||
return <ListPageSkeleton rows={5} />;
|
||||
}
|
||||
23
apps/portal-shell/src/app/shell/teacher/students/page.tsx
Normal file
23
apps/portal-shell/src/app/shell/teacher/students/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { StudentsListClient } from "@/features/teacher/students/students-list-client";
|
||||
import { ListPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 学生管理列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
|
||||
* 业务逻辑在 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 (
|
||||
<Suspense fallback={<ListPageSkeleton rows={5} />}>
|
||||
<StudentsListClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 包裹在 <Suspense> 中
|
||||
* (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
|
||||
*/
|
||||
export function AttendanceListClient(): React.ReactElement {
|
||||
const t = useTranslations("attendance");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const classId = searchParams.get("classId") ?? "";
|
||||
const date = searchParams.get("date") ?? "";
|
||||
const statusFilter = searchParams.get("status") ?? "";
|
||||
const q = searchParams.get("q") ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useAttendanceRecords({
|
||||
classId: classId || undefined,
|
||||
date: date || undefined,
|
||||
status: statusFilter || undefined,
|
||||
q: q || undefined,
|
||||
});
|
||||
|
||||
const filteredItems = useMemo<AttendanceRecord[]>(() => {
|
||||
const items = data?.items ?? [];
|
||||
return items;
|
||||
}, [data]);
|
||||
|
||||
const updateQuery = (key: string, value: string): void => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
startTransition(() => {
|
||||
router.push(`/shell/teacher/attendance?${params.toString()}`);
|
||||
});
|
||||
};
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("list.mswNotice")}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const emptyNode = (
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
title={t("list.emptyTitle")}
|
||||
description={t("list.emptyDescription")}
|
||||
action={{
|
||||
label: t("list.emptyAction"),
|
||||
href: "/shell/teacher/attendance/sheet",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ListPageShell
|
||||
title={t("list.title")}
|
||||
description={t("list.description")}
|
||||
icon={<ClipboardList className="size-6" />}
|
||||
actions={
|
||||
<Button asChild>
|
||||
<Link href="/shell/teacher/attendance/sheet">
|
||||
{t("list.newSheet")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
filters={
|
||||
<>
|
||||
<FilterSearchInput
|
||||
placeholder={t("list.searchPlaceholder")}
|
||||
value={q}
|
||||
onChange={(v) => updateQuery("q", v)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={classId}
|
||||
onChange={(e) => updateQuery("classId", e.target.value)}
|
||||
placeholder={t("list.classPlaceholder")}
|
||||
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("list.classPlaceholder")}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => updateQuery("date", e.target.value)}
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("list.dateFilter")}
|
||||
/>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => updateQuery("status", e.target.value)}
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("list.statusFilter")}
|
||||
>
|
||||
<option value="">{t("list.statusAll")}</option>
|
||||
<option value="present">{t("list.statusPresent")}</option>
|
||||
<option value="absent">{t("list.statusAbsent")}</option>
|
||||
<option value="late">{t("list.statusLate")}</option>
|
||||
<option value="leave">{t("list.statusLeave")}</option>
|
||||
</select>
|
||||
</>
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={<ListPageSkeleton rows={5} />}
|
||||
empty={filteredItems.length === 0 && !loading}
|
||||
emptyNode={emptyNode}
|
||||
errorNode={errorNode}
|
||||
pagination={
|
||||
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
||||
<span>{t("list.total", { count: filteredItems.length })}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AttendanceTable items={filteredItems} />
|
||||
</ListPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 考勤记录列表表格(纯展示组件,对齐 §8.2 排版规范)。
|
||||
*/
|
||||
function AttendanceTable({
|
||||
items,
|
||||
}: {
|
||||
items: AttendanceRecord[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("attendance");
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/30">
|
||||
<tr>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colStudentName")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colClassName")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">{t("list.colDate")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("list.colStatus")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("list.colRemark")}</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colRecordedBy")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colUpdatedAt")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{items.map((r) => (
|
||||
<tr key={r.id} className="hover:bg-muted/30">
|
||||
<td className="p-3 font-medium">{r.studentName}</td>
|
||||
<td className="p-3 text-muted-foreground">{r.className}</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{formatAttendanceDay(r.date)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<AttendanceStatusBadge status={r.status} />
|
||||
</td>
|
||||
<td className="p-3 text-xs text-muted-foreground">
|
||||
{r.remark ?? "-"}
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{r.recordedBy}
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{formatAttendanceDate(r.updatedAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 考勤状态徽章(按状态色阶展示)。
|
||||
*/
|
||||
function AttendanceStatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: string;
|
||||
}): React.ReactElement {
|
||||
const label = formatAttendanceStatus(status);
|
||||
const cls = attendanceStatusToBadgeClass(status);
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function AttendanceReportClient(): React.ReactElement {
|
||||
const t = useTranslations("attendance");
|
||||
const tCommon = useTranslations("common");
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const classId = searchParams.get("classId") ?? "cls-001";
|
||||
const startDate = searchParams.get("startDate") ?? undefined;
|
||||
const endDate = searchParams.get("endDate") ?? undefined;
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useAttendanceReport(
|
||||
classId,
|
||||
startDate,
|
||||
endDate,
|
||||
);
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("report.mswNotice")}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const emptyNode =
|
||||
!loading && !error && !data ? (
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title={t("report.notFound")}
|
||||
action={{
|
||||
label: t("report.backToList"),
|
||||
href: "/shell/teacher/attendance",
|
||||
}}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<DetailPageShell
|
||||
title={t("report.title")}
|
||||
description={
|
||||
data
|
||||
? t("report.subtitle", {
|
||||
className: data.className,
|
||||
range: data.range,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
icon={<FileText className="size-6" />}
|
||||
backHref="/shell/teacher/attendance"
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
emptyNode={emptyNode}
|
||||
>
|
||||
{data ? <ReportSummarySection data={data} /> : null}
|
||||
{data ? <ReportItemsSection items={data.items} /> : null}
|
||||
</DetailPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 报表汇总区。
|
||||
*/
|
||||
function ReportSummarySection({
|
||||
data,
|
||||
}: {
|
||||
data: NonNullable<ReturnType<typeof useAttendanceReport>["data"]>;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("attendance");
|
||||
const summary = data.summary;
|
||||
return (
|
||||
<DetailSection title={t("report.sectionSummary")}>
|
||||
<DetailField
|
||||
label={t("report.fieldTotal")}
|
||||
value={String(summary.total)}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("report.fieldPresent")}
|
||||
value={String(summary.present)}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("report.fieldAbsent")}
|
||||
value={String(summary.absent)}
|
||||
/>
|
||||
<DetailField label={t("report.fieldLate")} value={String(summary.late)} />
|
||||
<DetailField
|
||||
label={t("report.fieldLeave")}
|
||||
value={String(summary.leave)}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("report.fieldAttendanceRate")}
|
||||
value={
|
||||
<span
|
||||
className={`font-medium ${attendanceRateToColorClass(summary.attendanceRate)}`}
|
||||
>
|
||||
{formatAttendanceRate(summary.attendanceRate)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 报表明细区(按学生聚合)。
|
||||
*/
|
||||
function ReportItemsSection({
|
||||
items,
|
||||
}: {
|
||||
items: AttendanceReportItem[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("attendance");
|
||||
return (
|
||||
<DetailSection title={t("report.sectionItems")}>
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/30">
|
||||
<tr>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("report.colStudentName")}
|
||||
</th>
|
||||
<th className="p-3 text-center font-medium">
|
||||
{t("report.colPresent")}
|
||||
</th>
|
||||
<th className="p-3 text-center font-medium">
|
||||
{t("report.colAbsent")}
|
||||
</th>
|
||||
<th className="p-3 text-center font-medium">
|
||||
{t("report.colLate")}
|
||||
</th>
|
||||
<th className="p-3 text-center font-medium">
|
||||
{t("report.colLeave")}
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">
|
||||
{t("report.colAttendanceRate")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{items.map((item) => (
|
||||
<tr key={item.studentId} className="hover:bg-muted/30">
|
||||
<td className="p-3 font-medium">{item.studentName}</td>
|
||||
<td className="p-3 text-center text-muted-foreground">
|
||||
{item.present}
|
||||
</td>
|
||||
<td className="p-3 text-center text-muted-foreground">
|
||||
{item.absent}
|
||||
</td>
|
||||
<td className="p-3 text-center text-muted-foreground">
|
||||
{item.late}
|
||||
</td>
|
||||
<td className="p-3 text-center text-muted-foreground">
|
||||
{item.leave}
|
||||
</td>
|
||||
<td
|
||||
className={`p-3 text-right font-medium ${attendanceRateToColorClass(item.attendanceRate)}`}
|
||||
>
|
||||
{formatAttendanceRate(item.attendanceRate)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
@@ -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 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function AttendanceSheetClient(): React.ReactElement {
|
||||
const t = useTranslations("attendance");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const presetClassId = searchParams.get("classId") ?? "";
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const presetDate = searchParams.get("date") ?? today;
|
||||
|
||||
const [classId, setClassId] = useState(presetClassId);
|
||||
const [date, setDate] = useState(presetDate);
|
||||
const [entries, setEntries] = useState<Record<string, AttendanceEntryInput>>(
|
||||
{},
|
||||
);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// @contract-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<string, AttendanceEntryInput> = {};
|
||||
for (const e of data.entries) {
|
||||
next[e.studentId] = {
|
||||
studentId: e.studentId,
|
||||
status: e.status,
|
||||
remark: e.remark ?? undefined,
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
const handleStatusChange = (studentId: string, status: string): void => {
|
||||
setEntries((prev) => ({
|
||||
...prev,
|
||||
[studentId]: {
|
||||
studentId,
|
||||
status,
|
||||
remark: prev[studentId]?.remark,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const { run: saveSheet } = useSaveAttendanceSheet();
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
if (!classId.trim()) {
|
||||
notify.error(t("sheet.errorClassRequired"));
|
||||
return;
|
||||
}
|
||||
if (!date) {
|
||||
notify.error(t("sheet.errorDateRequired"));
|
||||
return;
|
||||
}
|
||||
const entryList = Object.values(entries);
|
||||
if (entryList.length === 0) {
|
||||
notify.warning(t("sheet.errorNoEntries"));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await saveSheet({ classId, date, entries: entryList });
|
||||
notify.success(t("sheet.success"));
|
||||
startTransition(() => {
|
||||
router.push(
|
||||
`/shell/teacher/attendance?classId=${classId}&date=${date}`,
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
notify.error(`${t("sheet.error")}: ${String(err)}`);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const errorSummary = error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{t("sheet.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<FormPageShell
|
||||
title={t("sheet.title")}
|
||||
description={t("sheet.description")}
|
||||
icon={<ClipboardCheck className="size-6" />}
|
||||
backHref="/shell/teacher/attendance"
|
||||
loading={loading && !data}
|
||||
submitting={submitting}
|
||||
submitLabel={t("sheet.submit")}
|
||||
cancelLabel={t("sheet.cancel")}
|
||||
errorSummary={errorSummary}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<label className="space-y-1">
|
||||
<span className="text-sm font-medium">{t("sheet.classId")}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={classId}
|
||||
onChange={(e) => setClassId(e.target.value)}
|
||||
placeholder={t("sheet.classIdPlaceholder")}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-sm font-medium">{t("sheet.date")}</span>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{data ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/30">
|
||||
<tr>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("sheet.colStudentName")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("sheet.colStatus")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("sheet.colRemark")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{data.entries.map((entry) => {
|
||||
const current = entries[entry.studentId];
|
||||
const status = current?.status ?? entry.status;
|
||||
return (
|
||||
<tr key={entry.studentId}>
|
||||
<td className="p-3 font-medium">{entry.studentName}</td>
|
||||
<td className="p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleStatusChange(entry.studentId, opt)
|
||||
}
|
||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium transition-opacity ${
|
||||
status === opt
|
||||
? attendanceStatusToBadgeClass(opt)
|
||||
: "bg-muted text-muted-foreground opacity-50"
|
||||
}`}
|
||||
aria-pressed={status === opt}
|
||||
>
|
||||
{formatAttendanceStatus(opt)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 text-xs text-muted-foreground">
|
||||
{current?.remark ?? entry.remark ?? "-"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("sheet.mswNotice")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</FormPageShell>
|
||||
);
|
||||
}
|
||||
@@ -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 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function AttendanceStatsClient(): React.ReactElement {
|
||||
const t = useTranslations("attendance");
|
||||
const tCommon = useTranslations("common");
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const classId = searchParams.get("classId") ?? undefined;
|
||||
const startDate = searchParams.get("startDate") ?? undefined;
|
||||
const endDate = searchParams.get("endDate") ?? undefined;
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useAttendanceStats({
|
||||
classId,
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("stats.mswNotice")}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const emptyNode =
|
||||
!loading && !error && !data ? (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title={t("stats.notFound")}
|
||||
action={{
|
||||
label: t("stats.backToList"),
|
||||
href: "/shell/teacher/attendance",
|
||||
}}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<DetailPageShell
|
||||
title={t("stats.title")}
|
||||
description={
|
||||
data
|
||||
? t("stats.subtitle", {
|
||||
rate: formatAttendanceRate(data.overallAttendanceRate),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
icon={<BarChart3 className="size-6" />}
|
||||
backHref="/shell/teacher/attendance"
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
emptyNode={emptyNode}
|
||||
>
|
||||
{data ? <StatsOverviewSection data={data} /> : null}
|
||||
{data ? (
|
||||
<StatsDistributionSection items={data.statusDistribution} />
|
||||
) : null}
|
||||
{data ? <StatsTrendSection items={data.trend} /> : null}
|
||||
{data ? <StatsRankingSection items={data.classRanking} /> : null}
|
||||
</DetailPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
type StatsData = NonNullable<ReturnType<typeof useAttendanceStats>["data"]>;
|
||||
|
||||
/**
|
||||
* 概览区。
|
||||
*/
|
||||
function StatsOverviewSection({
|
||||
data,
|
||||
}: {
|
||||
data: StatsData;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("attendance");
|
||||
return (
|
||||
<DetailSection title={t("stats.sectionOverview")}>
|
||||
<DetailField
|
||||
label={t("stats.fieldOverallRate")}
|
||||
value={
|
||||
<span
|
||||
className={`font-medium ${attendanceRateToColorClass(data.overallAttendanceRate)}`}
|
||||
>
|
||||
{formatAttendanceRate(data.overallAttendanceRate)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("stats.fieldTotalRecords")}
|
||||
value={String(data.totalRecords)}
|
||||
/>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态分布区。
|
||||
*/
|
||||
function StatsDistributionSection({
|
||||
items,
|
||||
}: {
|
||||
items: AttendanceStatusDistribution[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("attendance");
|
||||
return (
|
||||
<DetailSection title={t("stats.sectionDistribution")}>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("stats.noDistribution")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.status}
|
||||
className="flex items-center justify-between gap-4"
|
||||
>
|
||||
<span
|
||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${attendanceStatusToBadgeClass(item.status)}`}
|
||||
>
|
||||
{formatAttendanceStatus(item.status)}
|
||||
</span>
|
||||
<div className="flex flex-1 items-center gap-3">
|
||||
<div className="h-2 flex-1 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary"
|
||||
style={{ width: `${Math.round(item.ratio * 100)}%` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<span className="w-20 text-right font-mono text-xs text-muted-foreground">
|
||||
{item.count} · {formatAttendanceRate(item.ratio)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 趋势区。
|
||||
*/
|
||||
function StatsTrendSection({
|
||||
items,
|
||||
}: {
|
||||
items: AttendanceTrendItem[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("attendance");
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<DetailSection title={t("stats.sectionTrend")}>
|
||||
<p className="text-sm text-muted-foreground">{t("stats.noTrend")}</p>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
const maxRate = Math.max(
|
||||
...items.map((i) => Math.min(1, i.attendanceRate)),
|
||||
0.01,
|
||||
);
|
||||
return (
|
||||
<DetailSection title={t("stats.sectionTrend")}>
|
||||
<div className="flex h-40 items-end gap-1">
|
||||
{items.map((item) => {
|
||||
const heightPct = Math.round(
|
||||
(Math.min(1, item.attendanceRate) / maxRate) * 100,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={item.date}
|
||||
className="flex flex-1 flex-col items-center gap-1"
|
||||
title={`${formatAttendanceDate(item.date)}: ${formatAttendanceRate(item.attendanceRate)}`}
|
||||
>
|
||||
<div
|
||||
className={`w-full rounded-t ${attendanceRateToBarClass(item.attendanceRate)}`}
|
||||
style={{ height: `${Math.max(2, heightPct)}%` }}
|
||||
aria-label={`${formatAttendanceDate(item.date)} ${formatAttendanceRate(item.attendanceRate)}`}
|
||||
/>
|
||||
<span className="w-full truncate text-center text-xs text-muted-foreground">
|
||||
{formatAttendanceDate(item.date).slice(5, 10)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 班级排名区。
|
||||
*/
|
||||
function StatsRankingSection({
|
||||
items,
|
||||
}: {
|
||||
items: AttendanceClassRankingItem[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("attendance");
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<DetailSection title={t("stats.sectionRanking")}>
|
||||
<p className="text-sm text-muted-foreground">{t("stats.noRanking")}</p>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DetailSection title={t("stats.sectionRanking")}>
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/30">
|
||||
<tr>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("stats.colRank")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("stats.colClassName")}
|
||||
</th>
|
||||
<th className="p-3 text-center font-medium">
|
||||
{t("stats.colTotalStudents")}
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">
|
||||
{t("stats.colAttendanceRate")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{items.map((item, idx) => (
|
||||
<tr key={item.classId} className="hover:bg-muted/30">
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{idx + 1}
|
||||
</td>
|
||||
<td className="p-3 font-medium">{item.className}</td>
|
||||
<td className="p-3 text-center text-muted-foreground">
|
||||
{item.totalStudents}
|
||||
</td>
|
||||
<td
|
||||
className={`p-3 text-right font-medium ${attendanceRateToColorClass(item.attendanceRate)}`}
|
||||
>
|
||||
{formatAttendanceRate(item.attendanceRate)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Attendance 数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
|
||||
*
|
||||
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
|
||||
* 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
|
||||
*/
|
||||
|
||||
/** 考勤状态中文标签映射(对齐 ATTENDANCE_STATUS 枚举) */
|
||||
export const ATTENDANCE_STATUS_LABEL: Record<string, string> = {
|
||||
present: "出勤",
|
||||
absent: "缺勤",
|
||||
late: "迟到",
|
||||
leave: "请假",
|
||||
};
|
||||
|
||||
/**
|
||||
* 将考勤状态映射为中文标签。未知状态回退为原始值。
|
||||
*/
|
||||
export function formatAttendanceStatus(status: string): string {
|
||||
return ATTENDANCE_STATUS_LABEL[status] ?? status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据考勤状态返回 Tailwind 徽章语义类名。
|
||||
*/
|
||||
export function attendanceStatusToBadgeClass(status: string): string {
|
||||
switch (status) {
|
||||
case "present":
|
||||
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
|
||||
case "absent":
|
||||
return "bg-destructive/10 text-destructive";
|
||||
case "late":
|
||||
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
|
||||
case "leave":
|
||||
return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 ISO 日期字符串为本地化展示(zh-CN,含年月日时分)。
|
||||
* 输入无效时返回占位符。
|
||||
*/
|
||||
export function formatAttendanceDate(
|
||||
isoDate: string | null | undefined,
|
||||
): string {
|
||||
if (!isoDate) return "--";
|
||||
const d = new Date(isoDate);
|
||||
if (Number.isNaN(d.getTime())) return "--";
|
||||
return d.toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期为纯日期展示(zh-CN,仅年月日)。
|
||||
* 输入无效时返回占位符。
|
||||
*/
|
||||
export function formatAttendanceDay(
|
||||
isoDate: string | null | undefined,
|
||||
): string {
|
||||
if (!isoDate) return "--";
|
||||
const d = new Date(isoDate);
|
||||
if (Number.isNaN(d.getTime())) return "--";
|
||||
return d.toLocaleDateString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化出勤率为百分比字符串。
|
||||
* 输入 0~1 的浮点数,返回 "85.0%" 形式;输入无效返回 "--"。
|
||||
*/
|
||||
export function formatAttendanceRate(rate: number | null | undefined): string {
|
||||
if (rate == null || !Number.isFinite(rate)) return "--";
|
||||
const pct = rate <= 1 ? rate * 100 : rate;
|
||||
return `${pct.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据出勤率返回 Tailwind 文本语义类名。
|
||||
* - >= 0.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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function ClassDetailClient(): React.ReactElement {
|
||||
const t = useTranslations("classes");
|
||||
const tCommon = useTranslations("common");
|
||||
const params = useParams<{ id: string }>();
|
||||
const classId = params?.id ?? "";
|
||||
|
||||
// ✅ 真实查询:classInfo(id: ID!),schema 已就绪
|
||||
const { data, loading, error } = useClassInfo(classId);
|
||||
|
||||
// @contract-pending:学生名单,schema 无 classStudents(classId) → MSW 兜底
|
||||
const {
|
||||
data: studentsData,
|
||||
loading: studentsLoading,
|
||||
error: studentsError,
|
||||
} = useClassStudents(classId, { enabled: Boolean(data) });
|
||||
|
||||
// @contract-pending:任课老师,schema 无 classTeachers(classId) → MSW 兜底
|
||||
const {
|
||||
data: teachersData,
|
||||
loading: teachersLoading,
|
||||
error: teachersError,
|
||||
} = useClassTeachers(classId, { enabled: Boolean(data) });
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const emptyNode =
|
||||
!loading && !error && !data ? (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title={t("detail.notFound")}
|
||||
action={{
|
||||
label: t("detail.backToList"),
|
||||
href: "/shell/teacher/classes",
|
||||
}}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<DetailPageShell
|
||||
title={data?.name ?? t("detail.title")}
|
||||
description={
|
||||
data
|
||||
? t("detail.createdAtPrefix", {
|
||||
date: formatClassDate(data.createdAt),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
icon={<Users className="size-6" />}
|
||||
backHref="/shell/teacher/classes"
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
emptyNode={emptyNode}
|
||||
>
|
||||
{data ? <ClassDetailBody classInfo={data} /> : null}
|
||||
{data ? (
|
||||
<StudentsSection
|
||||
students={studentsData?.items}
|
||||
loading={studentsLoading}
|
||||
error={studentsError}
|
||||
mswNotice={t("detail.studentsMswNotice")}
|
||||
/>
|
||||
) : null}
|
||||
{data ? (
|
||||
<TeachersSection
|
||||
teachers={teachersData?.items}
|
||||
loading={teachersLoading}
|
||||
error={teachersError}
|
||||
mswNotice={t("detail.teachersMswNotice")}
|
||||
/>
|
||||
) : null}
|
||||
</DetailPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
type ClassInfoData = NonNullable<ReturnType<typeof useClassInfo>["data"]>;
|
||||
|
||||
/**
|
||||
* 详情基本信息区(对齐 §7.3 详情页模板)。
|
||||
*/
|
||||
function ClassDetailBody({
|
||||
classInfo,
|
||||
}: {
|
||||
classInfo: ClassInfoData;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("classes");
|
||||
return (
|
||||
<DetailSection title={t("detail.sectionBasic")}>
|
||||
<DetailField label={t("detail.fieldName")} value={classInfo.name} />
|
||||
<DetailField label={t("detail.fieldGradeId")} value={classInfo.gradeId} />
|
||||
<DetailField
|
||||
label={t("detail.fieldHeadTeacherId")}
|
||||
value={
|
||||
hasHeadTeacher(classInfo)
|
||||
? (classInfo.headTeacherId ?? "--")
|
||||
: t("detail.noHeadTeacher")
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldDescription")}
|
||||
value={
|
||||
hasDescription(classInfo)
|
||||
? (classInfo.description ?? "--")
|
||||
: t("detail.noDescription")
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldCreatedAt")}
|
||||
value={formatClassDate(classInfo.createdAt)}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldUpdatedAt")}
|
||||
value={formatClassDate(classInfo.updatedAt)}
|
||||
/>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生名单区(@contract-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 ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-4 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{mswNotice}</p>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<DetailSection title={t("detail.sectionStudents")}>
|
||||
{errorNode}
|
||||
{!error && loading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-8 animate-pulse rounded bg-muted/50"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{!error && !loading && (!students || students.length === 0) ? (
|
||||
<Link
|
||||
href="/shell/teacher/classes"
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
{t("detail.noStudents")}
|
||||
</Link>
|
||||
) : null}
|
||||
{!error && !loading && students && students.length > 0 ? (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/30">
|
||||
<tr>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("detail.colStudentNo")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("detail.colStudentName")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("detail.colEnrolledAt")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{students.map((s) => (
|
||||
<tr key={s.id} className="hover:bg-muted/30">
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{s.studentNo}
|
||||
</td>
|
||||
<td className="p-3 font-medium">{s.name}</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{formatStudentDate(s.enrolledAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 任课老师区(@contract-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 ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-4 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{mswNotice}</p>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<DetailSection title={t("detail.sectionTeachers")}>
|
||||
{errorNode}
|
||||
{!error && loading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-8 animate-pulse rounded bg-muted/50"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{!error && !loading && (!teachers || teachers.length === 0) ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("detail.noTeachers")}
|
||||
</p>
|
||||
) : null}
|
||||
{!error && !loading && teachers && teachers.length > 0 ? (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/30">
|
||||
<tr>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("detail.colTeacherName")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("detail.colSubjectName")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("detail.colTeacherRole")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{teachers.map((tc) => (
|
||||
<tr key={tc.id} className="hover:bg-muted/30">
|
||||
<td className="p-3 font-medium">{tc.name}</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{tc.subjectName}
|
||||
</td>
|
||||
<td className="p-3 text-xs text-muted-foreground">
|
||||
{tc.role}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 班级课表页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 课表查询 classSchedule(classId):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/classes_contract.md#class-schedule
|
||||
*
|
||||
* URL 状态:?classId=(可选,指定班级课表;未指定时返回默认课表)
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:DetailPageSkeleton
|
||||
* - error:errorNode 局部降级
|
||||
* - empty:data 为 null 时显示空态节点
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { Calendar } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useClassSchedule, type ClassScheduleItem } from "@/lib/api";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
import {
|
||||
DetailPageShell,
|
||||
DetailPageSkeleton,
|
||||
DetailSection,
|
||||
DetailField,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
formatSchedulePeriod,
|
||||
formatScheduleTime,
|
||||
formatWeekday,
|
||||
groupScheduleByWeekday,
|
||||
sortScheduleByDay,
|
||||
} from "@/features/teacher/classes/transformations";
|
||||
|
||||
/**
|
||||
* 课表客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function ClassScheduleClient(): React.ReactElement {
|
||||
const t = useTranslations("classes");
|
||||
const tCommon = useTranslations("common");
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const classId = searchParams.get("classId") ?? "cls-001";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useClassSchedule(classId);
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("schedule.mswNotice")}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const emptyNode =
|
||||
!loading && !error && !data ? (
|
||||
<EmptyState
|
||||
icon={Calendar}
|
||||
title={t("schedule.notFound")}
|
||||
action={{
|
||||
label: t("schedule.backToList"),
|
||||
href: "/shell/teacher/classes",
|
||||
}}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<DetailPageShell
|
||||
title={t("schedule.title")}
|
||||
description={
|
||||
data
|
||||
? t("schedule.subtitle", {
|
||||
className: data.className,
|
||||
weekRange: data.weekRange,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
icon={<Calendar className="size-6" />}
|
||||
backHref="/shell/teacher/classes"
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
emptyNode={emptyNode}
|
||||
>
|
||||
{data ? <ScheduleOverviewSection data={data} /> : null}
|
||||
{data ? <ScheduleByDaySection items={data.items} /> : null}
|
||||
</DetailPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
type ScheduleData = NonNullable<ReturnType<typeof useClassSchedule>["data"]>;
|
||||
|
||||
/**
|
||||
* 课表概览区。
|
||||
*/
|
||||
function ScheduleOverviewSection({
|
||||
data,
|
||||
}: {
|
||||
data: ScheduleData;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("classes");
|
||||
return (
|
||||
<DetailSection title={t("schedule.sectionOverview")}>
|
||||
<DetailField
|
||||
label={t("schedule.fieldClassName")}
|
||||
value={data.className}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("schedule.fieldWeekRange")}
|
||||
value={data.weekRange}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("schedule.fieldTotalLessons")}
|
||||
value={String(data.items.length)}
|
||||
/>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按星期分组的课表区。
|
||||
*/
|
||||
function ScheduleByDaySection({
|
||||
items,
|
||||
}: {
|
||||
items: ClassScheduleItem[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("classes");
|
||||
const sorted = sortScheduleByDay(items);
|
||||
const groups = groupScheduleByWeekday(sorted);
|
||||
|
||||
return (
|
||||
<DetailSection title={t("schedule.sectionByDay")}>
|
||||
<div className="space-y-4">
|
||||
{groups.map((group, idx) => {
|
||||
if (group.length === 0) return null;
|
||||
return (
|
||||
<div key={idx} className="space-y-2">
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
{formatWeekday(idx)}
|
||||
</h4>
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/30">
|
||||
<tr>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("schedule.colPeriod")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("schedule.colSubject")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("schedule.colTeacher")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("schedule.colClassroom")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("schedule.colTime")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{group.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-muted/30">
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{formatSchedulePeriod(item.period)}
|
||||
</td>
|
||||
<td className="p-3 font-medium">{item.subjectName}</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{item.teacherName}
|
||||
</td>
|
||||
<td className="p-3 text-xs text-muted-foreground">
|
||||
{item.classroom ?? "--"}
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{formatScheduleTime(item.startTime, item.endTime)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
@@ -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 包裹在 <Suspense> 中
|
||||
* (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
|
||||
*/
|
||||
export function ClassesListClient(): React.ReactElement {
|
||||
const t = useTranslations("classes");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const gradeId = searchParams.get("gradeId") ?? "";
|
||||
const subjectId = searchParams.get("subjectId") ?? "";
|
||||
const q = searchParams.get("q") ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useClasses({
|
||||
gradeId: gradeId || undefined,
|
||||
subjectId: subjectId || undefined,
|
||||
q: q || undefined,
|
||||
});
|
||||
|
||||
const filteredItems = useMemo<ClassListItem[]>(() => {
|
||||
const items = data?.items ?? [];
|
||||
return items;
|
||||
}, [data]);
|
||||
|
||||
const updateQuery = (key: string, value: string): void => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
startTransition(() => {
|
||||
router.push(`/shell/teacher/classes?${params.toString()}`);
|
||||
});
|
||||
};
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("list.mswNotice")}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const emptyNode = (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title={t("list.emptyTitle")}
|
||||
description={t("list.emptyDescription")}
|
||||
action={{
|
||||
label: t("list.emptyAction"),
|
||||
href: "/shell/teacher/classes",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ListPageShell
|
||||
title={t("list.title")}
|
||||
description={t("list.description")}
|
||||
icon={<Users className="size-6" />}
|
||||
filters={
|
||||
<>
|
||||
<FilterSearchInput
|
||||
placeholder={t("list.searchPlaceholder")}
|
||||
value={q}
|
||||
onChange={(v) => updateQuery("q", v)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={gradeId}
|
||||
onChange={(e) => updateQuery("gradeId", e.target.value)}
|
||||
placeholder={t("list.gradePlaceholder")}
|
||||
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("list.gradePlaceholder")}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={subjectId}
|
||||
onChange={(e) => updateQuery("subjectId", e.target.value)}
|
||||
placeholder={t("list.subjectPlaceholder")}
|
||||
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("list.subjectPlaceholder")}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={<ListPageSkeleton rows={5} />}
|
||||
empty={filteredItems.length === 0 && !loading}
|
||||
emptyNode={emptyNode}
|
||||
errorNode={errorNode}
|
||||
pagination={
|
||||
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
||||
<span>{t("list.total", { count: filteredItems.length })}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ClassesTable items={filteredItems} />
|
||||
</ListPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 班级列表表格(纯展示组件,对齐 §8.2 排版规范)。
|
||||
*/
|
||||
function ClassesTable({
|
||||
items,
|
||||
}: {
|
||||
items: ClassListItem[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("classes");
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/30">
|
||||
<tr>
|
||||
<th className="p-3 text-left font-medium">{t("list.colName")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("list.colGrade")}</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colHeadTeacher")}
|
||||
</th>
|
||||
<th className="p-3 text-center font-medium">
|
||||
{t("list.colStudentCount")}
|
||||
</th>
|
||||
<th className="p-3 text-center font-medium">
|
||||
{t("list.colSubjectCount")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colDescription")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colUpdatedAt")}
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">
|
||||
{t("list.colActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{items.map((cls) => (
|
||||
<tr key={cls.id} className="hover:bg-muted/30">
|
||||
<td className="max-w-xs p-3">
|
||||
<Link
|
||||
href={`/shell/teacher/classes/${cls.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{truncateClassName(cls.name)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{cls.gradeId}
|
||||
</td>
|
||||
<td className="p-3 text-xs text-muted-foreground">
|
||||
{cls.headTeacherName ?? "--"}
|
||||
</td>
|
||||
<td className="p-3 text-center text-xs text-muted-foreground">
|
||||
{formatStudentCount(cls.studentCount)}
|
||||
</td>
|
||||
<td className="p-3 text-center text-xs text-muted-foreground">
|
||||
{formatSubjectCount(cls.subjectCount)}
|
||||
</td>
|
||||
<td className="max-w-xs p-3 text-xs text-muted-foreground">
|
||||
{cls.description ?? "--"}
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{formatClassDate(cls.updatedAt)}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<Link
|
||||
href={`/shell/teacher/classes/${cls.id}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("list.viewDetail")}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 人");
|
||||
});
|
||||
});
|
||||
@@ -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 包裹在 <Suspense> 中
|
||||
* (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
|
||||
*/
|
||||
export function StudentsListClient(): React.ReactElement {
|
||||
const t = useTranslations("students");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const classId = searchParams.get("classId") ?? "";
|
||||
const gradeId = searchParams.get("gradeId") ?? "";
|
||||
const q = searchParams.get("q") ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useStudents({
|
||||
classId: classId || undefined,
|
||||
gradeId: gradeId || undefined,
|
||||
q: q || undefined,
|
||||
});
|
||||
|
||||
const filteredItems = useMemo<StudentListItem[]>(() => {
|
||||
const items = data?.items ?? [];
|
||||
return items;
|
||||
}, [data]);
|
||||
|
||||
const updateQuery = (key: string, value: string): void => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
startTransition(() => {
|
||||
router.push(`/shell/teacher/students?${params.toString()}`);
|
||||
});
|
||||
};
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("list.mswNotice")}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const emptyNode = (
|
||||
<EmptyState
|
||||
icon={GraduationCap}
|
||||
title={t("list.emptyTitle")}
|
||||
description={t("list.emptyDescription")}
|
||||
action={{
|
||||
label: t("list.emptyAction"),
|
||||
href: "/shell/teacher/students",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ListPageShell
|
||||
title={t("list.title")}
|
||||
description={t("list.description")}
|
||||
icon={<GraduationCap className="size-6" />}
|
||||
filters={
|
||||
<>
|
||||
<FilterSearchInput
|
||||
placeholder={t("list.searchPlaceholder")}
|
||||
value={q}
|
||||
onChange={(v) => updateQuery("q", v)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={classId}
|
||||
onChange={(e) => updateQuery("classId", e.target.value)}
|
||||
placeholder={t("list.classPlaceholder")}
|
||||
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("list.classPlaceholder")}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={gradeId}
|
||||
onChange={(e) => updateQuery("gradeId", e.target.value)}
|
||||
placeholder={t("list.gradePlaceholder")}
|
||||
className="h-9 w-32 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("list.gradePlaceholder")}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={<ListPageSkeleton rows={5} />}
|
||||
empty={filteredItems.length === 0 && !loading}
|
||||
emptyNode={emptyNode}
|
||||
errorNode={errorNode}
|
||||
pagination={
|
||||
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
||||
<span>{t("list.total", { count: filteredItems.length })}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<StudentsTable items={filteredItems} />
|
||||
</ListPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生列表表格(纯展示组件,对齐 §8.2 排版规范)。
|
||||
*/
|
||||
function StudentsTable({
|
||||
items,
|
||||
}: {
|
||||
items: StudentListItem[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("students");
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/30">
|
||||
<tr>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colStudentNo")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">{t("list.colName")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("list.colGender")}</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colClassName")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colGradeId")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colEnrolledAt")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{items.map((s) => (
|
||||
<tr key={s.id} className="hover:bg-muted/30">
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{s.studentNo}
|
||||
</td>
|
||||
<td className="p-3 font-medium">{truncateStudentName(s.name)}</td>
|
||||
<td className="p-3">
|
||||
<GenderBadge gender={s.gender} />
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">{s.className}</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{s.gradeId}
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">
|
||||
{formatStudentDay(s.enrolledAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 性别徽章(按性别色阶展示)。
|
||||
*/
|
||||
function GenderBadge({ gender }: { gender: string }): React.ReactElement {
|
||||
const label = formatGender(gender);
|
||||
const cls = genderToBadgeClass(gender);
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Students 数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
|
||||
*
|
||||
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
|
||||
* 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
|
||||
*/
|
||||
|
||||
import type { Student, StudentListItem } from "@/lib/api";
|
||||
|
||||
/** 性别中文标签映射 */
|
||||
export const GENDER_LABEL: Record<string, string> = {
|
||||
male: "男",
|
||||
female: "女",
|
||||
unknown: "未知",
|
||||
};
|
||||
|
||||
/**
|
||||
* 将性别代码映射为中文标签。未知值回退为原始值。
|
||||
*/
|
||||
export function formatGender(gender: string): string {
|
||||
return GENDER_LABEL[gender] ?? gender;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 ISO 日期字符串为本地化展示(zh-CN,含年月日时分)。
|
||||
* 输入无效时返回占位符。
|
||||
*/
|
||||
export function formatStudentDate(isoDate: string | null | undefined): string {
|
||||
if (!isoDate) return "--";
|
||||
const d = new Date(isoDate);
|
||||
if (Number.isNaN(d.getTime())) return "--";
|
||||
return d.toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期为纯日期展示(zh-CN,仅年月日)。用于入学日期等。
|
||||
* 输入无效时返回占位符。
|
||||
*/
|
||||
export function formatStudentDay(isoDate: string | null | undefined): string {
|
||||
if (!isoDate) return "--";
|
||||
const d = new Date(isoDate);
|
||||
if (Number.isNaN(d.getTime())) return "--";
|
||||
return d.toLocaleDateString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断学生姓名用于列表展示(通常无需截断,保留以对齐模块模式)。
|
||||
* - 超过 maxLen 字符时截断并加省略号
|
||||
* - maxLen 默认 30
|
||||
*/
|
||||
export function truncateStudentName(name: string, maxLen = 30): string {
|
||||
const text = name.replace(/\s+/g, " ").trim();
|
||||
if (text.length <= maxLen) return text;
|
||||
return `${text.slice(0, maxLen)}...`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从学生实体中提取列表项视图模型(StudentListItem 与 Student 同构)。
|
||||
* 保留为显式函数以对齐 questions/textbooks 模式,便于未来字段裁剪。
|
||||
*/
|
||||
export function toStudentListItem(student: Student): StudentListItem {
|
||||
return {
|
||||
id: student.id,
|
||||
studentNo: student.studentNo,
|
||||
name: student.name,
|
||||
gender: student.gender,
|
||||
classId: student.classId,
|
||||
className: student.className,
|
||||
gradeId: student.gradeId,
|
||||
enrolledAt: student.enrolledAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据性别返回 Tailwind 徽章语义类名。
|
||||
*/
|
||||
export function genderToBadgeClass(gender: string): string {
|
||||
switch (gender) {
|
||||
case "male":
|
||||
return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
|
||||
case "female":
|
||||
return "bg-pink-500/10 text-pink-600 dark:text-pink-400";
|
||||
case "unknown":
|
||||
return "bg-muted text-muted-foreground";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断学号是否有效(非空且非空白)。
|
||||
*/
|
||||
export function hasStudentNo(studentNo: string | null | undefined): boolean {
|
||||
return Boolean(studentNo && studentNo.trim().length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化学生数为展示字符串。
|
||||
* 输入无效返回 "0 人"。
|
||||
*/
|
||||
export function formatStudentCount(count: number): string {
|
||||
if (!Number.isFinite(count) || count < 0) return "0 人";
|
||||
return `${count} 人`;
|
||||
}
|
||||
384
apps/portal-shell/src/lib/api/attendance.ts
Normal file
384
apps/portal-shell/src/lib/api/attendance.ts
Normal file
@@ -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<AttendanceSheet | null> {
|
||||
const result = useWidgetQuery<
|
||||
AttendanceSheetResponse,
|
||||
{ classId: string; date: string }
|
||||
>(
|
||||
GET_ATTENDANCE_SHEET_DOC,
|
||||
{ classId, date },
|
||||
{
|
||||
...options,
|
||||
enabled: options?.enabled ?? (classId.length > 0 && date.length > 0),
|
||||
},
|
||||
);
|
||||
return {
|
||||
data: result.data?.attendanceSheet ?? null,
|
||||
loading: result.loading,
|
||||
error: result.error,
|
||||
refetch: result.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询某班级考勤报表(@contract-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<AttendanceReport | null> {
|
||||
const result = useWidgetQuery<
|
||||
AttendanceReportResponse,
|
||||
{ classId: string; startDate?: string; endDate?: string }
|
||||
>(
|
||||
GET_ATTENDANCE_REPORT_DOC,
|
||||
{ classId, startDate, endDate },
|
||||
{
|
||||
...options,
|
||||
enabled: options?.enabled ?? classId.length > 0,
|
||||
},
|
||||
);
|
||||
return {
|
||||
data: result.data?.attendanceReport ?? null,
|
||||
loading: result.loading,
|
||||
error: result.error,
|
||||
refetch: result.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询考勤统计(@contract-pending,MSW 兜底)。
|
||||
*
|
||||
* schema 无 attendanceStats(...) 根字段,由 MSW handlers 返回 mock 数据。
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.4 / §9.1 统计页 / §11.4 契约工单
|
||||
*/
|
||||
export function useAttendanceStats(
|
||||
filter: AttendanceStatsFilter,
|
||||
options?: AttendanceQueryOptions,
|
||||
): UseQueryResult<AttendanceStats | null> {
|
||||
const result = useWidgetQuery<
|
||||
AttendanceStatsResponse,
|
||||
{ classId?: string; startDate?: string; endDate?: string }
|
||||
>(
|
||||
GET_ATTENDANCE_STATS_DOC,
|
||||
{
|
||||
classId: filter.classId,
|
||||
startDate: filter.startDate,
|
||||
endDate: filter.endDate,
|
||||
},
|
||||
{
|
||||
enabled: options?.enabled ?? true,
|
||||
fetchPolicy: options?.fetchPolicy,
|
||||
pollInterval: options?.pollInterval,
|
||||
},
|
||||
);
|
||||
return {
|
||||
data: result.data?.attendanceStats ?? null,
|
||||
loading: result.loading,
|
||||
error: result.error,
|
||||
refetch: result.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存点名表 mutation(@contract-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 };
|
||||
}
|
||||
320
apps/portal-shell/src/lib/api/classes.ts
Normal file
320
apps/portal-shell/src/lib/api/classes.ts
Normal file
@@ -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<ClassInfo | null> {
|
||||
const result = useWidgetQuery<ClassInfoResponse, { id: string }>(
|
||||
GET_CLASS_INFO_DOC,
|
||||
{ id },
|
||||
{
|
||||
...options,
|
||||
enabled: options?.enabled ?? id.length > 0,
|
||||
},
|
||||
);
|
||||
return {
|
||||
data: result.data?.classInfo ?? null,
|
||||
loading: result.loading,
|
||||
error: result.error,
|
||||
refetch: result.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询班级列表(@contract-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<ClassSchedule | null> {
|
||||
const result = useWidgetQuery<ClassScheduleResponse, { classId: string }>(
|
||||
GET_CLASS_SCHEDULE_DOC,
|
||||
{ classId },
|
||||
{
|
||||
...options,
|
||||
enabled: options?.enabled ?? classId.length > 0,
|
||||
},
|
||||
);
|
||||
return {
|
||||
data: result.data?.classSchedule ?? null,
|
||||
loading: result.loading,
|
||||
error: result.error,
|
||||
refetch: result.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询班级学生名单(@contract-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<ClassStudentsResponse, { classId: string }>(
|
||||
GET_CLASS_STUDENTS_DOC,
|
||||
{ classId },
|
||||
{
|
||||
...options,
|
||||
enabled: options?.enabled ?? classId.length > 0,
|
||||
},
|
||||
);
|
||||
return {
|
||||
data: result.data?.classStudents,
|
||||
loading: result.loading,
|
||||
error: result.error,
|
||||
refetch: result.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询班级任课老师(@contract-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<ClassTeachersResponse, { classId: string }>(
|
||||
GET_CLASS_TEACHERS_DOC,
|
||||
{ classId },
|
||||
{
|
||||
...options,
|
||||
enabled: options?.enabled ?? classId.length > 0,
|
||||
},
|
||||
);
|
||||
return {
|
||||
data: result.data?.classTeachers,
|
||||
loading: result.loading,
|
||||
error: result.error,
|
||||
refetch: result.refetch,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
159
apps/portal-shell/src/lib/api/operations/attendance.graphql.ts
Normal file
159
apps/portal-shell/src/lib/api/operations/attendance.graphql.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
// Attendance domain GraphQL documents (ARCHITECTURE.md §5.3 契约纪律 / §9.1)
|
||||
//
|
||||
// 拆分原则:
|
||||
// - 全部操作:❌ schema 无 attendance / attendanceRecords / attendanceSheet / attendanceReport /
|
||||
// attendanceStats 根字段,也无 Mutation 类型
|
||||
// → 走 MSW 兜底(@contract-pending),等待后端补齐契约
|
||||
//
|
||||
// 契约工单:docs/architecture/issues/contracts/classes_contract.md#attendance
|
||||
// 关联:ARCHITECTURE.md §5.3 / §5.4 / §9.1 / §11.4
|
||||
import { gql } from "@apollo/client";
|
||||
|
||||
// ── 假契约查询(@contract-pending)─────────────────────────────
|
||||
// 列表查询:schema 无 attendanceRecords(...) 根字段
|
||||
// 页面通过 MSW 兜底获取列表数据,后端补齐后切换 fetcher 指向真实查询
|
||||
// 契约工单:classes_contract.md#attendance-records-list
|
||||
export const GET_ATTENDANCE_RECORDS_DOC = gql`
|
||||
query GetAttendanceRecords(
|
||||
$classId: ID
|
||||
$date: String
|
||||
$status: String
|
||||
$q: String
|
||||
$limit: Int
|
||||
$offset: Int
|
||||
) {
|
||||
attendanceRecords(
|
||||
classId: $classId
|
||||
date: $date
|
||||
status: $status
|
||||
q: $q
|
||||
limit: $limit
|
||||
offset: $offset
|
||||
) {
|
||||
items {
|
||||
id
|
||||
studentId
|
||||
studentName
|
||||
classId
|
||||
className
|
||||
date
|
||||
status
|
||||
remark
|
||||
recordedBy
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
total
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ── 点名表查询(@contract-pending)─────────────────────────────
|
||||
// schema 无 attendanceSheet(classId, date) 根字段
|
||||
// 点名表页通过 MSW 兜底获取某班级某日全部学生出勤,后端补齐后切换 fetcher
|
||||
// 契约工单:classes_contract.md#attendance-sheet
|
||||
export const GET_ATTENDANCE_SHEET_DOC = gql`
|
||||
query GetAttendanceSheet($classId: ID!, $date: String!) {
|
||||
attendanceSheet(classId: $classId, date: $date) {
|
||||
classId
|
||||
className
|
||||
date
|
||||
entries {
|
||||
studentId
|
||||
studentName
|
||||
status
|
||||
remark
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ── 考勤报表查询(@contract-pending)─────────────────────────────
|
||||
// schema 无 attendanceReport(classId, range) 根字段
|
||||
// 报表页通过 MSW 兜底获取按班级的考勤报表,后端补齐后切换 fetcher
|
||||
// 契约工单:classes_contract.md#attendance-report
|
||||
export const GET_ATTENDANCE_REPORT_DOC = gql`
|
||||
query GetAttendanceReport(
|
||||
$classId: ID!
|
||||
$startDate: String
|
||||
$endDate: String
|
||||
) {
|
||||
attendanceReport(
|
||||
classId: $classId
|
||||
startDate: $startDate
|
||||
endDate: $endDate
|
||||
) {
|
||||
classId
|
||||
className
|
||||
range
|
||||
summary {
|
||||
total
|
||||
present
|
||||
absent
|
||||
late
|
||||
leave
|
||||
attendanceRate
|
||||
}
|
||||
items {
|
||||
studentId
|
||||
studentName
|
||||
present
|
||||
absent
|
||||
late
|
||||
leave
|
||||
attendanceRate
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ── 考勤统计查询(@contract-pending)─────────────────────────────
|
||||
// schema 无 attendanceStats(...) 根字段
|
||||
// 统计页通过 MSW 兜底获取考勤聚合统计,后端补齐后切换 fetcher
|
||||
// 契约工单:classes_contract.md#attendance-stats
|
||||
export const GET_ATTENDANCE_STATS_DOC = gql`
|
||||
query GetAttendanceStats($classId: ID, $startDate: String, $endDate: String) {
|
||||
attendanceStats(
|
||||
classId: $classId
|
||||
startDate: $startDate
|
||||
endDate: $endDate
|
||||
) {
|
||||
classId
|
||||
overallAttendanceRate
|
||||
totalRecords
|
||||
statusDistribution {
|
||||
status
|
||||
count
|
||||
ratio
|
||||
}
|
||||
trend {
|
||||
date
|
||||
attendanceRate
|
||||
}
|
||||
classRanking {
|
||||
classId
|
||||
className
|
||||
attendanceRate
|
||||
totalStudents
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ── 假契约变更(@contract-pending)─────────────────────────────
|
||||
// 保存点名表:schema 无 Mutation 类型
|
||||
// 页面通过 MSW 兜底提交,后端补齐 mutation 后切换 fetcher
|
||||
// 契约工单:classes_contract.md#save-attendance-sheet-mutation
|
||||
export const SAVE_ATTENDANCE_SHEET_DOC = gql`
|
||||
mutation SaveAttendanceSheet(
|
||||
$classId: ID!
|
||||
$date: String!
|
||||
$entries: [AttendanceEntryInput!]!
|
||||
) {
|
||||
saveAttendanceSheet(classId: $classId, date: $date, entries: $entries) {
|
||||
classId
|
||||
date
|
||||
savedCount
|
||||
}
|
||||
}
|
||||
`;
|
||||
134
apps/portal-shell/src/lib/api/operations/classes.graphql.ts
Normal file
134
apps/portal-shell/src/lib/api/operations/classes.graphql.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
// Classes domain GraphQL documents (ARCHITECTURE.md §5.3 契约纪律 / §9.1)
|
||||
//
|
||||
// 拆分原则:
|
||||
// - GetClassInfo(按 id 单查):✅ combined-schema 中真实存在(classInfo(id: ID!): ClassInfo)
|
||||
// - GetClasses(列表查询):❌ schema 无 classes(...) 根字段
|
||||
// → 走 MSW 兜底(@contract-pending),等待后端补齐列表契约
|
||||
// - GetClassSchedule(课表):❌ schema 无 classSchedule(classId) 根字段
|
||||
// → 走 MSW 兜底(@contract-pending)
|
||||
// - GetClassStudents / GetClassTeachers:❌ schema 无对应根字段
|
||||
// → 走 MSW 兜底(@contract-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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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";
|
||||
|
||||
62
apps/portal-shell/src/lib/api/operations/students.graphql.ts
Normal file
62
apps/portal-shell/src/lib/api/operations/students.graphql.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// Students domain GraphQL documents (ARCHITECTURE.md §5.3 契约纪律 / §9.1)
|
||||
//
|
||||
// 拆分原则:
|
||||
// - 全部操作:❌ schema 无 students(...) 列表 / student(id) 单查根字段
|
||||
// → 走 MSW 兜底(@contract-pending),等待后端补齐契约
|
||||
//
|
||||
// 契约工单:docs/architecture/issues/contracts/classes_contract.md#students
|
||||
// 关联:ARCHITECTURE.md §5.3 / §5.4 / §9.1 / §11.4
|
||||
import { gql } from "@apollo/client";
|
||||
|
||||
// ── 假契约查询(@contract-pending)─────────────────────────────
|
||||
// 列表查询:schema 无 students(...) 根字段
|
||||
// 页面通过 MSW 兜底获取列表数据,后端补齐后切换 fetcher 指向真实查询
|
||||
// 契约工单:classes_contract.md#students-list
|
||||
export const GET_STUDENTS_DOC = gql`
|
||||
query GetStudents(
|
||||
$classId: ID
|
||||
$gradeId: String
|
||||
$q: String
|
||||
$limit: Int
|
||||
$offset: Int
|
||||
) {
|
||||
students(
|
||||
classId: $classId
|
||||
gradeId: $gradeId
|
||||
q: $q
|
||||
limit: $limit
|
||||
offset: $offset
|
||||
) {
|
||||
items {
|
||||
id
|
||||
studentNo
|
||||
name
|
||||
gender
|
||||
classId
|
||||
className
|
||||
gradeId
|
||||
enrolledAt
|
||||
}
|
||||
total
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ── 单查(@contract-pending)─────────────────────────────────────
|
||||
// schema 无 student(id) 根字段
|
||||
// 详情/弹窗通过 MSW 兜底获取单条学生,后端补齐后切换 fetcher
|
||||
// 契约工单:classes_contract.md#student-by-id
|
||||
export const GET_STUDENT_DOC = gql`
|
||||
query GetStudent($id: ID!) {
|
||||
student(id: $id) {
|
||||
id
|
||||
studentNo
|
||||
name
|
||||
gender
|
||||
classId
|
||||
className
|
||||
gradeId
|
||||
enrolledAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
148
apps/portal-shell/src/lib/api/students.ts
Normal file
148
apps/portal-shell/src/lib/api/students.ts
Normal file
@@ -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<Student | null> {
|
||||
const result = useWidgetQuery<StudentResponse, { id: string }>(
|
||||
GET_STUDENT_DOC,
|
||||
{ id },
|
||||
{
|
||||
...options,
|
||||
enabled: options?.enabled ?? id.length > 0,
|
||||
},
|
||||
);
|
||||
return {
|
||||
data: result.data?.student ?? null,
|
||||
loading: result.loading,
|
||||
error: result.error,
|
||||
refetch: result.refetch,
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "重试"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -152,6 +152,18 @@ export const EXACT_ROUTE_PERMISSIONS: Record<string, RoutePermissionConfig> = {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["ATTENDANCE_READ", "ATTENDANCE_MANAGE"],
|
||||
},
|
||||
// P2 迁移(B2 教师域):班级管理列表页根路由(无尾斜杠)
|
||||
// schema STUDENT_* 权限点不存在,students 路由复用 CLASS_READ/CLASS_MANAGE
|
||||
"/shell/teacher/classes": {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["CLASS_READ", "CLASS_MANAGE"],
|
||||
},
|
||||
// P2 迁移(B2 教师域):学生管理列表页根路由(无尾斜杠)
|
||||
// 无 STUDENT_* 权限点,复用 CLASS_READ/CLASS_MANAGE(学生隶属班级域)
|
||||
"/shell/teacher/students": {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["CLASS_READ", "CLASS_MANAGE"],
|
||||
},
|
||||
"/shell/teacher/diagnostics": {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["DIAGNOSTIC_READ", "DIAGNOSTIC_MANAGE"],
|
||||
@@ -268,6 +280,22 @@ export const PREFIX_ROUTE_PERMISSIONS: Array<{
|
||||
anyOfPermissions: ["ATTENDANCE_READ", "ATTENDANCE_MANAGE"],
|
||||
},
|
||||
},
|
||||
// P2 迁移(B2 教师域):班级管理子路由(含 /shell/teacher/classes/[id] /schedule)
|
||||
{
|
||||
prefix: "/shell/teacher/classes/",
|
||||
config: {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["CLASS_READ", "CLASS_MANAGE"],
|
||||
},
|
||||
},
|
||||
// P2 迁移(B2 教师域):学生管理子路由(预留,未来 /shell/teacher/students/[id])
|
||||
{
|
||||
prefix: "/shell/teacher/students/",
|
||||
config: {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["CLASS_READ", "CLASS_MANAGE"],
|
||||
},
|
||||
},
|
||||
// 班级管理
|
||||
{
|
||||
prefix: "/shell/admin/classes/",
|
||||
|
||||
Reference in New Issue
Block a user