feat(portal-shell): 教师域考试管理页面迁移(P2)

按 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
This commit is contained in:
SpecialX
2026-07-22 17:02:05 +08:00
parent 843c3c0144
commit d066da563f
19 changed files with 1578 additions and 10 deletions

View File

@@ -59,10 +59,12 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
if (MSW_ENABLED) { if (MSW_ENABLED) {
const body = (await req.json().catch(() => ({}))) as { const body = (await req.json().catch(() => ({}))) as {
operationName?: string; operationName?: string;
variables?: Record<string, unknown>;
}; };
return NextResponse.json(graphqlResponse(body.operationName), { return NextResponse.json(
headers: { "Cache-Control": "no-store" }, graphqlResponse(body.operationName, body.variables),
}); { headers: { "Cache-Control": "no-store" } },
);
} }
const cookieHeader = req.headers.get("cookie"); const cookieHeader = req.headers.get("cookie");

View File

@@ -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 边界包裹。
* 业务逻辑在 ExamDetailClientclient 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 (
<Suspense fallback={<DetailPageSkeleton />}>
<ExamDetailClient />
</Suspense>
);
}

View File

@@ -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 (
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-destructive/30 bg-destructive/5 p-10">
<h2 className="text-lg font-semibold text-destructive"></h2>
<p className="text-sm text-muted-foreground">
{error.message || "未知错误"}
</p>
<Button onClick={reset} variant="outline">
</Button>
</div>
);
}

View File

@@ -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 <ListPageSkeleton rows={5} />;
}

View File

@@ -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 要求)。
* 业务逻辑在 NewExamClientclient 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 (
<Suspense fallback={<FormPageSkeleton />}>
<NewExamClient />
</Suspense>
);
}

View File

@@ -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 要求)。
* 业务逻辑在 ExamsListClientclient 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 (
<Suspense fallback={<ListPageSkeleton rows={5} />}>
<ExamsListClient />
</Suspense>
);
}

View File

@@ -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("--");
});
});

View File

@@ -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
* - loadingDetailPageSkeleton
* - errorerrorNode 局部降级
* - notFounddata 为 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 包裹在 <Suspense> 中。
*/
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 ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
</div>
) : undefined;
return (
<DetailPageShell
title={data?.title ?? t("detail.title")}
description={
data
? t("detail.createdAtPrefix", {
date: formatExamDate(data.createdAt),
})
: undefined
}
icon={<FileText className="size-6" />}
backHref="/shell/teacher/exams"
actions={
data && isExamEditable(data.status) ? (
<Button variant="outline">{t("detail.edit")}</Button>
) : null
}
loading={loading}
loadingNode={<DetailPageSkeleton />}
errorNode={errorNode}
emptyNode={
!loading && !error && !data ? (
<div className="rounded-xl border p-6 text-center text-muted-foreground">
{t("detail.notFound")}
</div>
) : undefined
}
>
{data ? <ExamDetailBody exam={data} /> : null}
</DetailPageShell>
);
}
/**
* 详情内容区(基本信息 + 状态信息两个分区)。
*/
function ExamDetailBody({ exam }: { exam: Exam }): React.ReactElement {
const t = useTranslations("exams");
return (
<>
<DetailSection title={t("detail.sectionBasic")}>
<DetailField label={t("detail.fieldTitle")} value={exam.title} />
<DetailField
label={t("detail.fieldDescription")}
value={exam.description ?? "-"}
/>
<DetailField
label={t("detail.fieldExamDate")}
value={formatExamDate(exam.examDate)}
/>
<DetailField
label={t("detail.fieldDuration")}
value={formatDuration(exam.duration)}
/>
<DetailField
label={t("detail.fieldTotalScore")}
value={`${parseTotalScore(exam.totalScore)} ${t("detail.unitScore")}`}
/>
<DetailField label={t("detail.fieldClassId")} value={exam.classId} />
<DetailField
label={t("detail.fieldSubjectId")}
value={exam.subjectId}
/>
</DetailSection>
<DetailSection title={t("detail.sectionStatus")}>
<DetailField
label={t("detail.fieldCurrentStatus")}
value={formatExamStatus(exam.status)}
/>
<DetailField
label={t("detail.fieldStatusChangedAt")}
value={formatExamDate(exam.statusChangedAt)}
/>
<DetailField
label={t("detail.fieldStatusChangedBy")}
value={exam.statusChangedBy ?? "-"}
/>
<DetailField
label={t("detail.fieldCreatedBy")}
value={exam.createdBy}
/>
<DetailField
label={t("detail.fieldCreatedAt")}
value={formatExamDate(exam.createdAt)}
/>
<DetailField
label={t("detail.fieldUpdatedAt")}
value={formatExamDate(exam.updatedAt)}
/>
</DetailSection>
</>
);
}

View File

@@ -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 DoDloading骨架/ error局部降级/ emptyEmptyState
*
* 关联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 包裹在 <Suspense> 中
* 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-pendingMSW 兜底
const { data, loading, error } = useExams(classId);
// 客户端二次筛选status + q—— 后端补齐列表查询后改服务端筛选
const filteredItems = useMemo<ExamListItem[]>(() => {
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 ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{/* @contract-pending 提示MSW 兜底时若未开启 NEXT_PUBLIC_MSW=1 会失败 */}
{t("list.mswNotice")}
</p>
</div>
) : undefined;
return (
<ListPageShell
title={t("list.title")}
description={t("list.description")}
icon={<FileText className="size-6" />}
actions={
<Button asChild>
<Link href={`/shell/teacher/exams/new?classId=${classId}`}>
{t("list.new")}
</Link>
</Button>
}
filters={
<>
<FilterSearchInput
placeholder={t("list.searchPlaceholder")}
value={q}
onChange={(v) => updateQuery("q", v)}
/>
<select
value={statusFilter}
onChange={(e) => updateQuery("status", e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("list.statusFilter")}
>
<option value="">{t("list.statusAll")}</option>
<option value="DRAFT">{t("list.statusDraft")}</option>
<option value="PUBLISHED">{t("list.statusPublished")}</option>
<option value="IN_PROGRESS">{t("list.statusInProgress")}</option>
<option value="SCORED">{t("list.statusScored")}</option>
</select>
</>
}
loading={loading}
loadingNode={<ListPageSkeleton rows={5} />}
empty={filteredItems.length === 0 && !loading}
errorNode={errorNode}
pagination={
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("list.total", { count: filteredItems.length })}</span>
</div>
}
>
<ExamsTable items={filteredItems} />
</ListPageShell>
);
}
/**
* 考试列表表格(纯展示组件,对齐 §8.2 排版规范)。
*/
function ExamsTable({ items }: { items: ExamListItem[] }): React.ReactElement {
const t = useTranslations("exams");
return (
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left font-medium">{t("list.colName")}</th>
<th className="p-3 text-left font-medium">{t("list.colStatus")}</th>
<th className="p-3 text-left font-medium">
{t("list.colExamDate")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colDuration")}
</th>
<th className="p-3 text-left font-medium">
{t("list.colTotalScore")}
</th>
<th className="p-3 text-right font-medium">
{t("list.colActions")}
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((exam) => (
<tr key={exam.id} className="hover:bg-muted/30">
<td className="p-3">
<Link
href={`/shell/teacher/exams/${exam.id}`}
className="font-medium hover:underline"
>
{exam.title}
</Link>
{exam.description ? (
<p className="mt-1 text-xs text-muted-foreground">
{exam.description}
</p>
) : null}
</td>
<td className="p-3">
<ExamStatusBadge status={exam.status} />
</td>
<td className="p-3 font-mono text-xs">
{formatExamDate(exam.examDate)}
</td>
<td className="p-3 text-xs">{formatDuration(exam.duration)}</td>
<td className="p-3">
{parseTotalScore(exam.totalScore)} {t("list.unitScore")}
</td>
<td className="p-3 text-right">
<Link
href={`/shell/teacher/exams/${exam.id}`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t("list.viewDetail")}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/**
* 考试状态徽章(按状态色阶展示)。
*/
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 (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label}
</span>
);
}

View File

@@ -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
* - loadingFormPageSkeleton初始数据加载由 server page Suspense 兜底)
* - errorerrorSummary 表单级错误
* - successnotify.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 包裹在 <Suspense> 中。
*/
export function NewExamClient(): React.ReactElement {
const t = useTranslations("exams");
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const presetClassId = searchParams.get("classId") ?? "";
// @contract-pendingMSW 兜底
const { run: createExam, loading: submitting } = useCreateExam();
const handleSubmit = async (input: CreateExamInput): Promise<void> => {
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 (
<NewExamFormInner
presetClassId={presetClassId}
submitting={submitting}
onSubmit={handleSubmit}
/>
);
}
/**
* 表单主体(受控表单 + 内联校验)。
*
* 注:未引入 react-hook-form + zod因当前仅一个表单引入会增加依赖。
* 后续表单数量增多后统一迁移到 react-hook-form§7.3 表单页模板建议)。
*/
function NewExamFormInner({
presetClassId,
submitting,
onSubmit,
}: {
presetClassId: string;
submitting: boolean;
onSubmit: (input: CreateExamInput) => Promise<void>;
}): 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<string | null>(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 (
<FormPageShell
title={t("new.title")}
description={t("new.description")}
icon={<FileText className="size-6" />}
backHref={`/shell/teacher/exams?classId=${classId}`}
onSubmit={handleFormSubmit}
submitting={submitting}
submitLabel={t("new.submit")}
cancelLabel={tCommon("button.cancel")}
errorSummary={
error ? <p className="text-sm text-destructive">{error}</p> : undefined
}
>
{/* 班级 ID */}
<FormField label={t("new.classId")} required>
<input
type="text"
value={classId}
onChange={(e) => setClassId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder="cls-001"
required
/>
</FormField>
{/* 科目 ID */}
<FormField label={t("new.subjectId")} required>
<input
type="text"
value={subjectId}
onChange={(e) => setSubjectId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder="sub-math"
required
/>
</FormField>
{/* 标题 */}
<FormField label={t("new.titleLabel")} required>
<input
type="text"
value={title}
onChange={(e) => 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
/>
</FormField>
{/* 描述 */}
<FormField label={t("new.descriptionLabel")}>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder={t("new.descriptionPlaceholder")}
/>
</FormField>
{/* 考试日期 */}
<FormField label={t("new.examDate")} required>
<input
type="datetime-local"
value={examDate}
onChange={(e) => setExamDate(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
required
/>
</FormField>
{/* 时长 + 满分 */}
<div className="grid grid-cols-2 gap-4">
<FormField label={t("new.duration")} required>
<input
type="number"
min={1}
value={duration}
onChange={(e) => setDuration(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
required
/>
</FormField>
<FormField label={t("new.totalScore")} required>
<input
type="number"
min={1}
value={totalScore}
onChange={(e) => setTotalScore(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
required
/>
</FormField>
</div>
{/* @contract-pending 提示 */}
<p className="text-xs text-muted-foreground">
{t("new.contractPending")}
</p>
</FormPageShell>
);
}
/**
* 表单字段容器label + children
* 简化版,后续可提取到 shared/components/ui/form-field.tsx 复用。
*/
function FormField({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: React.ReactNode;
}): React.ReactElement {
return (
<div className="space-y-2">
<label className="text-sm font-medium">
{label}
{required ? <span className="ml-1 text-destructive">*</span> : null}
</label>
{children}
</div>
);
}

View File

@@ -0,0 +1,103 @@
/**
* Exams 数据变换工具ARCHITECTURE.md §11.3 DoD - 纯函数单测)
*
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
* 关联ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
*/
import type { Exam, ExamListItem } from "@/lib/api";
/** 考试状态中文标签映射(对齐旧 teacher-portal EXAM_STATUS_LABEL */
export const EXAM_STATUS_LABEL: Record<string, string> = {
DRAFT: "草稿",
PUBLISHED: "已发布",
IN_PROGRESS: "进行中",
GRADING: "批改中",
SCORED: "已完成",
ARCHIVED: "已归档",
};
/**
* 将考试状态枚举值映射为中文标签。
* 未知状态回退为原始值。
*/
export function formatExamStatus(status: string): string {
return EXAM_STATUS_LABEL[status] ?? status;
}
/**
* 格式化 ISO 日期字符串为本地化展示zh-CN
* 输入无效时返回占位符。
*/
export function formatExamDate(isoDate: string | null | undefined): string {
if (!isoDate) return "--";
const d = new Date(isoDate);
if (Number.isNaN(d.getTime())) return "--";
return d.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* 将 schema 的 totalScoreString转为数值用于展示。
* 转换失败返回 0。
*/
export function parseTotalScore(totalScore: string | number): number {
const n = typeof totalScore === "number" ? totalScore : Number(totalScore);
return Number.isFinite(n) ? n : 0;
}
/**
* 判断考试是否处于可编辑状态DRAFT
*/
export function isExamEditable(status: string): boolean {
return status === "DRAFT";
}
/**
* 判断考试是否处于已发布后的状态(不可再编辑)。
*/
export function isExamPublished(status: string): boolean {
return (
status === "PUBLISHED" ||
status === "IN_PROGRESS" ||
status === "GRADING" ||
status === "SCORED" ||
status === "ARCHIVED"
);
}
/**
* 从考试详情中提取列表项视图模型(裁剪字段)。
*/
export function toExamListItem(exam: Exam): ExamListItem {
return {
id: exam.id,
classId: exam.classId,
subjectId: exam.subjectId,
title: exam.title,
description: exam.description,
examDate: exam.examDate,
duration: exam.duration,
totalScore: exam.totalScore,
status: exam.status,
createdAt: exam.createdAt,
};
}
/**
* 格式化时长(分钟)为更友好的展示。
* - 60 分钟以下:返回 "N 分钟"
* - 60 分钟以上:返回 "X 小时 Y 分钟"
*/
export function formatDuration(minutes: number): string {
if (!Number.isFinite(minutes) || minutes <= 0) return "--";
if (minutes < 60) return `${minutes} 分钟`;
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
return rest === 0 ? `${hours} 小时` : `${hours} 小时 ${rest} 分钟`;
}

View File

@@ -0,0 +1,208 @@
"use client";
/**
* Exams domain APIARCHITECTURE.md §5.1 / §5.3 / §9.1 教师域考试模块)
*
* 三类操作:
* 1. useExam按 id 单查):✅ 真实查询 exam(id: ID!)schema 已就绪
* 2. useExams列表查询❌ schema 无 exams(classId) → MSW 兜底(@contract-pending
* 3. useCreateExammutation❌ schema 无 Mutation → MSW 兜底(@contract-pending
*
* 契约工单docs/architecture/issues/contracts/core-edu_contract.md
* 后端补齐后:重跑 normalize + codegen → 关闭 skipDocumentsValidation → 切换 fetcher → 删 mock
*
* 关联ARCHITECTURE.md §5.3 契约纪律 / §5.4 MSW 兜底 / §9.1 / §11.4 契约工单
*/
import type { FetchPolicy } from "@apollo/client";
import { useWidgetMutation } from "../useWidgetMutation";
import { useWidgetQuery } from "../useWidgetQuery";
import { ApiError } from "./errors";
import {
CREATE_EXAM_DOC,
GET_EXAM_DOC,
GET_EXAMS_DOC,
} from "./operations/exams.graphql";
import type { UseQueryResult } from "./types";
// ===== 数据类型 =====
/**
* 考试实体(对齐 combined-schema.graphql Exam 类型)
*
* 字段命名 camelCase与 schema 一致Exam.totalScore 在 schema 中是 String
* 但业务层语义为数值,使用时由调用方做 Number() 转换。
*/
export interface Exam {
id: string;
classId: string;
subjectId: string;
title: string;
description: string | null;
examDate: string;
duration: number;
totalScore: string;
status: string;
statusChangedAt: string;
statusChangedBy: string | null;
schoolId: string;
createdBy: string;
createdAt: string;
updatedAt: string;
}
/** 考试列表项(轻量字段集,用于列表渲染) */
export interface ExamListItem {
id: string;
classId: string;
subjectId: string;
title: string;
description: string | null;
examDate: string;
duration: number;
totalScore: string;
status: string;
createdAt: string;
}
/** 列表查询响应(@contract-pending 假契约形状MSW 返回此结构) */
interface ExamsListResponse {
exams: {
items: ExamListItem[];
total: number;
};
}
/** 单查响应(真实 schema */
interface ExamResponse {
exam: Exam | null;
}
/** 新建考试输入 */
export interface CreateExamInput {
classId: string;
subjectId: string;
title: string;
description?: string;
examDate: string;
duration: number;
totalScore: number;
}
/** 新建考试 mutation 响应(@contract-pending */
interface CreateExamResponse {
createExam: { id: string } | null;
}
// ===== 查询选项 =====
export interface ExamQueryOptions {
enabled?: boolean;
pollInterval?: number;
fetchPolicy?: FetchPolicy;
}
// ===== Hooks =====
/**
* 按 id 查询考试详情(真实 schema✅ 契约已就绪)。
*
* 关联ARCHITECTURE.md §5.5 后端已就绪查询 / §9.1 详情页
*/
export function useExam(
id: string,
options?: ExamQueryOptions,
): UseQueryResult<Exam | null> {
const result = useWidgetQuery<ExamResponse, { id: string }>(
GET_EXAM_DOC,
{ id },
{
...options,
enabled: options?.enabled ?? id.length > 0,
},
);
return {
data: result.data?.exam ?? null,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询班级下的考试列表(@contract-pendingMSW 兜底)。
*
* schema 无 exams(classId) 根字段,由 MSW handlers 返回 mock 数据。
* 后端补齐列表查询后切换到真实 fetcher页面无需改动。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 列表页 / §11.4 契约工单
*/
export function useExams(
classId: string,
options?: ExamQueryOptions & {
status?: string;
limit?: number;
offset?: number;
},
): UseQueryResult<{ items: ExamListItem[]; total: number }> {
const result = useWidgetQuery<
ExamsListResponse,
{
classId: string;
status?: string;
limit?: number;
offset?: number;
}
>(
GET_EXAMS_DOC,
{
classId,
status: options?.status,
limit: options?.limit,
offset: options?.offset,
},
{
enabled: options?.enabled ?? classId.length > 0,
fetchPolicy: options?.fetchPolicy,
pollInterval: options?.pollInterval,
},
);
return {
data: result.data?.exams,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 创建考试(@contract-pendingMSW 兜底)。
*
* schema 无 Mutation 类型,由 MSW handlers 返回 mock 数据。
* 后端补齐 mutation 后切换到真实 fetcher。
*
* 关联ARCHITECTURE.md §5.4 / §9.1 表单页 / §11.4 契约工单
*/
export function useCreateExam(): {
run: (input: CreateExamInput) => Promise<{ id: string }>;
loading: boolean;
error: unknown;
} {
const {
run: rawRun,
loading,
error,
} = useWidgetMutation<CreateExamResponse, { input: CreateExamInput }>(
CREATE_EXAM_DOC,
);
const run = async (input: CreateExamInput): Promise<{ id: string }> => {
const data = await rawRun({ input });
if (!data?.createExam) {
throw new ApiError("Failed to create exam", "INTERNAL_ERROR");
}
return data.createExam;
};
return { run, loading, error };
}

View File

@@ -14,6 +14,7 @@ export * from "./dashboard";
export * from "./sidebar"; export * from "./sidebar";
export * from "./topbar"; export * from "./topbar";
export * from "./teacher"; export * from "./teacher";
export * from "./exams";
export * from "./student"; export * from "./student";
export * from "./parent"; export * from "./parent";
export * from "./admin"; export * from "./admin";

View File

@@ -0,0 +1,72 @@
// Exams domain GraphQL documents (ARCHITECTURE.md §5.3 契约纪律 / §9.1)
//
// 拆分原则:
// - GetExam按 id 单查):✅ combined-schema 中真实存在exam(id: ID!): Exam
// - GetExams列表查询❌ schema 无 exams(classId) 根字段
// → 走 MSW 兜底(@contract-pending等待后端补齐列表契约
// - CreateExammutation❌ schema 无 Mutation 类型
// → 走 MSW 兜底(@contract-pending等待后端补齐 mutation 契约
//
// 契约工单docs/architecture/issues/contracts/core-edu_contract.md
// 关联ARCHITECTURE.md §5.3 / §5.4 / §9.1 / §11.4
import { gql } from "@apollo/client";
// ── 真实查询exam(id) 单查 ─────────────────────────────────────
// 字段全部对齐 combined-schema.graphql 中 Exam 类型core-edu 子图)
export const GET_EXAM_DOC = gql`
query GetExam($id: ID!) {
exam(id: $id) {
id
classId
subjectId
title
description
examDate
duration
totalScore
status
statusChangedAt
statusChangedBy
schoolId
createdBy
createdAt
updatedAt
}
}
`;
// ── 假契约查询(@contract-pending─────────────────────────────
// 列表查询schema 无 exams(classId) 根字段
// 页面通过 MSW 兜底获取列表数据,后端补齐后切换 fetcher 指向真实查询
// 契约工单core-edu_contract.md#exams-list
export const GET_EXAMS_DOC = gql`
query GetExams($classId: ID!, $status: String, $limit: Int, $offset: Int) {
exams(classId: $classId, status: $status, limit: $limit, offset: $offset) {
items {
id
classId
subjectId
title
description
examDate
duration
totalScore
status
createdAt
}
total
}
}
`;
// ── 假契约变更(@contract-pending─────────────────────────────
// 创建考试schema 无 Mutation 类型
// 页面通过 MSW 兜底提交,后端补齐 mutation 后切换 fetcher
// 契约工单core-edu_contract.md#create-exam-mutation
export const CREATE_EXAM_DOC = gql`
mutation CreateExam($input: CreateExamInput!) {
createExam(input: $input) {
id
}
}
`;

View File

@@ -4,6 +4,7 @@ export * from "./universal.graphql";
export * from "./sidebar.graphql"; export * from "./sidebar.graphql";
export * from "./topbar.graphql"; export * from "./topbar.graphql";
export * from "./teacher.graphql"; export * from "./teacher.graphql";
export * from "./exams.graphql";
export * from "./student.graphql"; export * from "./student.graphql";
export * from "./parent.graphql"; export * from "./parent.graphql";
export * from "./admin.graphql"; export * from "./admin.graphql";

View File

@@ -146,7 +146,71 @@
"title": "Classes" "title": "Classes"
}, },
"exams": { "exams": {
"title": "Exams" "title": "Exams",
"list": {
"title": "Exams",
"description": "View and manage all exams",
"new": "New Exam",
"searchPlaceholder": "Search exam name...",
"statusFilter": "Filter by status",
"statusAll": "All statuses",
"statusDraft": "Draft",
"statusPublished": "Published",
"statusInProgress": "In Progress",
"statusScored": "Completed",
"total": "{count} total",
"colName": "Name",
"colStatus": "Status",
"colExamDate": "Exam Date",
"colDuration": "Duration",
"colTotalScore": "Total Score",
"colActions": "Actions",
"viewDetail": "View Detail →",
"mswNotice": "List query contract is pending, please ensure NEXT_PUBLIC_MSW=1 is enabled.",
"unitScore": "pts"
},
"detail": {
"title": "Exam Detail",
"edit": "Edit",
"notFound": "Exam not found, may have been deleted",
"createdAtPrefix": "Created on {date}",
"sectionBasic": "Basic Info",
"sectionStatus": "Status Info",
"fieldTitle": "Title",
"fieldDescription": "Description",
"fieldExamDate": "Exam Date",
"fieldDuration": "Duration",
"fieldTotalScore": "Total Score",
"fieldClassId": "Class ID",
"fieldSubjectId": "Subject ID",
"fieldCurrentStatus": "Current Status",
"fieldStatusChangedAt": "Status Changed At",
"fieldStatusChangedBy": "Status Changed By",
"fieldCreatedBy": "Created By",
"fieldCreatedAt": "Created At",
"fieldUpdatedAt": "Updated At",
"unitScore": "pts"
},
"new": {
"title": "New Exam",
"description": "Fill in exam basic information",
"submit": "Create Exam",
"success": "Exam created successfully",
"error": "Creation failed",
"classId": "Class ID",
"subjectId": "Subject ID",
"titleLabel": "Title",
"titlePlaceholder": "e.g. 2026 Spring Midterm",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Exam scope, notes, etc.",
"examDate": "Exam Date",
"duration": "Duration (minutes)",
"totalScore": "Total Score",
"errorClassRequired": "Please fill in Class ID",
"errorTitleRequired": "Please fill in exam title",
"errorDateRequired": "Please select exam date",
"contractPending": "Create exam contract is @contract-pending, currently backed by MSW. Will switch to real submission once backend mutation is ready."
}
}, },
"homework": { "homework": {
"title": "Homework" "title": "Homework"

View File

@@ -146,7 +146,71 @@
"title": "班级管理" "title": "班级管理"
}, },
"exams": { "exams": {
"title": "考试管理" "title": "考试管理",
"list": {
"title": "考试管理",
"description": "查看和管理所有考试",
"new": "新建考试",
"searchPlaceholder": "搜索考试名称...",
"statusFilter": "按状态筛选",
"statusAll": "全部状态",
"statusDraft": "草稿",
"statusPublished": "已发布",
"statusInProgress": "进行中",
"statusScored": "已完成",
"total": "共 {count} 条",
"colName": "名称",
"colStatus": "状态",
"colExamDate": "考试时间",
"colDuration": "时长",
"colTotalScore": "满分",
"colActions": "操作",
"viewDetail": "查看详情 →",
"mswNotice": "列表查询契约待补齐,请确认 NEXT_PUBLIC_MSW=1 已启用。",
"unitScore": "分"
},
"detail": {
"title": "考试详情",
"edit": "编辑",
"notFound": "未找到考试,可能已被删除",
"createdAtPrefix": "创建于 {date}",
"sectionBasic": "基本信息",
"sectionStatus": "状态信息",
"fieldTitle": "标题",
"fieldDescription": "描述",
"fieldExamDate": "考试时间",
"fieldDuration": "时长",
"fieldTotalScore": "满分",
"fieldClassId": "班级 ID",
"fieldSubjectId": "科目 ID",
"fieldCurrentStatus": "当前状态",
"fieldStatusChangedAt": "状态变更时间",
"fieldStatusChangedBy": "状态变更人",
"fieldCreatedBy": "创建人",
"fieldCreatedAt": "创建时间",
"fieldUpdatedAt": "更新时间",
"unitScore": "分"
},
"new": {
"title": "新建考试",
"description": "填写考试基本信息",
"submit": "创建考试",
"success": "考试创建成功",
"error": "创建失败",
"classId": "班级 ID",
"subjectId": "科目 ID",
"titleLabel": "标题",
"titlePlaceholder": "例如2026 春季期中考试",
"descriptionLabel": "描述",
"descriptionPlaceholder": "考试范围、注意事项等",
"examDate": "考试日期",
"duration": "时长(分钟)",
"totalScore": "满分",
"errorClassRequired": "请填写班级 ID",
"errorTitleRequired": "请填写考试标题",
"errorDateRequired": "请选择考试日期",
"contractPending": "创建考试契约为 @contract-pending当前通过 MSW 兜底。后端补齐 mutation 后将切换为真实提交。"
}
}, },
"homework": { "homework": {
"title": "作业管理" "title": "作业管理"

View File

@@ -245,6 +245,67 @@ const mockTextbooks = [
}, },
]; ];
// ── Exams 域(@contract-pendingschema 无 exams 列表/createExam mutation
// 用于 /shell/teacher/exams 列表页 + /new 表单页 MSW 兜底
const mockExams = [
{
id: "exam-001",
classId: "cls-001",
subjectId: "sub-math",
title: "2026 春季期中考试",
description: "覆盖集合、函数、基本初等函数",
examDate: "2026-04-15T09:00:00Z",
duration: 120,
totalScore: "100",
status: "SCORED",
createdAt: "2026-04-01T00:00:00Z",
},
{
id: "exam-002",
classId: "cls-001",
subjectId: "sub-math",
title: "2026 春季期末考试",
description: "全册内容",
examDate: "2026-07-20T09:00:00Z",
duration: 120,
totalScore: "150",
status: "IN_PROGRESS",
createdAt: "2026-07-10T00:00:00Z",
},
{
id: "exam-003",
classId: "cls-001",
subjectId: "sub-math",
title: "单元测验 - 集合",
description: null,
examDate: "2026-07-25T14:00:00Z",
duration: 45,
totalScore: "50",
status: "DRAFT",
createdAt: "2026-07-22T00:00:00Z",
},
];
// ── Exam 单查 mock与 combined-schema Exam 类型字段对齐)
// 用于 /shell/teacher/exams/[id] 详情页 MSW 兜底
const mockExamDetail = {
id: "exam-001",
classId: "cls-001",
subjectId: "sub-math",
title: "2026 春季期中考试",
description: "覆盖集合、函数、基本初等函数",
examDate: "2026-04-15T09:00:00Z",
duration: 120,
totalScore: "100",
status: "SCORED",
statusChangedAt: "2026-04-16T10:00:00Z",
statusChangedBy: "usr-teacher-001",
schoolId: "sch-001",
createdBy: "usr-teacher-001",
createdAt: "2026-04-01T00:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
};
const mockGrades = [ const mockGrades = [
{ {
student_id: "stu-001", student_id: "stu-001",
@@ -275,10 +336,26 @@ const mockGrades = [
// ── GraphQL Response ─────────────────────────────────────────── // ── GraphQL Response ───────────────────────────────────────────
/** /**
* 根据 operationName 返回 mock GraphQL 响应 * GraphQL 请求体结构(用于 MSW handler 与 route.ts 透传)
*/
export interface GraphQLRequestBody {
operationName?: string;
variables?: Record<string, unknown>;
}
/**
* 根据 operationName + variables 返回 mock GraphQL 响应。
*
* Exams 域扩展:
* - GetExam($id):返回 mockExamDetail任意 id 都返回同一条dev 兜底用)
* - GetExams($classId):返回 mockExams按 classId 过滤,未指定返回全部)
* - CreateExam($input):返回新生成的 id基于时间戳
*
* 关联ARCHITECTURE.md §5.4 / §9.1 / §11.4 契约工单
*/ */
export function graphqlResponse( export function graphqlResponse(
operationName: string | undefined, operationName: string | undefined,
variables?: Record<string, unknown>,
): Record<string, unknown> { ): Record<string, unknown> {
switch (operationName) { switch (operationName) {
// ── Dashboard 域 ── // ── Dashboard 域 ──
@@ -408,6 +485,62 @@ export function graphqlResponse(
}, },
}; };
// ── Exams 域(教师域 P2 迁移,@contract-pending──
// GetExam($id):按 id 单查,任意 id 都返回同一条dev 兜底)
case "GetExam": {
const examId = (variables?.id as string | undefined) ?? "";
// 若请求的是 mockExams 中的某一条,返回对应数据;否则返回 mockExamDetail
const found = mockExams.find((e) => e.id === examId) ?? mockExamDetail;
return {
data: {
exam: {
...found,
statusChangedAt: "2026-04-16T10:00:00Z",
statusChangedBy: "usr-teacher-001",
schoolId: "sch-001",
createdBy: "usr-teacher-001",
updatedAt: "2026-04-16T10:00:00Z",
},
},
};
}
// GetExams($classId):列表查询,按 classId 过滤(未指定返回全部)
case "GetExams": {
const classId = variables?.classId as string | undefined;
const status = variables?.status as string | undefined;
const filtered = mockExams.filter(
(e) =>
(!classId || e.classId === classId) &&
(!status || e.status === status),
);
return {
data: {
exams: {
items: filtered,
total: filtered.length,
},
},
};
}
// CreateExam($input)mutation 兜底,返回基于时间戳的新 id
case "CreateExam": {
const input = (variables?.input ?? {}) as Record<string, unknown>;
const newId = `exam-${Date.now()}`;
mockExams.push({
id: newId,
classId: (input.classId as string) ?? "cls-001",
subjectId: (input.subjectId as string) ?? "sub-math",
title: (input.title as string) ?? "未命名考试",
description: (input.description as string | null) ?? null,
examDate: (input.examDate as string) ?? new Date().toISOString(),
duration: (input.duration as number) ?? 120,
totalScore: String((input.totalScore as number) ?? 100),
status: "DRAFT",
createdAt: new Date().toISOString(),
});
return { data: { createExam: { id: newId } } };
}
// ── Grades 域(预留) ── // ── Grades 域(预留) ──
case "GetGrades": case "GetGrades":
return { data: { grades: mockGrades } }; return { data: { grades: mockGrades } };

View File

@@ -24,14 +24,24 @@ const APOLLO_ROUTER_GRAPHQL =
export const handlers = [ export const handlers = [
// GraphQL 同域代理(客户端 Apollo Client // GraphQL 同域代理(客户端 Apollo Client
http.post("/api/graphql", async ({ request }) => { http.post("/api/graphql", async ({ request }) => {
const body = (await request.json()) as { operationName?: string }; const body = (await request.json()) as {
return HttpResponse.json(graphqlResponse(body.operationName)); operationName?: string;
variables?: Record<string, unknown>;
};
return HttpResponse.json(
graphqlResponse(body.operationName, body.variables),
);
}), }),
// GraphQL SSR 直连兜底msw/node server 拦截 RSC 端 fetch // GraphQL SSR 直连兜底msw/node server 拦截 RSC 端 fetch
http.post(APOLLO_ROUTER_GRAPHQL, async ({ request }) => { http.post(APOLLO_ROUTER_GRAPHQL, async ({ request }) => {
const body = (await request.json()) as { operationName?: string }; const body = (await request.json()) as {
return HttpResponse.json(graphqlResponse(body.operationName)); operationName?: string;
variables?: Record<string, unknown>;
};
return HttpResponse.json(
graphqlResponse(body.operationName, body.variables),
);
}), }),
// 登录兜底DEV_MODE 使用) // 登录兜底DEV_MODE 使用)