Files
Edu/apps/parent-portal/src/app/parent/exams/page.tsx
SpecialX 7c8e0f5dea feat(parent-portal): 完成 P4-P6 全部任务 + ARB-022 §24.4 双 /v1 修正 + 413 测试通过
主要变更:
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 任务已完成,无剩余工作项。
2026-07-13 13:10:07 +08:00

177 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 考试列表页面(家长查看子女考试)
// 依据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>
);
}