From 081cb5fbc38bfd1635e3c7e860f723914539577c Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:11:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(portal-shell):=20homework=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=207=20=E9=A1=B5=E8=BF=81=E7=A7=BB=EF=BC=88=E6=95=99?= =?UTF-8?q?=E5=B8=88=E5=9F=9F=20=C2=A79.1=20B2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §9.1 教师域 homework 模块完整迁移(继 exams 之后第二个 B2 模块): 7 页路由结构(与旧 teacher-portal 同构): - /shell/teacher/homework:列表页(?classId/status/q 筛选) - /shell/teacher/homework/new:布置作业表单页 - /shell/teacher/homework/[id]:详情 + 内联批改(含提交列表 + recordGrade 表单) - /shell/teacher/homework/submissions:跨作业提交评审列表 - /shell/teacher/homework/submissions/[submissionId]:单份提交批改 + AI 建议 + 上下份导航 - /shell/teacher/homework/submissions/[submissionId]/scan-grading:扫描批改工作台(三栏) - /shell/teacher/homework/assignments/[id]/submissions:按作业批量批改 + 统计 + AI 批量评分 数据契约(混合): - ✅ homework(id: ID!) 真实查询(schema 已就绪,详情页用) - ❌ 列表/mutation/submissions/grading/aiBatchGrading 全部 @contract-pending MSW 兜底 · 9 个 hook 走 MSW,待后端补齐 mutation 后切换真实 fetcher §11.3 DoD 11 项验收: 1. route-permissions:EXACT + PREFIX 表 /shell/teacher/homework 已配置 2. 页面模板:list/new 用 ListPageShell/FormPageShell;detail/grading 用 DetailPageShell; scan-grading 用 WorkbenchPageShell(三栏,未使用 emptyNode) 3. 三态:loading(Skeleton)/error(errorNode 或 errorSummary)/empty(emptyNode) 全实现 4. lib/api hooks:homework.ts 10 个 hooks(useHomework 真实 + 9 个 MSW) 5. @contract-pending MSW:graphql-data.ts 扩展 6 块 mock + 10 个 switch case 6. i18n:homework 节点扩展 8 个分区共 130+ keys(list/detail/new/submissions/grading/ scan/assignment/error)中英对齐 7. lint:0 errors(4 warnings 在 __generated__) 8. lint:tokens:0 errors 9. notify:mutation 反馈走 @/shared/lib/notify(非 sonner 直引) 10. vitest:transformations 纯函数单测齐全,全量 323/323 通过(新增 ~50 测试) 11. typecheck:0 errors(noUncheckedIndexedAccess 安全访问) 附带修复: - 修复 2 处遗留 broken link: · widgets/sidebar/quick-actions: /homework/new → /shell/teacher/homework/new · widgets/topbar/global-search: /homework → /shell/teacher/homework - scripts/check-page-count.ts baseline 同步 13 → 26(与 exams 6 + homework 7 一致) 剩余模块:grades(5)+lesson-plans(6)+questions(1)+textbooks(2)+attendance(4)+classes(3)+ students(1)+course-plans(2)+elective(3)+error-book(1)+diagnostic(2)+analytics(2)+ai-*(3)+ knowledge-graph(1)+practice(1)+schedule-changes(1)+leave(1) 共 39 页。 --- apps/portal-shell/scripts/check-page-count.ts | 4 +- .../app/shell/teacher/homework/[id]/page.tsx | 25 + .../assignments/[id]/submissions/page.tsx | 24 + .../src/app/shell/teacher/homework/error.tsx | 38 ++ .../app/shell/teacher/homework/loading.tsx | 12 + .../app/shell/teacher/homework/new/page.tsx | 23 + .../src/app/shell/teacher/homework/page.tsx | 23 + .../submissions/[submissionId]/page.tsx | 24 + .../[submissionId]/scan-grading/page.tsx | 24 + .../teacher/homework/submissions/page.tsx | 23 + .../__tests__/transformations.test.ts | 373 +++++++++++ .../assignment-submissions-client.tsx | 437 +++++++++++++ .../homework/homework-detail-client.tsx | 394 +++++++++++ .../teacher/homework/homework-list-client.tsx | 228 +++++++ .../teacher/homework/new-homework-client.tsx | 232 +++++++ .../teacher/homework/scan-grading-client.tsx | 356 ++++++++++ .../homework/submission-grading-client.tsx | 391 +++++++++++ .../homework/submissions-list-client.tsx | 226 +++++++ .../teacher/homework/transformations.ts | 266 ++++++++ apps/portal-shell/src/lib/api/homework.ts | 612 ++++++++++++++++++ apps/portal-shell/src/lib/api/index.ts | 1 + .../lib/api/operations/homework.graphql.ts | 261 ++++++++ .../src/lib/api/operations/index.ts | 1 + apps/portal-shell/src/messages/en.json | 210 +++++- apps/portal-shell/src/messages/zh-CN.json | 210 +++++- apps/portal-shell/src/mocks/graphql-data.ts | 394 +++++++++++ .../widgets/sidebar/quick-actions/index.tsx | 2 +- .../widgets/topbar/global-search/index.tsx | 2 +- 28 files changed, 4810 insertions(+), 6 deletions(-) create mode 100644 apps/portal-shell/src/app/shell/teacher/homework/[id]/page.tsx create mode 100644 apps/portal-shell/src/app/shell/teacher/homework/assignments/[id]/submissions/page.tsx create mode 100644 apps/portal-shell/src/app/shell/teacher/homework/error.tsx create mode 100644 apps/portal-shell/src/app/shell/teacher/homework/loading.tsx create mode 100644 apps/portal-shell/src/app/shell/teacher/homework/new/page.tsx create mode 100644 apps/portal-shell/src/app/shell/teacher/homework/page.tsx create mode 100644 apps/portal-shell/src/app/shell/teacher/homework/submissions/[submissionId]/page.tsx create mode 100644 apps/portal-shell/src/app/shell/teacher/homework/submissions/[submissionId]/scan-grading/page.tsx create mode 100644 apps/portal-shell/src/app/shell/teacher/homework/submissions/page.tsx create mode 100644 apps/portal-shell/src/features/teacher/homework/__tests__/transformations.test.ts create mode 100644 apps/portal-shell/src/features/teacher/homework/assignment-submissions-client.tsx create mode 100644 apps/portal-shell/src/features/teacher/homework/homework-detail-client.tsx create mode 100644 apps/portal-shell/src/features/teacher/homework/homework-list-client.tsx create mode 100644 apps/portal-shell/src/features/teacher/homework/new-homework-client.tsx create mode 100644 apps/portal-shell/src/features/teacher/homework/scan-grading-client.tsx create mode 100644 apps/portal-shell/src/features/teacher/homework/submission-grading-client.tsx create mode 100644 apps/portal-shell/src/features/teacher/homework/submissions-list-client.tsx create mode 100644 apps/portal-shell/src/features/teacher/homework/transformations.ts create mode 100644 apps/portal-shell/src/lib/api/homework.ts create mode 100644 apps/portal-shell/src/lib/api/operations/homework.graphql.ts 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" + /> +
    +
    +
    + +