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:
@@ -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} 分钟`;
|
||||
}
|
||||
Reference in New Issue
Block a user