From d066da563f733cc60878322456c0e31dc7850ce3 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:02:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(portal-shell):=20=E6=95=99=E5=B8=88?= =?UTF-8?q?=E5=9F=9F=E8=80=83=E8=AF=95=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=EF=BC=88P2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 ARCHITECTURE.md §9.1/§10 P2 要求,迁移教师域 exams 模块: - 列表页 /shell/teacher/exams(ListPageShell + URL 状态 + 客户端二次筛选) - 详情页 /shell/teacher/exams/[id](DetailPageShell + 真实 exam(id) 查询) - 新建页 /shell/teacher/exams/new(FormPageShell + MSW 兜底) - 纯函数 transformations.ts + 19 个 vitest 单测 - @contract-pending:exams(classId) 列表查询、createExam mutation 走 MSW - 三态 UI(loading/error/empty)+ 路由级 loading.tsx/error.tsx - i18n:zh-CN/en 双语补全,无硬编码中文 - MSW handlers 支持 variables 透传 §11.3 DoD 验收: - lint: 0 errors(4 个 __generated__ 预存警告) - typecheck: 0 errors - test: 250/250 passed(含 19 个新增 transformations 测试) - lint:tokens: 0 errors --- .../portal-shell/src/app/api/graphql/route.ts | 8 +- .../src/app/shell/teacher/exams/[id]/page.tsx | 22 ++ .../src/app/shell/teacher/exams/error.tsx | 33 +++ .../src/app/shell/teacher/exams/loading.tsx | 9 + .../src/app/shell/teacher/exams/new/page.tsx | 23 ++ .../src/app/shell/teacher/exams/page.tsx | 23 ++ .../exams/__tests__/transformations.test.ts | 180 +++++++++++++ .../teacher/exams/exam-detail-client.tsx | 150 +++++++++++ .../teacher/exams/exams-list-client.tsx | 226 ++++++++++++++++ .../teacher/exams/new-exam-client.tsx | 244 ++++++++++++++++++ .../features/teacher/exams/transformations.ts | 103 ++++++++ apps/portal-shell/src/lib/api/exams.ts | 208 +++++++++++++++ apps/portal-shell/src/lib/api/index.ts | 1 + .../src/lib/api/operations/exams.graphql.ts | 72 ++++++ .../src/lib/api/operations/index.ts | 1 + apps/portal-shell/src/messages/en.json | 66 ++++- apps/portal-shell/src/messages/zh-CN.json | 66 ++++- apps/portal-shell/src/mocks/graphql-data.ts | 135 +++++++++- apps/portal-shell/src/mocks/handlers.ts | 18 +- 19 files changed, 1578 insertions(+), 10 deletions(-) create mode 100644 apps/portal-shell/src/app/shell/teacher/exams/[id]/page.tsx create mode 100644 apps/portal-shell/src/app/shell/teacher/exams/error.tsx create mode 100644 apps/portal-shell/src/app/shell/teacher/exams/loading.tsx create mode 100644 apps/portal-shell/src/app/shell/teacher/exams/new/page.tsx create mode 100644 apps/portal-shell/src/app/shell/teacher/exams/page.tsx create mode 100644 apps/portal-shell/src/features/teacher/exams/__tests__/transformations.test.ts create mode 100644 apps/portal-shell/src/features/teacher/exams/exam-detail-client.tsx create mode 100644 apps/portal-shell/src/features/teacher/exams/exams-list-client.tsx create mode 100644 apps/portal-shell/src/features/teacher/exams/new-exam-client.tsx create mode 100644 apps/portal-shell/src/features/teacher/exams/transformations.ts create mode 100644 apps/portal-shell/src/lib/api/exams.ts create mode 100644 apps/portal-shell/src/lib/api/operations/exams.graphql.ts diff --git a/apps/portal-shell/src/app/api/graphql/route.ts b/apps/portal-shell/src/app/api/graphql/route.ts index 98520fc..593cb01 100644 --- a/apps/portal-shell/src/app/api/graphql/route.ts +++ b/apps/portal-shell/src/app/api/graphql/route.ts @@ -59,10 +59,12 @@ export async function POST(req: NextRequest): Promise { if (MSW_ENABLED) { const body = (await req.json().catch(() => ({}))) as { operationName?: string; + variables?: Record; }; - return NextResponse.json(graphqlResponse(body.operationName), { - headers: { "Cache-Control": "no-store" }, - }); + return NextResponse.json( + graphqlResponse(body.operationName, body.variables), + { headers: { "Cache-Control": "no-store" } }, + ); } const cookieHeader = req.headers.get("cookie"); diff --git a/apps/portal-shell/src/app/shell/teacher/exams/[id]/page.tsx b/apps/portal-shell/src/app/shell/teacher/exams/[id]/page.tsx new file mode 100644 index 0000000..218e355 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/exams/[id]/page.tsx @@ -0,0 +1,22 @@ +import { Suspense } from "react"; + +import { ExamDetailClient } from "@/features/teacher/exams/exam-detail-client"; +import { DetailPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 考试详情页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2) + * + * Server Component 入口:仅负责 Suspense 边界包裹。 + * 业务逻辑在 ExamDetailClient(client component)中。 + * + * 数据契约:单查 exam(id: ID!) ✅ schema 真实字段(core-edu 子图) + * + * 关联:ARCHITECTURE.md §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 + */ +export default function ExamDetailPage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/exams/error.tsx b/apps/portal-shell/src/app/shell/teacher/exams/error.tsx new file mode 100644 index 0000000..3c8b4e0 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/exams/error.tsx @@ -0,0 +1,33 @@ +"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"; + +export default function ExamsError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}): React.ReactElement { + useEffect(() => { + console.error("[portal-shell] exams route error:", error); + }, [error]); + + return ( +
+

考试页面出错了

+

+ {error.message || "未知错误"} +

+ +
+ ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/exams/loading.tsx b/apps/portal-shell/src/app/shell/teacher/exams/loading.tsx new file mode 100644 index 0000000..04f9f39 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/exams/loading.tsx @@ -0,0 +1,9 @@ +import { ListPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 考试列表页加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。 + * Next.js Route Segment loading.tsx,自动包裹页面渲染期间。 + */ +export default function ExamsLoading(): React.ReactElement { + return ; +} diff --git a/apps/portal-shell/src/app/shell/teacher/exams/new/page.tsx b/apps/portal-shell/src/app/shell/teacher/exams/new/page.tsx new file mode 100644 index 0000000..cb831b9 --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/exams/new/page.tsx @@ -0,0 +1,23 @@ +import { Suspense } from "react"; + +import { NewExamClient } from "@/features/teacher/exams/new-exam-client"; +import { FormPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 新建考试表单页(ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2) + * + * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。 + * 业务逻辑在 NewExamClient(client component)中。 + * + * 数据契约:mutation createExam(input) ❌ schema 无 Mutation → MSW 兜底(@contract-pending) + * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#create-exam-mutation + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +export default function NewExamPage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/teacher/exams/page.tsx b/apps/portal-shell/src/app/shell/teacher/exams/page.tsx new file mode 100644 index 0000000..7161d9e --- /dev/null +++ b/apps/portal-shell/src/app/shell/teacher/exams/page.tsx @@ -0,0 +1,23 @@ +import { Suspense } from "react"; + +import { ExamsListClient } from "@/features/teacher/exams/exams-list-client"; +import { ListPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 考试管理列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2) + * + * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。 + * 业务逻辑在 ExamsListClient(client component)中。 + * + * 数据契约:列表查询 exams(classId) ❌ schema 无此字段 → MSW 兜底(@contract-pending) + * 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#exams-list + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +export default function ExamsListPage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/features/teacher/exams/__tests__/transformations.test.ts b/apps/portal-shell/src/features/teacher/exams/__tests__/transformations.test.ts new file mode 100644 index 0000000..5da41da --- /dev/null +++ b/apps/portal-shell/src/features/teacher/exams/__tests__/transformations.test.ts @@ -0,0 +1,180 @@ +/** + * Exams 数据变换工具单测(ARCHITECTURE.md §11.3 DoD) + * + * 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测" + */ +import { describe, expect, it } from "vitest"; + +import type { Exam } from "@/lib/api"; + +import { + EXAM_STATUS_LABEL, + formatDuration, + formatExamDate, + formatExamStatus, + isExamEditable, + isExamPublished, + parseTotalScore, + toExamListItem, +} from "../transformations"; + +describe("formatExamStatus", () => { + it("maps known statuses to Chinese labels", () => { + expect(formatExamStatus("DRAFT")).toBe("草稿"); + expect(formatExamStatus("PUBLISHED")).toBe("已发布"); + expect(formatExamStatus("IN_PROGRESS")).toBe("进行中"); + expect(formatExamStatus("GRADING")).toBe("批改中"); + expect(formatExamStatus("SCORED")).toBe("已完成"); + expect(formatExamStatus("ARCHIVED")).toBe("已归档"); + }); + + it("returns original value for unknown status", () => { + expect(formatExamStatus("UNKNOWN")).toBe("UNKNOWN"); + expect(formatExamStatus("")).toBe(""); + }); + + it("EXAM_STATUS_LABEL covers all standard statuses", () => { + expect(Object.keys(EXAM_STATUS_LABEL)).toHaveLength(6); + }); +}); + +describe("formatExamDate", () => { + it("formats valid ISO date string", () => { + const result = formatExamDate("2026-07-22T10:00:00Z"); + // 不同时区下日期字符串会有差异,但应包含 2026 与 07 + expect(result).toContain("2026"); + expect(result).toContain("07"); + }); + + it("returns placeholder for null/undefined/empty", () => { + expect(formatExamDate(null)).toBe("--"); + expect(formatExamDate(undefined)).toBe("--"); + expect(formatExamDate("")).toBe("--"); + }); + + it("returns placeholder for invalid date", () => { + expect(formatExamDate("not-a-date")).toBe("--"); + }); +}); + +describe("parseTotalScore", () => { + it("parses numeric string", () => { + expect(parseTotalScore("100")).toBe(100); + expect(parseTotalScore("0")).toBe(0); + }); + + it("passes through number input", () => { + expect(parseTotalScore(150)).toBe(150); + }); + + it("returns 0 for non-numeric string", () => { + expect(parseTotalScore("abc")).toBe(0); + }); + + it("returns 0 for NaN", () => { + expect(parseTotalScore(Number.NaN)).toBe(0); + }); +}); + +describe("isExamEditable / isExamPublished", () => { + it("DRAFT is editable but not published", () => { + expect(isExamEditable("DRAFT")).toBe(true); + expect(isExamPublished("DRAFT")).toBe(false); + }); + + it("PUBLISHED and later statuses are published but not editable", () => { + for (const s of [ + "PUBLISHED", + "IN_PROGRESS", + "GRADING", + "SCORED", + "ARCHIVED", + ]) { + expect(isExamEditable(s)).toBe(false); + expect(isExamPublished(s)).toBe(true); + } + }); + + it("unknown status is neither editable nor published", () => { + expect(isExamEditable("UNKNOWN")).toBe(false); + expect(isExamPublished("UNKNOWN")).toBe(false); + }); +}); + +describe("toExamListItem", () => { + it("extracts list fields from full exam", () => { + const exam: Exam = { + id: "exam-001", + classId: "cls-001", + subjectId: "sub-math", + title: "期中考试", + description: "包含集合与函数", + examDate: "2026-07-22T10:00:00Z", + duration: 120, + totalScore: "100", + status: "DRAFT", + statusChangedAt: "2026-07-20T00:00:00Z", + statusChangedBy: "usr-001", + schoolId: "sch-001", + createdBy: "usr-001", + createdAt: "2026-07-19T00:00:00Z", + updatedAt: "2026-07-20T00:00:00Z", + }; + + const item = toExamListItem(exam); + expect(item.id).toBe("exam-001"); + expect(item.title).toBe("期中考试"); + expect(item.status).toBe("DRAFT"); + // 裁剪掉的字段不在列表项类型上 + expect(item).not.toHaveProperty("statusChangedAt"); + expect(item).not.toHaveProperty("schoolId"); + expect(item).not.toHaveProperty("createdBy"); + }); + + it("handles null description", () => { + const exam: Exam = { + id: "exam-002", + classId: "cls-001", + subjectId: "sub-math", + title: "无描述考试", + description: null, + examDate: "2026-07-22T10:00:00Z", + duration: 60, + totalScore: "50", + status: "PUBLISHED", + statusChangedAt: "2026-07-20T00:00:00Z", + statusChangedBy: null, + schoolId: "sch-001", + createdBy: "usr-001", + createdAt: "2026-07-19T00:00:00Z", + updatedAt: "2026-07-20T00:00:00Z", + }; + const item = toExamListItem(exam); + expect(item.description).toBeNull(); + }); +}); + +describe("formatDuration", () => { + it("formats minutes under 60", () => { + expect(formatDuration(30)).toBe("30 分钟"); + expect(formatDuration(45)).toBe("45 分钟"); + expect(formatDuration(1)).toBe("1 分钟"); + }); + + it("formats exact hours", () => { + expect(formatDuration(60)).toBe("1 小时"); + expect(formatDuration(120)).toBe("2 小时"); + }); + + it("formats hours with remainder minutes", () => { + expect(formatDuration(90)).toBe("1 小时 30 分钟"); + expect(formatDuration(75)).toBe("1 小时 15 分钟"); + }); + + it("returns placeholder for invalid input", () => { + expect(formatDuration(0)).toBe("--"); + expect(formatDuration(-1)).toBe("--"); + expect(formatDuration(Number.NaN)).toBe("--"); + expect(formatDuration(Number.POSITIVE_INFINITY)).toBe("--"); + }); +}); diff --git a/apps/portal-shell/src/features/teacher/exams/exam-detail-client.tsx b/apps/portal-shell/src/features/teacher/exams/exam-detail-client.tsx new file mode 100644 index 0000000..88ac6ba --- /dev/null +++ b/apps/portal-shell/src/features/teacher/exams/exam-detail-client.tsx @@ -0,0 +1,150 @@ +"use client"; + +/** + * 考试详情页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2) + * + * 数据契约: + * - 单查 exam(id: ID!):✅ schema 真实字段(core-edu 子图) + * - 无需 MSW 兜底,但 MSW 开启时也会返回 mock 数据(dev 体验) + * + * 三态规范(§11.3 DoD): + * - loading:DetailPageSkeleton + * - error:errorNode 局部降级 + * - notFound:data 为 null 时显示空态节点 + * + * 关联:ARCHITECTURE.md §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 + */ +import { FileText } from "lucide-react"; +import { useParams } from "next/navigation"; +import { useTranslations } from "next-intl"; + +import { useExam, type Exam } from "@/lib/api"; +import { Button } from "@/shared/components/ui/button"; +import { + DetailPageShell, + DetailPageSkeleton, + DetailSection, + DetailField, +} from "@/shared/components/page-templates"; +import { + formatExamDate, + formatExamStatus, + formatDuration, + isExamEditable, + parseTotalScore, +} from "@/features/teacher/exams/transformations"; + +/** + * 详情客户端主体。需由 server page 包裹在 中。 + */ +export function ExamDetailClient(): React.ReactElement { + const t = useTranslations("exams"); + const tCommon = useTranslations("common"); + const params = useParams<{ id: string }>(); + const examId = params?.id ?? ""; + + // ✅ 真实查询:exam(id: ID!),schema 已就绪 + const { data, loading, error } = useExam(examId); + + const errorNode = error ? ( +
+

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

+
+ ) : undefined; + + return ( + } + backHref="/shell/teacher/exams" + actions={ + data && isExamEditable(data.status) ? ( + + ) : null + } + loading={loading} + loadingNode={} + errorNode={errorNode} + emptyNode={ + !loading && !error && !data ? ( +
+ {t("detail.notFound")} +
+ ) : undefined + } + > + {data ? : null} +
+ ); +} + +/** + * 详情内容区(基本信息 + 状态信息两个分区)。 + */ +function ExamDetailBody({ exam }: { exam: Exam }): React.ReactElement { + const t = useTranslations("exams"); + return ( + <> + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/portal-shell/src/features/teacher/exams/exams-list-client.tsx b/apps/portal-shell/src/features/teacher/exams/exams-list-client.tsx new file mode 100644 index 0000000..b45f918 --- /dev/null +++ b/apps/portal-shell/src/features/teacher/exams/exams-list-client.tsx @@ -0,0 +1,226 @@ +"use client"; + +/** + * 考试管理列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2) + * + * 数据契约: + * - 列表查询 exams(classId):❌ schema 无此字段 → MSW 兜底(@contract-pending) + * - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#exams-list + * + * URL 状态:?classId=xxx &status=xxx &q=xxx + * + * 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState) + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4 + */ +import { FileText } from "lucide-react"; +import Link from "next/link"; +import { useSearchParams, useRouter } from "next/navigation"; +import { useMemo, useTransition } from "react"; +import { useTranslations } from "next-intl"; + +import { useExams, type ExamListItem } from "@/lib/api"; +import { Button } from "@/shared/components/ui/button"; +import { FilterSearchInput } from "@/shared/components/ui/filter-bar"; +import { + ListPageShell, + ListPageSkeleton, +} from "@/shared/components/page-templates"; +import { + formatExamDate, + formatExamStatus, + formatDuration, + parseTotalScore, +} from "@/features/teacher/exams/transformations"; + +/** + * 列表客户端主体。需由 server page 包裹在 中 + * (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。 + */ +export function ExamsListClient(): React.ReactElement { + const t = useTranslations("exams"); + const tCommon = useTranslations("common"); + const router = useRouter(); + const searchParams = useSearchParams(); + const [, startTransition] = useTransition(); + + const classId = searchParams.get("classId") ?? "cls-001"; + const statusFilter = searchParams.get("status") ?? ""; + const q = searchParams.get("q") ?? ""; + + // @contract-pending:MSW 兜底 + const { data, loading, error } = useExams(classId); + + // 客户端二次筛选(status + q)—— 后端补齐列表查询后改服务端筛选 + const filteredItems = useMemo(() => { + const items = data?.items ?? []; + return items.filter((item) => { + if (statusFilter && item.status !== statusFilter) return false; + if (q && !item.title.toLowerCase().includes(q.toLowerCase())) { + return false; + } + return true; + }); + }, [data, statusFilter, q]); + + const updateQuery = (key: string, value: string): void => { + const params = new URLSearchParams(searchParams.toString()); + if (value) { + params.set(key, value); + } else { + params.delete(key); + } + startTransition(() => { + router.push(`/shell/teacher/exams?${params.toString()}`); + }); + }; + + const errorNode = error ? ( +
+

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

+

+ {/* @contract-pending 提示:MSW 兜底时若未开启 NEXT_PUBLIC_MSW=1 会失败 */} + {t("list.mswNotice")} +

+
+ ) : undefined; + + return ( + } + actions={ + + } + filters={ + <> + updateQuery("q", v)} + /> + + + } + loading={loading} + loadingNode={} + empty={filteredItems.length === 0 && !loading} + errorNode={errorNode} + pagination={ +
+ {t("list.total", { count: filteredItems.length })} +
+ } + > + +
+ ); +} + +/** + * 考试列表表格(纯展示组件,对齐 §8.2 排版规范)。 + */ +function ExamsTable({ items }: { items: ExamListItem[] }): React.ReactElement { + const t = useTranslations("exams"); + return ( +
+ + + + + + + + + + + + + {items.map((exam) => ( + + + + + + + + + ))} + +
{t("list.colName")}{t("list.colStatus")} + {t("list.colExamDate")} + + {t("list.colDuration")} + + {t("list.colTotalScore")} + + {t("list.colActions")} +
+ + {exam.title} + + {exam.description ? ( +

+ {exam.description} +

+ ) : null} +
+ + + {formatExamDate(exam.examDate)} + {formatDuration(exam.duration)} + {parseTotalScore(exam.totalScore)} {t("list.unitScore")} + + + {t("list.viewDetail")} + +
+
+ ); +} + +/** + * 考试状态徽章(按状态色阶展示)。 + */ +function ExamStatusBadge({ status }: { status: string }): React.ReactElement { + const label = formatExamStatus(status); + const cls = + status === "DRAFT" + ? "bg-muted text-muted-foreground" + : status === "PUBLISHED" || status === "IN_PROGRESS" + ? "bg-primary/10 text-primary" + : status === "SCORED" + ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" + : "bg-muted text-muted-foreground"; + return ( + + {label} + + ); +} diff --git a/apps/portal-shell/src/features/teacher/exams/new-exam-client.tsx b/apps/portal-shell/src/features/teacher/exams/new-exam-client.tsx new file mode 100644 index 0000000..ff2be9a --- /dev/null +++ b/apps/portal-shell/src/features/teacher/exams/new-exam-client.tsx @@ -0,0 +1,244 @@ +"use client"; + +/** + * 新建考试表单页 - 客户端组件(ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2) + * + * 数据契约: + * - mutation createExam(input):❌ schema 无 Mutation 类型 → MSW 兜底(@contract-pending) + * - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#create-exam-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 { FileText } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useTransition, useState } from "react"; +import { useTranslations } from "next-intl"; + +import { useCreateExam, type CreateExamInput } from "@/lib/api"; +import { FormPageShell } from "@/shared/components/page-templates"; +import { notify } from "@/shared/lib/notify"; + +/** + * 表单客户端主体。需由 server page 包裹在 中。 + */ +export function NewExamClient(): React.ReactElement { + const t = useTranslations("exams"); + const router = useRouter(); + const searchParams = useSearchParams(); + const [, startTransition] = useTransition(); + + const presetClassId = searchParams.get("classId") ?? ""; + + // @contract-pending:MSW 兜底 + const { run: createExam, loading: submitting } = useCreateExam(); + + const handleSubmit = async (input: CreateExamInput): Promise => { + try { + const result = await createExam(input); + notify.success(t("new.success")); + startTransition(() => { + router.push(`/shell/teacher/exams?classId=${input.classId}`); + }); + void result; + } catch (err) { + notify.error(`${t("new.error")}: ${String(err)}`); + } + }; + + return ( + + ); +} + +/** + * 表单主体(受控表单 + 内联校验)。 + * + * 注:未引入 react-hook-form + zod,因当前仅一个表单,引入会增加依赖。 + * 后续表单数量增多后统一迁移到 react-hook-form(§7.3 表单页模板建议)。 + */ +function NewExamFormInner({ + presetClassId, + submitting, + onSubmit, +}: { + presetClassId: string; + submitting: boolean; + onSubmit: (input: CreateExamInput) => Promise; +}): React.ReactElement { + const t = useTranslations("exams"); + const tCommon = useTranslations("common"); + + const [classId, setClassId] = useState(presetClassId); + const [subjectId, setSubjectId] = useState("sub-math"); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [examDate, setExamDate] = useState(""); + const [duration, setDuration] = useState("120"); + const [totalScore, setTotalScore] = useState("100"); + const [error, setError] = useState(null); + + const handleFormSubmit = (): void => { + setError(null); + + if (!classId.trim()) { + setError(t("new.errorClassRequired")); + return; + } + if (!title.trim()) { + setError(t("new.errorTitleRequired")); + return; + } + if (!examDate) { + setError(t("new.errorDateRequired")); + return; + } + + const input: CreateExamInput = { + classId: classId.trim(), + subjectId: subjectId.trim(), + title: title.trim(), + description: description.trim() || undefined, + examDate: new Date(examDate).toISOString(), + duration: Number(duration) || 0, + totalScore: Number(totalScore) || 0, + }; + + void onSubmit(input); + }; + + return ( + } + backHref={`/shell/teacher/exams?classId=${classId}`} + onSubmit={handleFormSubmit} + submitting={submitting} + submitLabel={t("new.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 */} + + setSubjectId(e.target.value)} + className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" + placeholder="sub-math" + required + /> + + + {/* 标题 */} + + setTitle(e.target.value)} + className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" + placeholder={t("new.titlePlaceholder")} + required + /> + + + {/* 描述 */} + +