From 2f8f3f38553276a7a4856b24f2e5732a9e7fef34 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:41:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(portal-shell):=20grades=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=205=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 B2 grades 行:/grades、/entry、/analytics、/stats、/report-card 契约:grade(id) ✅ 真实查询;列表/分析/统计/成绩单 → MSW 兜底 新增文件: - src/lib/api/grades.ts (6 hooks) - src/lib/api/operations/grades.graphql.ts (6 documents) - src/features/teacher/grades/ (7 files: transformations + tests + 5 clients) - src/app/shell/teacher/grades/ (5 page.tsx + loading.tsx + error.tsx) 修改文件: - src/mocks/graphql-data.ts (5 mock 常量 + 6 handler cases) - src/messages/{zh-CN,en}.json (grades i18n) - src/lib/api/{index,operations/index}.ts (导出 grades) - scripts/check-page-count.ts (baseline 26 → 31) DoD 验收(§11.3 11 项): - typecheck 0 errors - lint 0 errors - vitest 357 tests passed - lint:tokens 0 errors - check:pages 31 PASS 关联:ARCHITECTURE.md §5.3 / §5.4 / §9.1 / §10 P2 / §11.3 / §11.4 --- apps/portal-shell/scripts/check-page-count.ts | 4 +- .../shell/teacher/grades/analytics/page.tsx | 24 + .../app/shell/teacher/grades/entry/page.tsx | 23 + .../src/app/shell/teacher/grades/error.tsx | 38 ++ .../src/app/shell/teacher/grades/loading.tsx | 12 + .../src/app/shell/teacher/grades/page.tsx | 23 + .../shell/teacher/grades/report-card/page.tsx | 23 + .../app/shell/teacher/grades/stats/page.tsx | 23 + .../grades/__tests__/transformations.test.ts | 318 +++++++++++++ .../teacher/grades/grade-analytics-client.tsx | 246 ++++++++++ .../teacher/grades/grade-entry-client.tsx | 249 ++++++++++ .../teacher/grades/grade-stats-client.tsx | 218 +++++++++ .../teacher/grades/grades-list-client.tsx | 225 +++++++++ .../teacher/grades/report-card-client.tsx | 235 ++++++++++ .../teacher/grades/transformations.ts | 248 ++++++++++ apps/portal-shell/src/lib/api/grades.ts | 426 ++++++++++++++++++ apps/portal-shell/src/lib/api/index.ts | 1 + .../src/lib/api/operations/grades.graphql.ts | 179 ++++++++ .../src/lib/api/operations/index.ts | 1 + apps/portal-shell/src/messages/en.json | 120 ++++- apps/portal-shell/src/messages/zh-CN.json | 120 ++++- apps/portal-shell/src/mocks/graphql-data.ts | 422 +++++++++++++++-- 22 files changed, 3144 insertions(+), 34 deletions(-) create mode 100644 apps/portal-shell/src/app/shell/teacher/grades/analytics/page.tsx create mode 100644 apps/portal-shell/src/app/shell/teacher/grades/entry/page.tsx create mode 100644 apps/portal-shell/src/app/shell/teacher/grades/error.tsx create mode 100644 apps/portal-shell/src/app/shell/teacher/grades/loading.tsx create mode 100644 apps/portal-shell/src/app/shell/teacher/grades/page.tsx create mode 100644 apps/portal-shell/src/app/shell/teacher/grades/report-card/page.tsx create mode 100644 apps/portal-shell/src/app/shell/teacher/grades/stats/page.tsx create mode 100644 apps/portal-shell/src/features/teacher/grades/__tests__/transformations.test.ts create mode 100644 apps/portal-shell/src/features/teacher/grades/grade-analytics-client.tsx create mode 100644 apps/portal-shell/src/features/teacher/grades/grade-entry-client.tsx create mode 100644 apps/portal-shell/src/features/teacher/grades/grade-stats-client.tsx create mode 100644 apps/portal-shell/src/features/teacher/grades/grades-list-client.tsx create mode 100644 apps/portal-shell/src/features/teacher/grades/report-card-client.tsx create mode 100644 apps/portal-shell/src/features/teacher/grades/transformations.ts create mode 100644 apps/portal-shell/src/lib/api/grades.ts create mode 100644 apps/portal-shell/src/lib/api/operations/grades.graphql.ts diff --git a/apps/portal-shell/scripts/check-page-count.ts b/apps/portal-shell/scripts/check-page-count.ts index f7c5058..3b50ddc 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 P2 (2026-07-22, homework module added). Update when adding pages. +// Baseline as of P2 (2026-07-22, grades module added). Update when adding pages. const BASELINE: Baseline = { - total: 26, + total: 31, categories: { dashboards: { pattern: "shell/{admin,teacher,student,parent}/page.tsx", diff --git a/apps/portal-shell/src/app/shell/teacher/grades/analytics/page.tsx b/apps/portal-shell/src/app/shell/teacher/grades/analytics/page.tsx new file mode 100644 index 0000000..e9e9992 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/grades/analytics/page.tsx @@ -0,0 +1,24 @@ +import { Suspense } from "react"; + +import { GradeAnalyticsClient } from "@/features/teacher/grades/grade-analytics-client"; +import { DetailPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 成绩分析页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2) + * + * Server Component 入口:仅负责 Suspense 边界包裹。 + * 业务逻辑在 GradeAnalyticsClient(client component)中。 + * + * 数据契约:混合契约 + * - 基础统计 ✅ assignmentAnalysis(data-ana 子图,schema 已就绪) + * - 扩展字段(排名/分布)❌ → MSW 兜底(@contract-pending) + * + * 关联:ARCHITECTURE.md §5.4 / §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +export default function GradeAnalyticsPage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/grades/entry/page.tsx b/apps/portal-shell/src/app/shell/teacher/grades/entry/page.tsx new file mode 100644 index 0000000..c7ae3bf --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/grades/entry/page.tsx @@ -0,0 +1,23 @@ +import { Suspense } from "react"; + +import { GradeEntryClient } from "@/features/teacher/grades/grade-entry-client"; +import { FormPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 成绩录入表单页(ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2) + * + * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。 + * 业务逻辑在 GradeEntryClient(client component)中。 + * + * 数据契约:mutation createGrade(input) ❌ schema 无 Mutation → MSW 兜底(@contract-pending) + * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#create-grade-mutation + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +export default function GradeEntryPage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/grades/error.tsx b/apps/portal-shell/src/app/shell/teacher/grades/error.tsx new file mode 100644 index 0000000..68a4a52 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/grades/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 { useTranslations } from "next-intl"; + +import { Button } from "@/shared/components/ui/button"; + +export default function GradesError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}): React.ReactElement { + const t = useTranslations("grades"); + + useEffect(() => { + console.error("[portal-shell] grades route error:", error); + }, [error]); + + return ( +
+

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

+

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

+ +
+ ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/grades/loading.tsx b/apps/portal-shell/src/app/shell/teacher/grades/loading.tsx new file mode 100644 index 0000000..d7f0ecb --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/grades/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/grades 列表/重定向期间显示。 + */ +export default function GradesLoading(): React.ReactElement { + return ; +} diff --git a/apps/portal-shell/src/app/shell/teacher/grades/page.tsx b/apps/portal-shell/src/app/shell/teacher/grades/page.tsx new file mode 100644 index 0000000..3aa01fc --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/grades/page.tsx @@ -0,0 +1,23 @@ +import { Suspense } from "react"; + +import { GradesListClient } from "@/features/teacher/grades/grades-list-client"; +import { ListPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 成绩管理列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2) + * + * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。 + * 业务逻辑在 GradesListClient(client component)中。 + * + * 数据契约:列表查询 grades(classId) ❌ schema 无此字段 → MSW 兜底(@contract-pending) + * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#grades-list + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +export default function GradesListPage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/grades/report-card/page.tsx b/apps/portal-shell/src/app/shell/teacher/grades/report-card/page.tsx new file mode 100644 index 0000000..aa82a25 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/grades/report-card/page.tsx @@ -0,0 +1,23 @@ +import { Suspense } from "react"; + +import { ReportCardClient } from "@/features/teacher/grades/report-card-client"; +import { DetailPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 学生成绩单页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2) + * + * Server Component 入口:仅负责 Suspense 边界包裹。 + * 业务逻辑在 ReportCardClient(client component)中。 + * + * 数据契约:reportCard(studentId, period) ❌ schema 无此字段 → MSW 兜底(@contract-pending) + * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#report-card + * + * 关联:ARCHITECTURE.md §5.4 / §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +export default function ReportCardPage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/grades/stats/page.tsx b/apps/portal-shell/src/app/shell/teacher/grades/stats/page.tsx new file mode 100644 index 0000000..8fd0231 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/grades/stats/page.tsx @@ -0,0 +1,23 @@ +import { Suspense } from "react"; + +import { GradeStatsClient } from "@/features/teacher/grades/grade-stats-client"; +import { DetailPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 成绩统计页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2) + * + * Server Component 入口:仅负责 Suspense 边界包裹。 + * 业务逻辑在 GradeStatsClient(client component)中。 + * + * 数据契约:gradeStats(classId, period) ❌ schema 无此字段 → MSW 兜底(@contract-pending) + * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#grade-stats + * + * 关联:ARCHITECTURE.md §5.4 / §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +export default function GradeStatsPage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/features/teacher/grades/__tests__/transformations.test.ts b/apps/portal-shell/src/features/teacher/grades/__tests__/transformations.test.ts new file mode 100644 index 0000000..93d4ac2 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/grades/__tests__/transformations.test.ts @@ -0,0 +1,318 @@ +/** + * Grades 数据变换工具单测(ARCHITECTURE.md §11.3 DoD) + * + * 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测" + */ +import { describe, expect, it } from "vitest"; + +import type { Grade } from "@/lib/api"; + +import { + GRADE_LEVEL_LABEL, + GRADE_STATUS_LABEL, + calcGradeDistribution, + formatGradeDate, + formatGradeLevel, + formatGradeStatus, + formatPassRate, + formatPercent, + formatScore, + isPassing, + levelToColorClass, + parseScore, + scoreRateToColorClass, + scoreToLevel, + toGradeListItem, +} from "../transformations"; + +describe("formatGradeStatus", () => { + it("maps known statuses to Chinese labels", () => { + expect(formatGradeStatus("GRADED")).toBe("已评分"); + expect(formatGradeStatus("PENDING")).toBe("待评分"); + expect(formatGradeStatus("EXEMPT")).toBe("免考"); + expect(formatGradeStatus("ABSENT")).toBe("缺考"); + }); + + it("returns original value for unknown status", () => { + expect(formatGradeStatus("UNKNOWN")).toBe("UNKNOWN"); + expect(formatGradeStatus("")).toBe(""); + }); + + it("GRADE_STATUS_LABEL covers all standard statuses", () => { + expect(Object.keys(GRADE_STATUS_LABEL)).toHaveLength(4); + }); +}); + +describe("formatGradeLevel", () => { + it("maps known levels to Chinese labels", () => { + expect(formatGradeLevel("A")).toBe("优秀"); + expect(formatGradeLevel("B")).toBe("良好"); + expect(formatGradeLevel("C")).toBe("合格"); + expect(formatGradeLevel("D")).toBe("不合格"); + }); + + it("returns original value for unknown level", () => { + expect(formatGradeLevel("X")).toBe("X"); + expect(formatGradeLevel("")).toBe(""); + }); + + it("GRADE_LEVEL_LABEL covers A/B/C/D", () => { + expect(Object.keys(GRADE_LEVEL_LABEL)).toHaveLength(4); + }); +}); + +describe("formatGradeDate", () => { + it("formats valid ISO date string", () => { + const result = formatGradeDate("2026-07-22T10:00:00Z"); + expect(result).toContain("2026"); + expect(result).toContain("07"); + }); + + it("returns placeholder for null/undefined/empty", () => { + expect(formatGradeDate(null)).toBe("--"); + expect(formatGradeDate(undefined)).toBe("--"); + expect(formatGradeDate("")).toBe("--"); + }); + + it("returns placeholder for invalid date", () => { + expect(formatGradeDate("not-a-date")).toBe("--"); + }); +}); + +describe("parseScore", () => { + it("parses numeric string", () => { + expect(parseScore("95")).toBe(95); + expect(parseScore("0")).toBe(0); + }); + + it("passes through number input", () => { + expect(parseScore(88)).toBe(88); + }); + + it("returns 0 for non-numeric string", () => { + expect(parseScore("abc")).toBe(0); + }); + + it("returns 0 for NaN", () => { + expect(parseScore(Number.NaN)).toBe(0); + }); +}); + +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 non-finite input", () => { + expect(formatScore(Number.NaN)).toBe("--"); + expect(formatScore(Number.POSITIVE_INFINITY)).toBe("--"); + expect(formatScore(Number.NEGATIVE_INFINITY)).toBe("--"); + }); +}); + +describe("formatPassRate / formatPercent", () => { + it("formats rate in [0,1] as percentage", () => { + expect(formatPassRate(0.86)).toBe("86%"); + expect(formatPassRate(0)).toBe("0%"); + expect(formatPassRate(1)).toBe("100%"); + expect(formatPercent(0.92)).toBe("92%"); + }); + + it("returns placeholder for out-of-range or non-finite input", () => { + expect(formatPassRate(-0.1)).toBe("--"); + expect(formatPassRate(1.1)).toBe("--"); + expect(formatPassRate(Number.NaN)).toBe("--"); + expect(formatPercent(Number.NaN)).toBe("--"); + }); +}); + +describe("scoreToLevel", () => { + it("maps score ratio to A/B/C/D levels", () => { + expect(scoreToLevel(90, 100)).toBe("A"); + expect(scoreToLevel(85, 100)).toBe("A"); + expect(scoreToLevel(75, 100)).toBe("B"); + expect(scoreToLevel(70, 100)).toBe("B"); + expect(scoreToLevel(65, 100)).toBe("C"); + expect(scoreToLevel(60, 100)).toBe("C"); + expect(scoreToLevel(59, 100)).toBe("D"); + expect(scoreToLevel(0, 100)).toBe("D"); + }); + + it("returns placeholder for invalid input", () => { + expect(scoreToLevel(Number.NaN, 100)).toBe("--"); + expect(scoreToLevel(80, 0)).toBe("--"); + expect(scoreToLevel(80, -1)).toBe("--"); + }); +}); + +describe("isPassing", () => { + it("returns true for score >= 60% of totalScore by default", () => { + expect(isPassing(60, 100)).toBe(true); + expect(isPassing(59, 100)).toBe(false); + expect(isPassing(80, 100)).toBe(true); + }); + + it("respects custom passScore", () => { + expect(isPassing(50, 100, 50)).toBe(true); + expect(isPassing(49, 100, 50)).toBe(false); + }); + + it("returns false for invalid input", () => { + expect(isPassing(Number.NaN, 100)).toBe(false); + expect(isPassing(80, 0)).toBe(false); + expect(isPassing(80, -1)).toBe(false); + }); +}); + +describe("scoreRateToColorClass", () => { + it("returns emerald for high rates (>= 0.8)", () => { + expect(scoreRateToColorClass(80, 100)).toBe("text-emerald-600"); + expect(scoreRateToColorClass(95, 100)).toBe("text-emerald-600"); + }); + + it("returns amber for medium rates (>= 0.6)", () => { + expect(scoreRateToColorClass(60, 100)).toBe("text-amber-600"); + expect(scoreRateToColorClass(79, 100)).toBe("text-amber-600"); + }); + + it("returns destructive for low rates (< 0.6)", () => { + expect(scoreRateToColorClass(59, 100)).toBe("text-destructive"); + expect(scoreRateToColorClass(0, 100)).toBe("text-destructive"); + }); + + it("returns muted for invalid input", () => { + expect(scoreRateToColorClass(Number.NaN, 100)).toBe( + "text-muted-foreground", + ); + expect(scoreRateToColorClass(80, 0)).toBe("text-muted-foreground"); + }); +}); + +describe("levelToColorClass", () => { + it("maps each level to correct color class", () => { + expect(levelToColorClass("A")).toBe("text-emerald-600"); + expect(levelToColorClass("B")).toBe("text-blue-600"); + expect(levelToColorClass("C")).toBe("text-amber-600"); + expect(levelToColorClass("D")).toBe("text-destructive"); + }); + + it("returns muted for unknown level", () => { + expect(levelToColorClass("X")).toBe("text-muted-foreground"); + expect(levelToColorClass("")).toBe("text-muted-foreground"); + }); +}); + +describe("calcGradeDistribution", () => { + it("distributes scores into 5 buckets", () => { + const scores = [ + { score: 95, totalScore: 100 }, + { score: 85, totalScore: 100 }, + { score: 75, totalScore: 100 }, + { score: 65, totalScore: 100 }, + { score: 45, totalScore: 100 }, + ]; + const result = calcGradeDistribution(scores); + expect(result).toHaveLength(5); + expect(result[0]!.count).toBe(1); // 0-59 + expect(result[1]!.count).toBe(1); // 60-69 + expect(result[2]!.count).toBe(1); // 70-79 + expect(result[3]!.count).toBe(1); // 80-89 + expect(result[4]!.count).toBe(1); // 90-100 + }); + + it("handles empty array", () => { + const result = calcGradeDistribution([]); + expect(result).toHaveLength(5); + expect(result.every((b) => b.count === 0)).toBe(true); + }); + + it("skips invalid scores", () => { + const scores = [ + { score: 90, totalScore: 100 }, + { score: Number.NaN, totalScore: 100 }, + { score: 80, totalScore: 0 }, + ]; + const result = calcGradeDistribution(scores); + expect(result[4]!.count).toBe(1); // only 90/100 counted + expect(result[3]!.count).toBe(0); + }); + + it("handles boundary values correctly", () => { + const scores = [ + { score: 60, totalScore: 100 }, // 60-69 + { score: 70, totalScore: 100 }, // 70-79 + { score: 80, totalScore: 100 }, // 80-89 + { score: 90, totalScore: 100 }, // 90-100 + { score: 59, totalScore: 100 }, // 0-59 + ]; + const result = calcGradeDistribution(scores); + expect(result[0]!.count).toBe(1); // 59 → 0-59 + expect(result[1]!.count).toBe(1); // 60 → 60-69 + expect(result[2]!.count).toBe(1); // 70 → 70-79 + expect(result[3]!.count).toBe(1); // 80 → 80-89 + expect(result[4]!.count).toBe(1); // 90 → 90-100 + }); +}); + +describe("toGradeListItem", () => { + it("extracts list fields from full grade with extra info", () => { + const grade: Grade = { + id: "grade-001", + studentId: "stu-001", + examId: "exam-001", + homeworkId: null, + score: "95", + totalScore: "100", + feedback: "基础扎实", + gradedBy: "usr-teacher-001", + schoolId: "sch-001", + idempotencyKey: null, + createdAt: "2026-04-16T10:00:00Z", + updatedAt: "2026-04-16T10:00:00Z", + }; + + const item = toGradeListItem(grade, { + studentName: "张明", + studentNo: "2026001", + classId: "cls-001", + className: "高三(1)班", + examName: "期中考试", + status: "GRADED", + }); + + expect(item.id).toBe("grade-001"); + expect(item.studentName).toBe("张明"); + expect(item.score).toBe(95); // string → number + expect(item.totalScore).toBe(100); + expect(item.examName).toBe("期中考试"); + expect(item.status).toBe("GRADED"); + }); + + it("handles missing extra info with defaults", () => { + const grade: Grade = { + id: "grade-002", + studentId: "stu-002", + examId: null, + homeworkId: "hw-001", + score: "88", + totalScore: "100", + feedback: null, + gradedBy: "usr-teacher-001", + schoolId: "sch-001", + idempotencyKey: null, + createdAt: "2026-07-20T00:00:00Z", + updatedAt: "2026-07-20T00:00:00Z", + }; + + const item = toGradeListItem(grade); + expect(item.studentName).toBe("--"); + expect(item.studentNo).toBe("--"); + expect(item.className).toBe("--"); + expect(item.examName).toBeNull(); + expect(item.homeworkTitle).toBeNull(); + expect(item.status).toBe("GRADED"); + expect(item.score).toBe(88); + }); +}); diff --git a/apps/portal-shell/src/features/teacher/grades/grade-analytics-client.tsx b/apps/portal-shell/src/features/teacher/grades/grade-analytics-client.tsx new file mode 100644 index 0000000..c70d32b --- /dev/null +++ b/apps/portal-shell/src/features/teacher/grades/grade-analytics-client.tsx @@ -0,0 +1,246 @@ +"use client"; + +/** + * 成绩分析页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2) + * + * 数据契约(混合契约): + * - 基础统计 ✅ assignmentAnalysis(data-ana 子图,schema 已就绪) + * - 扩展字段(排名/分布)❌ → MSW 兜底(@contract-pending) + * + * 三态规范(§11.3 DoD): + * - loading:DetailPageSkeleton + * - error:errorNode 局部降级 + * - empty:data 为 null 时显示空态节点 + * + * 关联:ARCHITECTURE.md §5.4 / §5.5 / §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 { useGradeAnalytics } from "@/lib/api"; +import { + DetailPageShell, + DetailPageSkeleton, + DetailSection, +} from "@/shared/components/page-templates"; +import { + formatPassRate, + formatScore, + levelToColorClass, +} from "@/features/teacher/grades/transformations"; + +/** + * 分析客户端主体。需由 server page 包裹在 中。 + */ +export function GradeAnalyticsClient(): React.ReactElement { + const t = useTranslations("grades"); + const tCommon = useTranslations("common"); + const searchParams = useSearchParams(); + + const examId = searchParams.get("examId") ?? "exam-001"; + const classId = searchParams.get("classId") ?? undefined; + + // 混合契约:基础统计 + 扩展字段 MSW + const { data, loading, error } = useGradeAnalytics({ examId, classId }); + + const errorNode = error ? ( +
+

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

+
+ ) : undefined; + + return ( + } + backHref="/shell/teacher/grades" + loading={loading} + loadingNode={} + errorNode={errorNode} + emptyNode={ + !loading && !error && !data ? ( +
+ {t("analytics.notFound")} +
+ ) : undefined + } + > + {data ? : null} +
+ ); +} + +/** + * 分析内容区(汇总卡片 + 分布图 + 学生排名)。 + */ +function GradeAnalyticsBody({ + data, +}: { + data: NonNullable["data"]>; +}): React.ReactElement { + const t = useTranslations("grades"); + return ( + <> + +
+ + + + + + + +
+
+ + + + + + + + + + ); +} + +/** + * 汇总卡片。 + */ +function SummaryCard({ + label, + value, +}: { + label: string; + value: string; +}): React.ReactElement { + return ( +
+

{label}

+

{value}

+
+ ); +} + +/** + * 分数段分布柱状图(纯 SVG)。 + */ +function DistributionChart({ + items, +}: { + items: Array<{ label: string; count: number }>; +}): React.ReactElement { + const t = useTranslations("grades"); + if (items.length === 0) { + return ( +

+ {t("analytics.emptyDistribution")} +

+ ); + } + const maxCount = Math.max(...items.map((i) => i.count), 1); + return ( +
+ {items.map((item) => ( +
+ + {item.label} + +
+
+
+ {item.count} +
+ ))} +
+ ); +} + +/** + * 学生排名表格。 + */ +function RankingsTable({ + items, +}: { + items: Array<{ + studentId: string; + studentNo: string; + studentName: string; + score: number; + rank: number; + level: string; + }>; +}): React.ReactElement { + const t = useTranslations("grades"); + if (items.length === 0) { + return ( +

+ {t("analytics.emptyRankings")} +

+ ); + } + return ( +
+ + + + + + + + + + + + {items.map((item) => ( + + + + + + + + ))} + +
{t("analytics.colRank")} + {t("analytics.colStudentNo")} + + {t("analytics.colStudentName")} + {t("analytics.colScore")}{t("analytics.colLevel")}
{item.rank}{item.studentNo}{item.studentName}{formatScore(item.score)} + + {item.level} + +
+
+ ); +} diff --git a/apps/portal-shell/src/features/teacher/grades/grade-entry-client.tsx b/apps/portal-shell/src/features/teacher/grades/grade-entry-client.tsx new file mode 100644 index 0000000..d4a9009 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/grades/grade-entry-client.tsx @@ -0,0 +1,249 @@ +"use client"; + +/** + * 成绩录入表单页 - 客户端组件(ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2) + * + * 数据契约: + * - mutation createGrade(input):❌ schema 无 Mutation 类型 → MSW 兜底(@contract-pending) + * - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#create-grade-mutation + * + * 三态规范(§11.3 DoD): + * - loading:FormPageSkeleton(由 server page Suspense 兜底) + * - error:errorSummary 表单级错误 + * - success:notify.success + router.push 回列表 + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +import { Award } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useTransition, useState } from "react"; +import { useTranslations } from "next-intl"; + +import { useCreateGrade, type CreateGradeInput } from "@/lib/api"; +import { FormPageShell } from "@/shared/components/page-templates"; +import { notify } from "@/shared/lib/notify"; + +/** + * 表单客户端主体。需由 server page 包裹在 中。 + */ +export function GradeEntryClient(): React.ReactElement { + const t = useTranslations("grades"); + const router = useRouter(); + const searchParams = useSearchParams(); + const [, startTransition] = useTransition(); + + const presetClassId = searchParams.get("classId") ?? ""; + + // @contract-pending:MSW 兜底 + const { run: createGrade, loading: submitting } = useCreateGrade(); + + const handleSubmit = async (input: CreateGradeInput): Promise => { + try { + const result = await createGrade(input); + notify.success(t("entry.success")); + startTransition(() => { + router.push(`/shell/teacher/grades?classId=${input.classId}`); + }); + void result; + } catch (err) { + notify.error(`${t("entry.error")}: ${String(err)}`); + } + }; + + return ( + + ); +} + +/** + * 表单主体(受控表单 + 内联校验)。 + */ +function GradeEntryFormInner({ + presetClassId, + submitting, + onSubmit, +}: { + presetClassId: string; + submitting: boolean; + onSubmit: (input: CreateGradeInput) => Promise; +}): React.ReactElement { + const t = useTranslations("grades"); + const tCommon = useTranslations("common"); + + const [classId, setClassId] = useState(presetClassId); + const [studentId, setStudentId] = useState(""); + const [examId, setExamId] = useState(""); + const [homeworkId, setHomeworkId] = useState(""); + const [score, setScore] = useState(""); + const [totalScore, setTotalScore] = useState("100"); + const [feedback, setFeedback] = useState(""); + const [error, setError] = useState(null); + + const handleFormSubmit = (): void => { + setError(null); + + if (!classId.trim()) { + setError(t("entry.errorClassRequired")); + return; + } + if (!studentId.trim()) { + setError(t("entry.errorStudentRequired")); + return; + } + const scoreNum = Number(score); + if (!Number.isFinite(scoreNum) || scoreNum < 0) { + setError(t("entry.errorScoreInvalid")); + return; + } + const totalNum = Number(totalScore); + if (!Number.isFinite(totalNum) || totalNum <= 0) { + setError(t("entry.errorTotalInvalid")); + return; + } + if (!examId.trim() && !homeworkId.trim()) { + setError(t("entry.errorSourceRequired")); + return; + } + + const input: CreateGradeInput = { + classId: classId.trim(), + studentId: studentId.trim(), + examId: examId.trim() || undefined, + homeworkId: homeworkId.trim() || undefined, + score: scoreNum, + totalScore: totalNum, + feedback: feedback.trim() || undefined, + }; + + void onSubmit(input); + }; + + return ( + } + backHref={`/shell/teacher/grades?classId=${classId}`} + onSubmit={handleFormSubmit} + submitting={submitting} + submitLabel={t("entry.submit")} + cancelLabel={tCommon("button.cancel")} + errorSummary={ + error ?

{error}

: undefined + } + > + {/* 班级 ID */} + + setClassId(e.target.value)} + className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" + placeholder="cls-001" + required + /> + + + {/* 学生 ID */} + + setStudentId(e.target.value)} + className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" + placeholder="stu-001" + required + /> + + + {/* 考试 ID(可选) */} + + setExamId(e.target.value)} + className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" + placeholder={t("entry.examIdPlaceholder")} + /> + + + {/* 作业 ID(可选) */} + + setHomeworkId(e.target.value)} + className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" + placeholder={t("entry.homeworkIdPlaceholder")} + /> + + + {/* 分数 + 满分 */} +
+ + setScore(e.target.value)} + className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" + required + /> + + + setTotalScore(e.target.value)} + className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" + required + /> + +
+ + {/* 反馈 */} + +