diff --git a/apps/portal-shell/scripts/check-page-count.ts b/apps/portal-shell/scripts/check-page-count.ts index e509dfa..f7c5058 100644 --- a/apps/portal-shell/scripts/check-page-count.ts +++ b/apps/portal-shell/scripts/check-page-count.ts @@ -19,9 +19,9 @@ interface Baseline { categories: Record; } -// Baseline as of P1-8 (2026-07-22). Update when adding pages. +// Baseline as of P2 (2026-07-22, homework module added). Update when adding pages. const BASELINE: Baseline = { - total: 13, + total: 26, categories: { dashboards: { pattern: "shell/{admin,teacher,student,parent}/page.tsx", diff --git a/apps/portal-shell/src/app/shell/teacher/homework/[id]/page.tsx b/apps/portal-shell/src/app/shell/teacher/homework/[id]/page.tsx new file mode 100644 index 0000000..81ff768 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/homework/[id]/page.tsx @@ -0,0 +1,25 @@ +import { Suspense } from "react"; + +import { HomeworkDetailClient } from "@/features/teacher/homework/homework-detail-client"; +import { DetailPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 作业详情页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2) + * + * Server Component 入口:仅负责 Suspense 边界包裹。 + * 业务逻辑在 HomeworkDetailClient(client component)中。 + * + * 数据契约: + * - 单查 homework(id: ID!) ✅ schema 真实字段(core-edu 子图) + * - 提交列表 homeworkSubmissions(homeworkId) ❌ schema 无此字段 → MSW 兜底 + * - mutation recordGrade(input) ❌ schema 无 Mutation → MSW 兜底 + * + * 关联:ARCHITECTURE.md §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +export default function HomeworkDetailPage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/homework/assignments/[id]/submissions/page.tsx b/apps/portal-shell/src/app/shell/teacher/homework/assignments/[id]/submissions/page.tsx new file mode 100644 index 0000000..ff6d09c --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/homework/assignments/[id]/submissions/page.tsx @@ -0,0 +1,24 @@ +import { Suspense } from "react"; + +import { AssignmentSubmissionsClient } from "@/features/teacher/homework/assignment-submissions-client"; +import { ListPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 按作业批量批改页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2) + * + * Server Component 入口:仅负责 Suspense 边界包裹。 + * 业务逻辑在 AssignmentSubmissionsClient(client component)中。 + * + * 数据契约: + * - 查询 assignmentSubmissions(homeworkId) ❌ schema 无此字段 → MSW 兜底(@contract-pending) + * - 查询 aiBatchGrading(homeworkId) ❌ schema 无此字段 → MSW 兜底 + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +export default function AssignmentSubmissionsPage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/homework/error.tsx b/apps/portal-shell/src/app/shell/teacher/homework/error.tsx new file mode 100644 index 0000000..9c3db0f --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/homework/error.tsx @@ -0,0 +1,38 @@ +"use client"; + +/** + * 作业路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。 + * Next.js Route Segment error.tsx,捕获子树未处理异常。 + */ +import { useEffect } from "react"; + +import { Button } from "@/shared/components/ui/button"; +import { useTranslations } from "next-intl"; + +export default function HomeworkError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}): React.ReactElement { + const t = useTranslations("homework"); + + useEffect(() => { + console.error("[portal-shell] homework route error:", error); + }, [error]); + + return ( +
+

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

+

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

+ +
+ ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/homework/loading.tsx b/apps/portal-shell/src/app/shell/teacher/homework/loading.tsx new file mode 100644 index 0000000..a07518b --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/homework/loading.tsx @@ -0,0 +1,12 @@ +import { ListPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 作业路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。 + * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。 + * + * 子页面(详情/批改/扫描批改)的 Skeleton 由各自 server page 的 兜底, + * 本文件仅在 /shell/teacher/homework 列表/重定向期间显示。 + */ +export default function HomeworkLoading(): React.ReactElement { + return ; +} diff --git a/apps/portal-shell/src/app/shell/teacher/homework/new/page.tsx b/apps/portal-shell/src/app/shell/teacher/homework/new/page.tsx new file mode 100644 index 0000000..8321093 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/homework/new/page.tsx @@ -0,0 +1,23 @@ +import { Suspense } from "react"; + +import { NewHomeworkClient } from "@/features/teacher/homework/new-homework-client"; +import { FormPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 布置作业表单页(ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2) + * + * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。 + * 业务逻辑在 NewHomeworkClient(client component)中。 + * + * 数据契约:mutation assignHomework(input) ❌ schema 无 Mutation → MSW 兜底(@contract-pending) + * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#assign-homework-mutation + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +export default function NewHomeworkPage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/homework/page.tsx b/apps/portal-shell/src/app/shell/teacher/homework/page.tsx new file mode 100644 index 0000000..79b035b --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/homework/page.tsx @@ -0,0 +1,23 @@ +import { Suspense } from "react"; + +import { HomeworkListClient } from "@/features/teacher/homework/homework-list-client"; +import { ListPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 作业管理列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2) + * + * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。 + * 业务逻辑在 HomeworkListClient(client component)中。 + * + * 数据契约:列表查询 homeworks(classId) ❌ schema 无此字段 → MSW 兜底(@contract-pending) + * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#homework-list + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +export default function HomeworkListPage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/homework/submissions/[submissionId]/page.tsx b/apps/portal-shell/src/app/shell/teacher/homework/submissions/[submissionId]/page.tsx new file mode 100644 index 0000000..953415c --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/homework/submissions/[submissionId]/page.tsx @@ -0,0 +1,24 @@ +import { Suspense } from "react"; + +import { SubmissionGradingClient } from "@/features/teacher/homework/submission-grading-client"; +import { DetailPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 单份提交批改页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2) + * + * Server Component 入口:仅负责 Suspense 边界包裹。 + * 业务逻辑在 SubmissionGradingClient(client component)中。 + * + * 数据契约: + * - 单查 submissionDetail(submissionId) ❌ schema 无此字段 → MSW 兜底(@contract-pending) + * - mutation gradeSubmission(input) ❌ schema 无 Mutation → MSW 兜底 + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +export default function SubmissionGradingPage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/homework/submissions/[submissionId]/scan-grading/page.tsx b/apps/portal-shell/src/app/shell/teacher/homework/submissions/[submissionId]/scan-grading/page.tsx new file mode 100644 index 0000000..2baccf2 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/homework/submissions/[submissionId]/scan-grading/page.tsx @@ -0,0 +1,24 @@ +import { Suspense } from "react"; + +import { ScanGradingClient } from "@/features/teacher/homework/scan-grading-client"; +import { WorkbenchPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 扫描批改页(ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2) + * + * Server Component 入口:仅负责 Suspense 边界包裹。 + * 业务逻辑在 ScanGradingClient(client component)中。 + * + * 数据契约: + * - 单查 submissionDetail(submissionId) ❌ schema 无此字段 → MSW 兜底(@contract-pending) + * - mutation saveScanGrading(input) ❌ schema 无 Mutation → MSW 兜底 + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +export default function ScanGradingPage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/homework/submissions/page.tsx b/apps/portal-shell/src/app/shell/teacher/homework/submissions/page.tsx new file mode 100644 index 0000000..1002e98 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/homework/submissions/page.tsx @@ -0,0 +1,23 @@ +import { Suspense } from "react"; + +import { SubmissionsListClient } from "@/features/teacher/homework/submissions-list-client"; +import { ListPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 提交批改列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2) + * + * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。 + * 业务逻辑在 SubmissionsListClient(client component)中。 + * + * 数据契约:列表查询 homeworkSubmissions(filter) ❌ schema 无此字段 → MSW 兜底(@contract-pending) + * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#homework-submissions + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +export default function HomeworkSubmissionsPage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/features/teacher/homework/__tests__/transformations.test.ts b/apps/portal-shell/src/features/teacher/homework/__tests__/transformations.test.ts new file mode 100644 index 0000000..c8b8126 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/homework/__tests__/transformations.test.ts @@ -0,0 +1,373 @@ +/** + * Homework 数据变换工具单测(ARCHITECTURE.md §11.3 DoD) + * + * 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测" + */ +import { describe, expect, it } from "vitest"; + +import type { Homework } from "@/lib/api"; + +import { + HOMEWORK_STATUS_LABEL, + SUBMISSION_STATUS_LABEL, + calcSubmissionRate, + confidenceToColorClass, + formatConfidence, + formatDueDate, + formatGradeLevel, + formatGracePeriod, + formatHomeworkStatus, + formatScore, + formatSubmissionRate, + formatSubmissionStatus, + gradeLevelToColorClass, + homeworkStatusToBadgeClass, + isGraded, + isHomeworkEditable, + isHomeworkPublished, + isOverdue, + isPendingGrading, + submissionStatusToBadgeClass, + toHomeworkListItem, +} from "../transformations"; + +describe("formatHomeworkStatus", () => { + it("maps known statuses to Chinese labels", () => { + expect(formatHomeworkStatus("DRAFT")).toBe("草稿"); + expect(formatHomeworkStatus("PUBLISHED")).toBe("已发布"); + expect(formatHomeworkStatus("CLOSED")).toBe("已关闭"); + expect(formatHomeworkStatus("ARCHIVED")).toBe("已归档"); + }); + + it("returns original value for unknown status", () => { + expect(formatHomeworkStatus("UNKNOWN")).toBe("UNKNOWN"); + expect(formatHomeworkStatus("")).toBe(""); + }); + + it("HOMEWORK_STATUS_LABEL covers all standard statuses", () => { + expect(Object.keys(HOMEWORK_STATUS_LABEL)).toHaveLength(4); + }); +}); + +describe("formatSubmissionStatus", () => { + it("maps known statuses to Chinese labels", () => { + expect(formatSubmissionStatus("SUBMITTED")).toBe("已提交"); + expect(formatSubmissionStatus("GRADING")).toBe("批改中"); + expect(formatSubmissionStatus("GRADED")).toBe("已批改"); + expect(formatSubmissionStatus("RETURNED")).toBe("已退回"); + expect(formatSubmissionStatus("LATE")).toBe("迟交"); + }); + + it("returns original value for unknown status", () => { + expect(formatSubmissionStatus("UNKNOWN")).toBe("UNKNOWN"); + }); + + it("SUBMISSION_STATUS_LABEL covers all standard statuses", () => { + expect(Object.keys(SUBMISSION_STATUS_LABEL)).toHaveLength(5); + }); +}); + +describe("formatDueDate", () => { + it("formats valid ISO date string", () => { + const result = formatDueDate("2026-07-25T23:59:59Z"); + expect(result).toContain("2026"); + expect(result).toContain("07"); + }); + + it("returns placeholder for null/undefined/empty", () => { + expect(formatDueDate(null)).toBe("--"); + expect(formatDueDate(undefined)).toBe("--"); + expect(formatDueDate("")).toBe("--"); + }); + + it("returns placeholder for invalid date", () => { + expect(formatDueDate("not-a-date")).toBe("--"); + }); +}); + +describe("formatGracePeriod", () => { + it("formats hours under 24", () => { + expect(formatGracePeriod(12)).toBe("12 小时"); + expect(formatGracePeriod(1)).toBe("1 小时"); + }); + + it("formats exact days", () => { + expect(formatGracePeriod(24)).toBe("1 天"); + expect(formatGracePeriod(48)).toBe("2 天"); + }); + + it("formats days with remainder hours", () => { + expect(formatGracePeriod(36)).toBe("1 天 12 小时"); + expect(formatGracePeriod(25)).toBe("1 天 1 小时"); + }); + + it("returns placeholder for invalid or zero input", () => { + expect(formatGracePeriod(0)).toBe("无宽限期"); + expect(formatGracePeriod(-1)).toBe("无宽限期"); + expect(formatGracePeriod(Number.NaN)).toBe("无宽限期"); + }); +}); + +describe("isOverdue", () => { + it("returns true when dueDate is in the past", () => { + const past = "2020-01-01T00:00:00Z"; + expect(isOverdue(past, new Date("2026-07-22T00:00:00Z"))).toBe(true); + }); + + it("returns false when dueDate is in the future", () => { + const future = "2030-01-01T00:00:00Z"; + expect(isOverdue(future, new Date("2026-07-22T00:00:00Z"))).toBe(false); + }); + + it("returns false for null/undefined/invalid date", () => { + expect(isOverdue(null)).toBe(false); + expect(isOverdue(undefined)).toBe(false); + expect(isOverdue("not-a-date")).toBe(false); + }); + + it("returns false when dueDate equals now (not strictly past)", () => { + const now = new Date("2026-07-22T00:00:00Z"); + expect(isOverdue("2026-07-22T00:00:00Z", now)).toBe(false); + }); +}); + +describe("isHomeworkEditable / isHomeworkPublished", () => { + it("DRAFT is editable but not published", () => { + expect(isHomeworkEditable("DRAFT")).toBe(true); + expect(isHomeworkPublished("DRAFT")).toBe(false); + }); + + it("PUBLISHED is published but not editable", () => { + expect(isHomeworkEditable("PUBLISHED")).toBe(false); + expect(isHomeworkPublished("PUBLISHED")).toBe(true); + }); + + it("unknown status is neither editable nor published", () => { + expect(isHomeworkEditable("UNKNOWN")).toBe(false); + expect(isHomeworkPublished("UNKNOWN")).toBe(false); + }); +}); + +describe("toHomeworkListItem", () => { + it("extracts list fields from full homework", () => { + const hw: Homework = { + id: "hw-001", + classId: "cls-001", + subjectId: "sub-math", + title: "集合练习", + description: "完成教材练习", + dueDate: "2026-07-25T23:59:59Z", + gracePeriod: 24, + status: "PUBLISHED", + schoolId: "sch-001", + createdBy: "usr-001", + createdAt: "2026-07-20T00:00:00Z", + updatedAt: "2026-07-20T00:00:00Z", + }; + + const item = toHomeworkListItem(hw); + expect(item.id).toBe("hw-001"); + expect(item.title).toBe("集合练习"); + expect(item.status).toBe("PUBLISHED"); + expect(item).not.toHaveProperty("schoolId"); + expect(item).not.toHaveProperty("createdBy"); + expect(item).not.toHaveProperty("updatedAt"); + }); + + it("handles null description", () => { + const hw: Homework = { + id: "hw-002", + classId: "cls-001", + subjectId: "sub-math", + title: "无描述作业", + description: null, + dueDate: "2026-07-25T23:59:59Z", + gracePeriod: 12, + status: "DRAFT", + schoolId: "sch-001", + createdBy: "usr-001", + createdAt: "2026-07-20T00:00:00Z", + updatedAt: "2026-07-20T00:00:00Z", + }; + const item = toHomeworkListItem(hw); + expect(item.description).toBeNull(); + }); +}); + +describe("isGraded / isPendingGrading", () => { + it("GRADED and RETURNED are graded", () => { + expect(isGraded("GRADED")).toBe(true); + expect(isGraded("RETURNED")).toBe(true); + }); + + it("SUBMITTED and LATE are pending grading", () => { + expect(isPendingGrading("SUBMITTED")).toBe(true); + expect(isPendingGrading("LATE")).toBe(true); + }); + + it("GRADED is not pending", () => { + expect(isPendingGrading("GRADED")).toBe(false); + }); + + it("GRADING is neither graded nor pending", () => { + expect(isGraded("GRADING")).toBe(false); + expect(isPendingGrading("GRADING")).toBe(false); + }); +}); + +describe("formatScore", () => { + it("formats finite numbers with 1 decimal place", () => { + expect(formatScore(82.5)).toBe("82.5"); + expect(formatScore(98)).toBe("98.0"); + expect(formatScore(0)).toBe("0.0"); + }); + + it("returns placeholder for null/undefined", () => { + expect(formatScore(null)).toBe("--"); + expect(formatScore(undefined)).toBe("--"); + }); + + it("returns placeholder for non-finite input", () => { + expect(formatScore(Number.NaN)).toBe("--"); + expect(formatScore(Number.POSITIVE_INFINITY)).toBe("--"); + }); +}); + +describe("formatSubmissionRate", () => { + it("formats rate in [0,1] as percentage", () => { + expect(formatSubmissionRate(0.789)).toBe("79%"); + expect(formatSubmissionRate(0)).toBe("0%"); + expect(formatSubmissionRate(1)).toBe("100%"); + }); + + it("returns placeholder for out-of-range or non-finite input", () => { + expect(formatSubmissionRate(-0.1)).toBe("--"); + expect(formatSubmissionRate(1.1)).toBe("--"); + expect(formatSubmissionRate(Number.NaN)).toBe("--"); + }); +}); + +describe("calcSubmissionRate", () => { + it("calculates rate correctly", () => { + expect(calcSubmissionRate(30, 38)).toBeCloseTo(0.789, 2); + expect(calcSubmissionRate(0, 38)).toBe(0); + expect(calcSubmissionRate(38, 38)).toBe(1); + }); + + it("caps at 1 when submitted > total", () => { + expect(calcSubmissionRate(40, 38)).toBe(1); + }); + + it("returns 0 for zero or invalid total", () => { + expect(calcSubmissionRate(10, 0)).toBe(0); + expect(calcSubmissionRate(10, -1)).toBe(0); + }); + + it("returns 0 for negative submitted count", () => { + expect(calcSubmissionRate(-5, 38)).toBe(0); + }); +}); + +describe("formatGradeLevel", () => { + it("maps score rate to A/B/C/D levels", () => { + expect(formatGradeLevel(90, 100)).toBe("A"); + expect(formatGradeLevel(85, 100)).toBe("A"); + expect(formatGradeLevel(75, 100)).toBe("B"); + expect(formatGradeLevel(70, 100)).toBe("B"); + expect(formatGradeLevel(65, 100)).toBe("C"); + expect(formatGradeLevel(60, 100)).toBe("C"); + expect(formatGradeLevel(50, 100)).toBe("D"); + expect(formatGradeLevel(0, 100)).toBe("D"); + }); + + it("returns placeholder for null/undefined score", () => { + expect(formatGradeLevel(null, 100)).toBe("--"); + expect(formatGradeLevel(undefined, 100)).toBe("--"); + }); + + it("returns placeholder for zero or invalid maxScore", () => { + expect(formatGradeLevel(80, 0)).toBe("--"); + expect(formatGradeLevel(80, -1)).toBe("--"); + }); +}); + +describe("gradeLevelToColorClass", () => { + it("maps each level to correct color class", () => { + expect(gradeLevelToColorClass("A")).toBe("text-emerald-600"); + expect(gradeLevelToColorClass("B")).toBe("text-blue-600"); + expect(gradeLevelToColorClass("C")).toBe("text-amber-600"); + expect(gradeLevelToColorClass("D")).toBe("text-destructive"); + }); + + it("returns muted for unknown level", () => { + expect(gradeLevelToColorClass("X")).toBe("text-muted-foreground"); + expect(gradeLevelToColorClass("")).toBe("text-muted-foreground"); + }); +}); + +describe("submissionStatusToBadgeClass", () => { + it("returns correct badge class for each status", () => { + expect(submissionStatusToBadgeClass("SUBMITTED")).toContain("amber"); + expect(submissionStatusToBadgeClass("LATE")).toContain("amber"); + expect(submissionStatusToBadgeClass("GRADING")).toContain("primary"); + expect(submissionStatusToBadgeClass("GRADED")).toContain("emerald"); + expect(submissionStatusToBadgeClass("RETURNED")).toContain("blue"); + }); + + it("returns muted for unknown status", () => { + expect(submissionStatusToBadgeClass("UNKNOWN")).toBe( + "bg-muted text-muted-foreground", + ); + }); +}); + +describe("homeworkStatusToBadgeClass", () => { + it("returns correct badge class for each status", () => { + expect(homeworkStatusToBadgeClass("DRAFT")).toBe( + "bg-muted text-muted-foreground", + ); + expect(homeworkStatusToBadgeClass("PUBLISHED")).toContain("primary"); + expect(homeworkStatusToBadgeClass("CLOSED")).toContain("amber"); + }); + + it("returns muted for unknown status", () => { + expect(homeworkStatusToBadgeClass("UNKNOWN")).toBe( + "bg-muted text-muted-foreground", + ); + }); +}); + +describe("confidenceToColorClass", () => { + it("returns emerald for high confidence", () => { + expect(confidenceToColorClass(0.8)).toBe("text-emerald-600"); + expect(confidenceToColorClass(0.95)).toBe("text-emerald-600"); + }); + + it("returns amber for medium confidence", () => { + expect(confidenceToColorClass(0.6)).toBe("text-amber-600"); + expect(confidenceToColorClass(0.79)).toBe("text-amber-600"); + }); + + it("returns destructive for low confidence", () => { + expect(confidenceToColorClass(0.59)).toBe("text-destructive"); + expect(confidenceToColorClass(0)).toBe("text-destructive"); + }); + + it("returns muted for non-finite input", () => { + expect(confidenceToColorClass(Number.NaN)).toBe("text-muted-foreground"); + }); +}); + +describe("formatConfidence", () => { + it("formats confidence in [0,1] as percentage", () => { + expect(formatConfidence(0.92)).toBe("92%"); + expect(formatConfidence(0)).toBe("0%"); + expect(formatConfidence(1)).toBe("100%"); + }); + + it("returns placeholder for out-of-range or non-finite input", () => { + expect(formatConfidence(-0.1)).toBe("--"); + expect(formatConfidence(1.1)).toBe("--"); + expect(formatConfidence(Number.NaN)).toBe("--"); + }); +}); diff --git a/apps/portal-shell/src/features/teacher/homework/assignment-submissions-client.tsx b/apps/portal-shell/src/features/teacher/homework/assignment-submissions-client.tsx new file mode 100644 index 0000000..a6d1d28 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/homework/assignment-submissions-client.tsx @@ -0,0 +1,437 @@ +"use client"; + +/** + * 按作业批量批改页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2) + * + * 数据契约: + * - 查询 assignmentSubmissions(homeworkId):❌ schema 无此字段 → MSW 兜底(@contract-pending) + * - 查询 aiBatchGrading(homeworkId):❌ schema 无此字段 → MSW 兜底(@contract-pending) + * + * 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState) + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +import { Layers } from "lucide-react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useTranslations } from "next-intl"; + +import { + useAssignmentSubmissions, + useAiBatchGrading, + type AiGradingSuggestion, +} from "@/lib/api"; +import { Button } from "@/shared/components/ui/button"; +import { + ListPageShell, + ListPageSkeleton, +} from "@/shared/components/page-templates"; +import { notify } from "@/shared/lib/notify"; +import { + calcSubmissionRate, + confidenceToColorClass, + formatConfidence, + formatDueDate, + formatScore, + formatSubmissionRate, + formatSubmissionStatus, + submissionStatusToBadgeClass, +} from "@/features/teacher/homework/transformations"; + +/** + * 批量批改客户端主体。需由 server page 包裹在 中。 + */ +export function AssignmentSubmissionsClient(): React.ReactElement { + const t = useTranslations("homework"); + const tCommon = useTranslations("common"); + const params = useParams<{ id: string }>(); + const homeworkId = params?.id ?? ""; + + // @contract-pending:MSW 兜底 + const { data, loading, error } = useAssignmentSubmissions(homeworkId); + + const errorNode = error ? ( +
+

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

+

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

+
+ ) : undefined; + + const homeworkTitle = data?.homework.title ?? ""; + const stats = data?.stats; + const submissions = data?.submissions ?? []; + + return ( + } + actions={ + + } + loading={loading} + loadingNode={} + empty={!loading && !error && submissions.length === 0} + errorNode={errorNode} + pagination={ +
+ {t("assignment.total", { count: submissions.length })} +
+ } + > + {data ? ( + + ) : null} +
+ ); +} + +/** + * 主体:统计卡片 + AI 建议区 + 提交表格。 + */ +function AssignmentSubmissionsBody({ + homeworkId, + homeworkTitle, + dueDate, + className, + stats, + submissions, +}: { + homeworkId: string; + homeworkTitle: string; + dueDate: string; + className: string; + stats: + | { + totalStudents: number; + submittedCount: number; + gradedCount: number; + pendingCount: number; + avgScore: number; + submissionRate: number; + } + | undefined; + submissions: ReadonlyArray<{ + id: string; + homeworkId: string; + homeworkTitle: string; + studentId: string; + studentName: string; + studentNo: string; + classId: string; + className: string; + status: string; + submittedAt: string | null; + gradedAt: string | null; + gradedBy: string | null; + totalScore: number | null; + maxScore: number; + }>; +}): React.ReactElement { + const t = useTranslations("homework"); + return ( +
+ {/* 统计卡片区 */} + {stats ? : null} + + {/* 作业信息 */} +
+

+ {t("assignment.sectionHomework")} +

+
+ + + + +
+
+ + {/* AI 批量评分区 */} + + + {/* 提交列表 */} +
+
+

+ {t("assignment.sectionSubmissions")} +

+
+
+ + + + + + + + + + + + {submissions.map((s) => ( + + + + + + + + ))} + +
+ {t("assignment.colStudent")} + + {t("assignment.colStatus")} + + {t("assignment.colSubmittedAt")} + + {t("assignment.colScore")} + + {t("assignment.colActions")} +
+ + {s.studentName} + +

+ {s.studentNo} +

+
+ + {formatSubmissionStatus(s.status)} + + + {formatDueDate(s.submittedAt)} + + {formatScore(s.totalScore)} / {s.maxScore} + + + {t("assignment.scanGrade")} + + · + + {t("assignment.detailGrade")} + +
+
+
+
+ ); +} + +/** + * 统计卡片(4 个指标 + 提交率)。 + */ +function StatsCard({ + stats, +}: { + stats: { + totalStudents: number; + submittedCount: number; + gradedCount: number; + pendingCount: number; + avgScore: number; + submissionRate: number; + }; +}): React.ReactElement { + const t = useTranslations("homework"); + const items = [ + { + label: t("assignment.statsTotalStudents"), + value: String(stats.totalStudents), + }, + { + label: t("assignment.statsSubmitted"), + value: String(stats.submittedCount), + }, + { + label: t("assignment.statsGraded"), + value: String(stats.gradedCount), + }, + { + label: t("assignment.statsPending"), + value: String(stats.pendingCount), + }, + { + label: t("assignment.statsAvgScore"), + value: formatScore(stats.avgScore), + }, + { + label: t("assignment.statsSubmissionRate"), + value: formatSubmissionRate( + calcSubmissionRate(stats.submittedCount, stats.totalStudents), + ), + }, + ]; + return ( +
+ {items.map((it) => ( +
+

{it.label}

+

{it.value}

+
+ ))} +
+ ); +} + +/** + * AI 批量评分区(点击按钮触发拉取建议 + 显示建议列表)。 + */ +function AiBatchGradingSection({ + homeworkId, +}: { + homeworkId: string; +}): React.ReactElement { + const t = useTranslations("homework"); + const tCommon = useTranslations("common"); + const { data, loading, error, refetch } = useAiBatchGrading(homeworkId, { + enabled: false, + }); + + const handleTrigger = (): void => { + void refetch(); + notify.info(t("assignment.aiTriggered")); + }; + + if (loading) { + return ( +
+

{tCommon("loading")}

+
+ ); + } + + if (error) { + return ( +
+

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

+
+ ); + } + + const suggestions = data?.suggestions ?? []; + const summary = data?.summary; + + return ( +
+
+
+

+ {t("assignment.sectionAiGrading")} +

+ {summary ? ( +

+ {t("assignment.aiSummary", { + processed: summary.processed, + total: summary.totalSubmissions, + avg: formatConfidence(summary.avgConfidence), + })} +

+ ) : null} +
+ +
+ {suggestions.length > 0 ? ( +
    + {suggestions.map((s) => ( + + ))} +
+ ) : ( +

+ {t("assignment.aiEmpty")} +

+ )} +

+ {t("assignment.aiContractPending")} +

+
+ ); +} + +function AiSuggestionRow({ + suggestion, +}: { + suggestion: AiGradingSuggestion; +}): React.ReactElement { + const t = useTranslations("homework"); + return ( +
  • +
    +

    {suggestion.studentName}

    +

    {suggestion.reasoning}

    +
    +
    + + {t("assignment.suggestedScore")}:{" "} + {suggestion.suggestedScore} + + + {t("assignment.confidence")}:{" "} + {formatConfidence(suggestion.confidence)} + +
    +
  • + ); +} + +function InfoItem({ + label, + value, +}: { + label: string; + value: string; +}): React.ReactElement { + return ( +
    +

    {label}

    +

    {value}

    +
    + ); +} diff --git a/apps/portal-shell/src/features/teacher/homework/homework-detail-client.tsx b/apps/portal-shell/src/features/teacher/homework/homework-detail-client.tsx new file mode 100644 index 0000000..203b0ea --- /dev/null +++ b/apps/portal-shell/src/features/teacher/homework/homework-detail-client.tsx @@ -0,0 +1,394 @@ +"use client"; + +/** + * 作业详情页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2) + * + * 数据契约: + * - 单查 homework(id: ID!):✅ schema 真实字段(core-edu 子图) + * - 列表查询 homeworkSubmissions(homeworkId):❌ schema 无此字段 → MSW 兜底(@contract-pending) + * - mutation recordGrade(input):❌ schema 无 Mutation → MSW 兜底(@contract-pending) + * + * 三态规范(§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 { ClipboardList } from "lucide-react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useState } from "react"; +import { useTranslations } from "next-intl"; + +import { + useHomework, + useHomeworkSubmissions, + useRecordGrade, + type Homework, + type HomeworkSubmissionItem, +} from "@/lib/api"; +import { Button } from "@/shared/components/ui/button"; +import { + DetailPageShell, + DetailPageSkeleton, + DetailSection, + DetailField, +} from "@/shared/components/page-templates"; +import { notify } from "@/shared/lib/notify"; +import { + formatDueDate, + formatGracePeriod, + formatHomeworkStatus, + formatScore, + formatSubmissionStatus, + homeworkStatusToBadgeClass, + isHomeworkEditable, + isOverdue, + submissionStatusToBadgeClass, +} from "@/features/teacher/homework/transformations"; + +/** + * 详情客户端主体。需由 server page 包裹在 中。 + */ +export function HomeworkDetailClient(): React.ReactElement { + const t = useTranslations("homework"); + const tCommon = useTranslations("common"); + const params = useParams<{ id: string }>(); + const homeworkId = params?.id ?? ""; + + // ✅ 真实查询:homework(id: ID!),schema 已就绪 + const { data, loading, error } = useHomework(homeworkId); + + const errorNode = error ? ( +
    +

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

    +
    + ) : undefined; + + return ( + } + backHref="/shell/teacher/homework" + actions={ + data && isHomeworkEditable(data.status) ? ( + + ) : null + } + loading={loading} + loadingNode={} + errorNode={errorNode} + emptyNode={ + !loading && !error && !data ? ( +
    + {t("detail.notFound")} +
    + ) : undefined + } + > + {data ? : null} +
    + ); +} + +/** + * 详情内容区(基本信息 + 提交列表 + 内联批改表单)。 + */ +function HomeworkDetailBody({ + homework, +}: { + homework: Homework; +}): React.ReactElement { + const t = useTranslations("homework"); + return ( + <> + + + + + {formatDueDate(homework.dueDate)} + + } + /> + + + {formatHomeworkStatus(homework.status)} + + } + /> + + + + + + + + + {t("detail.viewAllSubmissions")} + + + } + > + + + + + + + + ); +} + +/** + * 提交列表(按 homeworkId 拉取,@contract-pending MSW 兜底)。 + */ +function HomeworkSubmissionsSection({ + homeworkId, +}: { + homeworkId: string; +}): React.ReactElement { + const t = useTranslations("homework"); + const tCommon = useTranslations("common"); + const { data, loading, error } = useHomeworkSubmissions({ homeworkId }); + + if (loading) { + return ( +

    {tCommon("loading")}

    + ); + } + + if (error) { + return ( +

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

    + ); + } + + const items = data?.items ?? []; + if (items.length === 0) { + return ( +

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

    + ); + } + + return ( +
    + + + + + + + + + + + + {items.map((s) => ( + + ))} + +
    + {t("detail.colStudent")} + + {t("detail.colStatus")} + + {t("detail.colSubmittedAt")} + + {t("detail.colScore")} + + {t("detail.colActions")} +
    +
    + ); +} + +function SubmissionRow({ + submission, +}: { + submission: HomeworkSubmissionItem; +}): React.ReactElement { + const t = useTranslations("homework"); + return ( + + + + {submission.studentName} + +

    {submission.studentNo}

    + + + + {formatSubmissionStatus(submission.status)} + + + + {formatDueDate(submission.submittedAt)} + + + {formatScore(submission.totalScore)} / {submission.maxScore} + + + + {t("detail.grade")} + + + + ); +} + +/** + * 内联批改表单(录入单个学生单次成绩,@contract-pending MSW 兜底)。 + */ +function InlineGradeForm({ + homeworkId, +}: { + homeworkId: string; +}): React.ReactElement { + const t = useTranslations("homework"); + const { run: recordGrade, loading: submitting } = useRecordGrade(); + + const [studentId, setStudentId] = useState(""); + const [score, setScore] = useState(""); + const [feedback, setFeedback] = useState(""); + const [error, setError] = useState(null); + + const handleSubmit = async (): Promise => { + setError(null); + if (!studentId.trim()) { + setError(t("detail.gradeErrorStudentRequired")); + return; + } + const scoreNum = Number(score); + if (!Number.isFinite(scoreNum) || scoreNum < 0) { + setError(t("detail.gradeErrorScoreInvalid")); + return; + } + try { + await recordGrade({ + homeworkId, + studentId: studentId.trim(), + score: scoreNum, + feedback: feedback.trim() || undefined, + }); + notify.success(t("detail.gradeSuccess")); + setStudentId(""); + setScore(""); + setFeedback(""); + } catch (err) { + notify.error(`${t("detail.gradeError")}: ${String(err)}`); + } + }; + + return ( +
    +
    +
    + + setStudentId(e.target.value)} + placeholder="stu-001" + className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" + /> +
    +
    + + setScore(e.target.value)} + className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" + /> +
    +
    +
    + +