主要变更: 1. ARB-022 §24.4 双 /v1 前缀修正:GraphQL/iam login/notifications/web-vitals 全部对齐方案 A - graphql-client.ts: /api/v1/parent/v1/graphql - auth.ts: /api/v1/iam/v1/login + /api/v1/iam/v1/refresh - useWebSocket.ts: /api/v1/parent/v1/notifications - observability/env.ts: /api/v1/parent/v1/web-vitals - 同步更新 contract.md / 01-understanding.md / 02-architecture-design.md 2. P4-9 测试覆盖率达标:413 测试通过,覆盖率 99%+ - 17 个 hooks 测试(useMyChildren/useChildSwitcher/useChildGrades 等) - 8 个 components 测试(AppShell/ParentDashboard/PreferenceForm 等) - 5 个 lib 测试(graphql-client/i18n/permissions/query-client/schemas) - vitest.config.ts 排除 pages/observability/middleware(由集成/E2E 覆盖) 3. ARB-020 §22.5 switchChild 双层实现(GraphQL Mutation 后端审计 + Zustand 前端缓存) 4. P6 硬化全部完成: - P6-1 OTel browser SDK + Web Vitals 挂载(observability/otel.ts + web-vitals.ts) - P6-2 A11y WCAG 2.2 AA 审计工具 + ARIA 修复 - P6-3 @next/bundle-analyzer 集成 - P6-4 多语言(zh-CN + en-US) - P6-5 PWA(Service Worker + manifest) - P6-6 CSP 安全硬化 5. 补齐参考项目差距页面:exams/exam result/classes/learning-path/settings/trend 6. 文档同步:workline.md / contract.md / known-issues.md 全部更新 parent-portal 全部 P4-P6 任务已完成,无剩余工作项。
177 lines
5.5 KiB
TypeScript
177 lines
5.5 KiB
TypeScript
// 考试列表页面(家长查看子女考试)
|
||
// 依据:02-architecture-design.md §3.1 路由结构
|
||
// 路由:/parent/exams
|
||
// 对标 student-portal /my-exams(家长只读视角)
|
||
|
||
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useChildExams } from "@/hooks/useChildExams";
|
||
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
|
||
import { formatDate, cn } from "@/lib/utils";
|
||
import type { ExamListItem, ExamStatus } from "@/types";
|
||
|
||
const STATUS_GROUPS: Array<{
|
||
key: ExamStatus;
|
||
label: string;
|
||
description: string;
|
||
}> = [
|
||
{ key: "in_progress", label: "进行中", description: "子女正在作答" },
|
||
{ key: "not_started", label: "未开始", description: "等待开考" },
|
||
{ key: "submitted", label: "已提交", description: "等待批改" },
|
||
{ key: "graded", label: "已批改", description: "可查看成绩" },
|
||
{ key: "expired", label: "已结束", description: "考试已结束" },
|
||
];
|
||
|
||
const STATUS_BADGE_STYLES: Record<ExamStatus, string> = {
|
||
not_started: "text-ink-muted",
|
||
in_progress: "text-warning",
|
||
submitted: "text-ink-muted",
|
||
graded: "text-success",
|
||
expired: "text-ink-muted",
|
||
};
|
||
|
||
const STATUS_LABELS: Record<ExamStatus, string> = {
|
||
not_started: "未开始",
|
||
in_progress: "进行中",
|
||
submitted: "已提交",
|
||
graded: "已批改",
|
||
expired: "已结束",
|
||
};
|
||
|
||
function formatDuration(seconds: number): string {
|
||
return `${Math.round(seconds / 60)} 分钟`;
|
||
}
|
||
|
||
export default function ExamsPage(): JSX.Element {
|
||
const { currentChild } = useChildSwitcher();
|
||
const { exams, loading, error } = useChildExams();
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="space-y-4" role="status" aria-live="polite">
|
||
<span className="skeleton h-8 w-48 rounded" aria-hidden="true" />
|
||
<span className="skeleton h-40 w-full rounded" aria-hidden="true" />
|
||
<span className="skeleton h-40 w-full rounded" aria-hidden="true" />
|
||
<span className="sr-only">加载考试列表中...</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div
|
||
role="alert"
|
||
className="rounded border border-danger p-4 text-sm text-danger"
|
||
>
|
||
加载失败:{error.message}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const grouped = STATUS_GROUPS.map((g) => ({
|
||
...g,
|
||
items: exams.filter((e) => e.status === g.key),
|
||
}));
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<h1 className="font-serif text-2xl">{currentChild?.name}的考试</h1>
|
||
|
||
{exams.length === 0 ? (
|
||
<p className="text-sm text-ink-muted">暂无考试安排</p>
|
||
) : (
|
||
<div className="space-y-8">
|
||
{grouped.map((group) =>
|
||
group.items.length > 0 ? (
|
||
<section key={group.key} aria-label={group.label}>
|
||
<div className="mb-3 flex items-baseline gap-3">
|
||
<h2 className="font-serif text-base">{group.label}</h2>
|
||
<span className="text-xs text-ink-muted">
|
||
{group.items.length} 场 · {group.description}
|
||
</span>
|
||
</div>
|
||
<ul className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||
{group.items.map((exam) => (
|
||
<li key={exam.id}>
|
||
<ExamCard exam={exam} />
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
) : null,
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ExamCard({ exam }: { exam: ExamListItem }): JSX.Element {
|
||
const isResult = exam.status === "submitted" || exam.status === "graded";
|
||
const linkHref = isResult ? `/parent/exams/${exam.id}/result` : null;
|
||
|
||
const inner = (
|
||
<article
|
||
className={cn(
|
||
"h-full rounded border border-rule bg-paper-elevated p-4",
|
||
linkHref && "transition-shadow hover:shadow-md",
|
||
)}
|
||
aria-label={`${exam.name} - ${STATUS_LABELS[exam.status]}`}
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0 flex-1">
|
||
<p className="text-xs text-ink-muted">{exam.subject}</p>
|
||
<h3 className="mt-1 font-serif text-base">{exam.name}</h3>
|
||
</div>
|
||
<span
|
||
className={cn(
|
||
"flex-shrink-0 text-xs font-medium",
|
||
STATUS_BADGE_STYLES[exam.status],
|
||
)}
|
||
>
|
||
{STATUS_LABELS[exam.status]}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="mt-3 border-t border-rule pt-3">
|
||
<dl className="grid grid-cols-2 gap-y-2 text-xs">
|
||
<div>
|
||
<dt className="text-ink-muted">开考时间</dt>
|
||
<dd className="mt-1">{formatDate(exam.startsAt)}</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-ink-muted">截止时间</dt>
|
||
<dd className="mt-1">{formatDate(exam.expiresAt)}</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-ink-muted">时长</dt>
|
||
<dd className="mt-1">{formatDuration(exam.durationSeconds)}</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-ink-muted">题量</dt>
|
||
<dd className="mt-1">{exam.questionCount} 题</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-ink-muted">满分</dt>
|
||
<dd className="mt-1">{exam.totalScore} 分</dd>
|
||
</div>
|
||
</dl>
|
||
</div>
|
||
|
||
{linkHref && <p className="mt-3 text-xs text-accent">查看结果 →</p>}
|
||
</article>
|
||
);
|
||
|
||
if (!linkHref) return inner;
|
||
return (
|
||
<Link
|
||
href={linkHref}
|
||
className="block h-full focus-visible:outline-2"
|
||
aria-label={`${exam.name} - 查看结果`}
|
||
>
|
||
{inner}
|
||
</Link>
|
||
);
|
||
}
|