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

@@ -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} 分钟`;
}