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:
@@ -59,10 +59,12 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
if (MSW_ENABLED) {
|
||||
const body = (await req.json().catch(() => ({}))) as {
|
||||
operationName?: string;
|
||||
variables?: Record<string, unknown>;
|
||||
};
|
||||
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");
|
||||
|
||||
22
apps/portal-shell/src/app/shell/teacher/exams/[id]/page.tsx
Normal file
22
apps/portal-shell/src/app/shell/teacher/exams/[id]/page.tsx
Normal 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 边界包裹。
|
||||
* 业务逻辑在 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 (
|
||||
<Suspense fallback={<DetailPageSkeleton />}>
|
||||
<ExamDetailClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
33
apps/portal-shell/src/app/shell/teacher/exams/error.tsx
Normal file
33
apps/portal-shell/src/app/shell/teacher/exams/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
23
apps/portal-shell/src/app/shell/teacher/exams/new/page.tsx
Normal file
23
apps/portal-shell/src/app/shell/teacher/exams/new/page.tsx
Normal 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 要求)。
|
||||
* 业务逻辑在 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 (
|
||||
<Suspense fallback={<FormPageSkeleton />}>
|
||||
<NewExamClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
23
apps/portal-shell/src/app/shell/teacher/exams/page.tsx
Normal file
23
apps/portal-shell/src/app/shell/teacher/exams/page.tsx
Normal 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 要求)。
|
||||
* 业务逻辑在 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 (
|
||||
<Suspense fallback={<ListPageSkeleton rows={5} />}>
|
||||
<ExamsListClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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("--");
|
||||
});
|
||||
});
|
||||
@@ -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 包裹在 <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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 包裹在 <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-pending:MSW 兜底
|
||||
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>
|
||||
);
|
||||
}
|
||||
244
apps/portal-shell/src/features/teacher/exams/new-exam-client.tsx
Normal file
244
apps/portal-shell/src/features/teacher/exams/new-exam-client.tsx
Normal 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):
|
||||
* - 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 包裹在 <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-pending:MSW 兜底
|
||||
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>
|
||||
);
|
||||
}
|
||||
103
apps/portal-shell/src/features/teacher/exams/transformations.ts
Normal file
103
apps/portal-shell/src/features/teacher/exams/transformations.ts
Normal 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 的 totalScore(String)转为数值用于展示。
|
||||
* 转换失败返回 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} 分钟`;
|
||||
}
|
||||
208
apps/portal-shell/src/lib/api/exams.ts
Normal file
208
apps/portal-shell/src/lib/api/exams.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Exams domain API(ARCHITECTURE.md §5.1 / §5.3 / §9.1 教师域考试模块)
|
||||
*
|
||||
* 三类操作:
|
||||
* 1. useExam(按 id 单查):✅ 真实查询 exam(id: ID!),schema 已就绪
|
||||
* 2. useExams(列表查询):❌ schema 无 exams(classId) → MSW 兜底(@contract-pending)
|
||||
* 3. useCreateExam(mutation):❌ 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-pending,MSW 兜底)。
|
||||
*
|
||||
* 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-pending,MSW 兜底)。
|
||||
*
|
||||
* 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 };
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export * from "./dashboard";
|
||||
export * from "./sidebar";
|
||||
export * from "./topbar";
|
||||
export * from "./teacher";
|
||||
export * from "./exams";
|
||||
export * from "./student";
|
||||
export * from "./parent";
|
||||
export * from "./admin";
|
||||
|
||||
72
apps/portal-shell/src/lib/api/operations/exams.graphql.ts
Normal file
72
apps/portal-shell/src/lib/api/operations/exams.graphql.ts
Normal 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),等待后端补齐列表契约
|
||||
// - CreateExam(mutation):❌ 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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -4,6 +4,7 @@ export * from "./universal.graphql";
|
||||
export * from "./sidebar.graphql";
|
||||
export * from "./topbar.graphql";
|
||||
export * from "./teacher.graphql";
|
||||
export * from "./exams.graphql";
|
||||
export * from "./student.graphql";
|
||||
export * from "./parent.graphql";
|
||||
export * from "./admin.graphql";
|
||||
|
||||
@@ -146,7 +146,71 @@
|
||||
"title": "Classes"
|
||||
},
|
||||
"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": {
|
||||
"title": "Homework"
|
||||
|
||||
@@ -146,7 +146,71 @@
|
||||
"title": "班级管理"
|
||||
},
|
||||
"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": {
|
||||
"title": "作业管理"
|
||||
|
||||
@@ -245,6 +245,67 @@ const mockTextbooks = [
|
||||
},
|
||||
];
|
||||
|
||||
// ── Exams 域(@contract-pending,schema 无 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 = [
|
||||
{
|
||||
student_id: "stu-001",
|
||||
@@ -275,10 +336,26 @@ const mockGrades = [
|
||||
// ── 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(
|
||||
operationName: string | undefined,
|
||||
variables?: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
switch (operationName) {
|
||||
// ── 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 域(预留) ──
|
||||
case "GetGrades":
|
||||
return { data: { grades: mockGrades } };
|
||||
|
||||
@@ -24,14 +24,24 @@ const APOLLO_ROUTER_GRAPHQL =
|
||||
export const handlers = [
|
||||
// GraphQL 同域代理(客户端 Apollo Client)
|
||||
http.post("/api/graphql", async ({ request }) => {
|
||||
const body = (await request.json()) as { operationName?: string };
|
||||
return HttpResponse.json(graphqlResponse(body.operationName));
|
||||
const body = (await request.json()) as {
|
||||
operationName?: string;
|
||||
variables?: Record<string, unknown>;
|
||||
};
|
||||
return HttpResponse.json(
|
||||
graphqlResponse(body.operationName, body.variables),
|
||||
);
|
||||
}),
|
||||
|
||||
// GraphQL SSR 直连兜底(msw/node server 拦截 RSC 端 fetch)
|
||||
http.post(APOLLO_ROUTER_GRAPHQL, async ({ request }) => {
|
||||
const body = (await request.json()) as { operationName?: string };
|
||||
return HttpResponse.json(graphqlResponse(body.operationName));
|
||||
const body = (await request.json()) as {
|
||||
operationName?: string;
|
||||
variables?: Record<string, unknown>;
|
||||
};
|
||||
return HttpResponse.json(
|
||||
graphqlResponse(body.operationName, body.variables),
|
||||
);
|
||||
}),
|
||||
|
||||
// 登录兜底(DEV_MODE 使用)
|
||||
|
||||
Reference in New Issue
Block a user