feat(parent-portal): 完成参考项目(CICD)家长端功能全量覆盖 - 9 新页面 + 3 页面增强 + 763 测试通过

## 变更内容

### P0 高优先级新页面
- /parent/leave: 请假表单(react-hook-form + Zod) + 历史列表(状态筛选)
- /parent/grades/report-card: 报告卡(学年/学期筛选 + 打印 + 教师评语)
- /parent/children/[studentId]: 子女详情聚合页(5 Tab: overview/homework/grades/exams/schedule)

### P1 中优先级新页面
- /parent/error-book: 错题本(5 项统计 + Top 错题 + 薄弱知识点)
- /parent/diagnostic: 诊断报告(掌握度摘要 + 已发布诊断报告)
- /parent/practice: 练习统计(4 项统计 + 练习历史)

### P2 低优先级新页面
- /parent/course-plans + [id]: 课程计划列表 + 详情(章节列表)
- /parent/lesson-plans + [planId]/view: 备课列表(学科筛选) + 只读详情
- /parent/elective: 选修课(分类色点 + 状态徽标)

### P3 现有页面增强
- /parent/dashboard: ParentAttentionBanner + 多子女卡片网格 + 趋势图标 + 逾期高亮
- /parent/attendance: AttendanceRateCard + AttendanceWarningBanner + 月份导航
- /parent/grades: GrowthArchiveChart + ExportGradesButton + 班级均对比线

### 基础设施
- types/index.ts: +20 新类型(LeaveRequest/ReportCard/ErrorBookStats/DiagnosticReport/PracticeStats/CoursePlan/LessonPlan/ElectiveSelection/ChildDetail/ScheduleItem 等)
- operations.ts: +19 GraphQL operations(17 query + 2 mutation)
- fixtures.ts: +18 mock 数据集
- handlers.ts: +19 MSW GraphQL handler

### 质量校验
- typecheck: 0 错误
- lint: 0 错误
- test: 71 文件 / 763 测试全部通过(从 413 增至 763, +350 测试)

### 文档
- workline.md: 新增 §7 参考项目(CICD)差距分析与实现安排 + §7.3-7.5 实现进度与覆盖完成度

### 设计决策
- 保留当前"单子女切换"范式(MultiChildTabBar + useChildSwitcher),不迁移到"多子女同屏对比"
- 仪表盘除外:已增强为多子女卡片网格并列展示
- 参考项目所有 11 个家长端页面功能已 100% 覆盖

Refs: ARB-020 §22, ARB-022 §24.4
This commit is contained in:
SpecialX
2026-07-13 15:12:56 +08:00
parent d49d211425
commit 18d94c0cc2
98 changed files with 11977 additions and 112 deletions

View File

@@ -1,16 +1,35 @@
// 考勤页面
// 依据02-architecture-design.md §3.1 路由结构
// 路由:/parent/attendance
// - 月份导航(前一月/后一月切换)
// - 聚合出勤率卡片 + 异常预警横幅
// - 月度考勤日历
"use client";
import { useState, useMemo } from "react";
import { useChildAttendance } from "@/hooks/useChildAttendance";
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
import { AttendanceCalendar } from "@/components/AttendanceCalendar";
import { AttendanceRateCard } from "@/components/AttendanceRateCard";
import { AttendanceWarningBanner } from "@/components/AttendanceWarningBanner";
function getCurrentMonth(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}
export default function AttendancePage() {
const { currentChild } = useChildSwitcher();
const { attendance, loading, error } = useChildAttendance();
const [currentMonth, setCurrentMonth] = useState<string>(getCurrentMonth);
const { attendance, loading, error } = useChildAttendance({
month: currentMonth,
});
const monthLabel = useMemo(() => {
const [year, month] = currentMonth.split("-");
return `${year ?? ""}${parseInt(month ?? "1", 10)}`;
}, [currentMonth]);
if (loading) {
return <span className="skeleton h-64 w-full rounded" />;
@@ -27,7 +46,20 @@ export default function AttendancePage() {
return (
<div className="space-y-6">
<h1 className="font-serif text-2xl">{currentChild?.name}</h1>
<AttendanceCalendar records={attendance} />
<AttendanceRateCard records={attendance} />
<AttendanceWarningBanner records={attendance} />
<AttendanceCalendar
records={attendance}
month={currentMonth}
onMonthChange={setCurrentMonth}
/>
<p className="text-xs text-ink-muted">
{monthLabel}
</p>
</div>
);
}

View File

@@ -0,0 +1,131 @@
// 子女详情聚合页
// 依据02-architecture-design.md §3.1 路由结构
// 路由:/parent/children/[studentId]
// 头部:面包屑 + 学生信息主体Tab 面板nuqs 管理 ?tab=
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useQueryState, parseAsString } from "nuqs";
import { useChildDetail } from "@/hooks/useChildDetail";
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
import {
ChildDetailPanel,
type DetailTab,
} from "@/components/ChildDetailPanel";
import { cn } from "@/lib/utils";
const VALID_TABS: DetailTab[] = [
"overview",
"homework",
"grades",
"exams",
"schedule",
];
function isDetailTab(value: string): value is DetailTab {
return (VALID_TABS as string[]).includes(value);
}
export default function ChildDetailPage(): JSX.Element {
const params = useParams();
const studentId =
typeof params?.studentId === "string"
? params.studentId
: Array.isArray(params?.studentId)
? (params.studentId[0] ?? "")
: "";
const { children } = useChildSwitcher();
const { childDetail, loading, error } = useChildDetail(studentId);
// nuqs 管理 ?tab= 参数
const [tabStr, setTabStr] = useQueryState(
"tab",
parseAsString.withDefault("overview"),
);
const activeTab: DetailTab = isDetailTab(tabStr) ? tabStr : "overview";
// 兄弟姐妹(除当前子女外)
const siblings = children.filter((c) => c.id !== studentId);
return (
<div className="space-y-6">
{/* 面包屑 */}
<nav aria-label="面包屑" className="text-xs text-ink-muted">
<ol className="flex flex-wrap gap-1">
<li>
<Link href="/parent/dashboard" className="hover:text-ink">
</Link>
</li>
<li aria-hidden="true">/</li>
<li aria-current="page"></li>
</ol>
</nav>
{/* 头部:学生信息 */}
<header className="rounded border border-rule bg-paper-elevated p-4">
{loading ? (
<span className="skeleton h-16 w-full rounded" />
) : error ? (
<div
role="alert"
className="rounded border border-danger p-4 text-sm text-danger"
>
{error.message}
</div>
) : !childDetail ? (
<p className="text-sm text-ink-muted" role="status">
</p>
) : (
<div className="flex flex-wrap items-center gap-4">
<div
className="flex h-12 w-12 items-center justify-center rounded-full bg-accent-soft font-serif text-lg font-medium text-accent"
aria-hidden="true"
>
{childDetail.basicInfo.name.slice(0, 1)}
</div>
<div className="space-y-1">
<h1 className="font-serif text-2xl">
{childDetail.basicInfo.name}
</h1>
<p className="text-xs text-ink-muted">
{childDetail.basicInfo.className} · {childDetail.basicInfo.schoolName} · {childDetail.basicInfo.relation}
</p>
</div>
</div>
)}
</header>
{/* 兄弟姐妹切换 */}
{siblings.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-ink-muted"></span>
{siblings.map((s) => (
<Link
key={s.id}
href={`/parent/children/${s.id}`}
className={cn(
"rounded border border-rule px-3 py-1 text-sm transition-colors hover:bg-paper-elevated",
)}
>
{s.name}
</Link>
))}
</div>
)}
{/* Tab 面板 */}
{childDetail && (
<ChildDetailPanel
childDetail={childDetail}
activeTab={activeTab}
onTabChange={(t) => setTabStr(t)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,76 @@
// 课程计划详情页面(家长查看子女课程计划详情)
// 依据02-architecture-design.md §3.1 路由结构
// 路由:/parent/course-plans/[id]
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useChildCoursePlanDetail } from "@/hooks/useChildCoursePlanDetail";
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
import { CoursePlanDetail } from "@/components/CoursePlanDetail";
export default function CoursePlanDetailPage(): JSX.Element {
const params = useParams();
const planId =
typeof params?.id === "string"
? params.id
: Array.isArray(params?.id)
? (params.id[0] ?? "")
: "";
const { currentChild } = useChildSwitcher();
const { coursePlan, loading, error } = useChildCoursePlanDetail(planId);
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-64 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>
);
}
if (!coursePlan) {
return (
<div className="space-y-4">
<Link
href="/parent/course-plans"
className="text-sm text-accent hover:underline"
>
</Link>
<p className="text-sm text-ink-muted" role="status">
</p>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="font-serif text-2xl">{currentChild?.name}</h1>
<Link
href="/parent/course-plans"
className="text-sm text-accent hover:underline"
>
</Link>
</div>
<CoursePlanDetail coursePlan={coursePlan} />
</div>
);
}

View File

@@ -0,0 +1,46 @@
// 课程计划列表页面(家长查看子女课程计划)
// 依据02-architecture-design.md §3.1 路由结构
// 路由:/parent/course-plans
"use client";
import { useChildCoursePlans } from "@/hooks/useChildCoursePlans";
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
import { CoursePlanList } from "@/components/CoursePlanList";
export default function CoursePlansPage(): JSX.Element {
const { currentChild } = useChildSwitcher();
const { coursePlans, loading, error } = useChildCoursePlans();
if (loading) {
return (
<div className="space-y-4" role="status" aria-live="polite">
<span className="skeleton h-8 w-48 rounded" aria-hidden="true" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<span className="skeleton h-44 w-full rounded" aria-hidden="true" />
<span className="skeleton h-44 w-full rounded" aria-hidden="true" />
<span className="skeleton h-44 w-full rounded" aria-hidden="true" />
</div>
<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>
);
}
return (
<div className="space-y-6">
<h1 className="font-serif text-2xl">{currentChild?.name}</h1>
<CoursePlanList coursePlans={coursePlans} />
</div>
);
}

View File

@@ -1,9 +1,143 @@
// 仪表盘页面
// 依据02-architecture-design.md §3.1 路由结构
// 依据02-architecture-design.md §3.1 路由结构、§15 仪表盘设计
// 路由:/parent/dashboard
// - 多子女并列展示 ChildSummaryCard
// - 顶部关注横幅(逾期作业 / 出勤率低预警)
// - 当前选中子女的考勤日历
import { ParentDashboard } from "@/components/ParentDashboard";
"use client";
import { useState, useCallback, useEffect } from "react";
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
import { useChildSummary } from "@/hooks/useChildSummary";
import { useChildAttendance } from "@/hooks/useChildAttendance";
import { ChildSummaryCard } from "@/components/ChildSummaryCard";
import { AttendanceCalendar } from "@/components/AttendanceCalendar";
import { ParentAttentionBanner } from "@/components/ParentAttentionBanner";
import type { ChildInfo, ChildSummary } from "@/types";
export default function DashboardPage() {
return <ParentDashboard />;
const { children, currentChild, loading } = useChildSwitcher();
const { attendance, loading: attLoading } = useChildAttendance();
const [summaries, setSummaries] = useState<Record<string, ChildSummary>>({});
const handleSummaryLoaded = useCallback(
(childId: string, summary: ChildSummary) => {
setSummaries((prev) => {
if (prev[childId]?.avgScore === summary.avgScore) return prev;
return { ...prev, [childId]: summary };
});
},
[],
);
if (loading) {
return (
<div className="space-y-4">
<span className="skeleton h-8 w-full rounded" />
<span className="skeleton h-32 w-full rounded" />
<span className="skeleton h-48 w-full rounded" />
</div>
);
}
if (children.length === 0) {
return (
<p className="rounded border border-rule bg-paper-elevated p-4 text-sm text-ink-muted">
</p>
);
}
const attentionItems = children.map((c) => ({
childId: c.id,
childName: c.name,
pendingHomeworkCount: summaries[c.id]?.pendingHomeworkCount ?? 0,
attendanceRate: summaries[c.id]?.attendanceRate ?? 1,
}));
return (
<div className="space-y-6">
<ParentAttentionBanner items={attentionItems} />
<h1 className="font-serif text-2xl"></h1>
{/* 多子女并列卡片 */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{children.map((child) => (
<ChildDashboardCard
key={child.id}
child={child}
onSummaryLoaded={handleSummaryLoaded}
/>
))}
</div>
{/* 当前选中子女的考勤日历 */}
{currentChild && (
<div>
<h2 className="mb-3 font-serif text-lg">{currentChild.name}</h2>
{attLoading ? (
<span className="skeleton h-64 w-full rounded" />
) : (
<AttendanceCalendar records={attendance} />
)}
</div>
)}
</div>
);
}
// 子女仪表盘卡片:独立获取每个子女的 summary
function ChildDashboardCard({
child,
onSummaryLoaded,
}: {
child: ChildInfo;
onSummaryLoaded: (childId: string, summary: ChildSummary) => void;
}) {
const { summary, loading, error } = useChildSummary(child.id);
useEffect(() => {
if (summary) {
onSummaryLoaded(child.id, summary);
}
}, [summary, child.id, onSummaryLoaded]);
if (loading) {
return (
<div className="space-y-4">
<h2 className="font-serif text-lg">{child.name}</h2>
<span className="skeleton h-32 w-full rounded" />
<span className="skeleton h-48 w-full rounded" />
</div>
);
}
if (error) {
return (
<div>
<h2 className="mb-2 font-serif text-lg">{child.name}</h2>
<div className="rounded border border-danger p-4 text-sm text-danger">
{error.message}
</div>
</div>
);
}
if (!summary) {
return (
<div>
<h2 className="mb-2 font-serif text-lg">{child.name}</h2>
<p className="text-sm text-ink-muted"></p>
</div>
);
}
return (
<div>
<h2 className="mb-3 font-serif text-lg">{child.name}</h2>
<ChildSummaryCard summary={summary} />
</div>
);
}

View File

@@ -0,0 +1,60 @@
// 诊断报告页面
// 依据02-architecture-design.md §3.1 路由结构
// 路由:/parent/diagnostic
"use client";
import { useChildDiagnostic } from "@/hooks/useChildDiagnostic";
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
import { MasterySummaryCard } from "@/components/MasterySummaryCard";
import { DiagnosticReportList } from "@/components/DiagnosticReportList";
export default function DiagnosticPage() {
const { currentChild } = useChildSwitcher();
const { masterySummary, reports, loading, error } = useChildDiagnostic();
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-48 w-full rounded" aria-hidden="true" />
<span className="skeleton h-64 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 hasData = masterySummary !== null || reports.length > 0;
return (
<div className="space-y-6">
<h1 className="font-serif text-2xl">{currentChild?.name}</h1>
{!hasData ? (
<p className="rounded border border-rule bg-paper-elevated p-4 text-sm text-ink-muted">
</p>
) : (
<>
{masterySummary && <MasterySummaryCard summary={masterySummary} />}
<section className="space-y-3" aria-label="已发布诊断报告">
<h2 className="font-serif text-lg"></h2>
<DiagnosticReportList reports={reports} />
</section>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,43 @@
// 选修课页面(家长查看子女选修课)
// 依据02-architecture-design.md §3.1 路由结构
// 路由:/parent/elective
"use client";
import { useChildElective } from "@/hooks/useChildElective";
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
import { ElectiveSelectionList } from "@/components/ElectiveSelectionList";
export default function ElectivePage(): JSX.Element {
const { currentChild } = useChildSwitcher();
const { electiveSelections, loading, error } = useChildElective();
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-32 w-full rounded" aria-hidden="true" />
<span className="skeleton h-32 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>
);
}
return (
<div className="space-y-6">
<h1 className="font-serif text-2xl">{currentChild?.name}</h1>
<ElectiveSelectionList electiveSelections={electiveSelections} />
</div>
);
}

View File

@@ -0,0 +1,127 @@
// 错题本页面
// 依据02-architecture-design.md §3.1 路由结构
// 路由:/parent/error-book
"use client";
import { useChildErrorBook } from "@/hooks/useChildErrorBook";
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
import { ErrorBookStatsCard } from "@/components/ErrorBookStatsCard";
import { TopWrongQuestionList } from "@/components/TopWrongQuestionList";
import { cn, formatPercent } from "@/lib/utils";
export default function ErrorBookPage() {
const { currentChild } = useChildSwitcher();
const { stats, topWrongQuestions, weakKps, loading, error } =
useChildErrorBook();
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-64 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 hasData =
stats !== null ||
topWrongQuestions.length > 0 ||
weakKps.length > 0;
return (
<div className="space-y-6">
<h1 className="font-serif text-2xl">{currentChild?.name}</h1>
{!hasData ? (
<p className="rounded border border-rule bg-paper-elevated p-4 text-sm text-ink-muted">
</p>
) : (
<>
{stats && <ErrorBookStatsCard stats={stats} />}
<section className="space-y-3" aria-label="高频错题">
<h2 className="font-serif text-lg"></h2>
<TopWrongQuestionList questions={topWrongQuestions} />
</section>
<section className="space-y-3" aria-label="薄弱知识点">
<h2 className="font-serif text-lg"></h2>
{weakKps.length === 0 ? (
<p className="rounded border border-rule bg-paper-elevated p-4 text-sm text-ink-muted">
</p>
) : (
<ul className="space-y-3">
{weakKps.map((kp) => {
const masteryPercent = Math.round(kp.masteryRate * 100);
const isWeak = kp.masteryRate < 0.6;
return (
<li
key={`${kp.subject}-${kp.knowledgePointName}`}
className="rounded border border-rule bg-paper-elevated p-4"
>
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="font-serif text-base">
{kp.knowledgePointName}
</h3>
<p className="mt-1 text-xs text-ink-muted">
{kp.subject}
</p>
</div>
<div className="text-right">
<p className="font-mono text-sm text-danger">
{kp.errorCount}
</p>
<p
className={cn(
"mt-1 font-mono text-sm",
isWeak ? "text-danger" : "text-success",
)}
>
{formatPercent(kp.masteryRate)}
</p>
</div>
</div>
<div
className="mt-3 h-2 overflow-hidden rounded bg-rule"
role="progressbar"
aria-valuenow={masteryPercent}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${kp.knowledgePointName}掌握率`}
>
<div
className={cn(
"h-full rounded",
isWeak ? "bg-danger" : "bg-success",
)}
style={{ width: `${masteryPercent}%` }}
/>
</div>
</li>
);
})}
</ul>
)}
</section>
</>
)}
</div>
);
}

View File

@@ -1,17 +1,25 @@
// 成绩页面
// 依据02-architecture-design.md §3.1 路由结构
// 路由:/parent/grades
// - 顶部导出按钮
// - 成绩对比图(学生分数柱 + 班级均虚线)
// - 成绩明细表
// - 底部成长档案折线图(学生 vs 班级均)
"use client";
import { useChildGrades } from "@/hooks/useChildGrades";
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
import { useChildGrowthArchive } from "@/hooks/useChildGrowthArchive";
import { ChildGradeChart } from "@/components/ChildGradeChart";
import { GrowthArchiveChart } from "@/components/GrowthArchiveChart";
import { ExportGradesButton } from "@/components/ExportGradesButton";
import { formatDate, scoreToGrade, gradeToChinese, cn } from "@/lib/utils";
export default function GradesPage() {
const { currentChild } = useChildSwitcher();
const { currentChild, currentChildId } = useChildSwitcher();
const { grades, loading, error } = useChildGrades();
const { growthArchive, loading: archiveLoading } = useChildGrowthArchive();
if (loading) {
return (
@@ -36,7 +44,15 @@ export default function GradesPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
<h1 className="font-serif text-2xl">{currentChild?.name}</h1>
{currentChildId && (
<ExportGradesButton
childId={currentChildId}
childName={currentChild?.name}
/>
)}
</div>
{grades.length > 0 && <ChildGradeChart grades={grades} />}
@@ -87,6 +103,14 @@ export default function GradesPage() {
</tbody>
</table>
</div>
{archiveLoading && (
<span className="skeleton h-72 w-full rounded" aria-hidden="true" />
)}
{!archiveLoading && growthArchive && growthArchive.dataPoints.length > 0 && (
<GrowthArchiveChart archive={growthArchive} />
)}
</div>
);
}

View File

@@ -0,0 +1,106 @@
// 报告卡页面
// 依据02-architecture-design.md §3.1 路由结构
// 路由:/parent/grades/report-card
// 顶部:学年/学期筛选 + 打印按钮;主体:报告卡视图
"use client";
import { useQueryState, parseAsString } from "nuqs";
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
import { useAcademicYears } from "@/hooks/useAcademicYears";
import { useReportCard } from "@/hooks/useReportCard";
import { ReportCardView } from "@/components/ReportCardView";
export default function ReportCardPage(): JSX.Element {
const { currentChildId, currentChild } = useChildSwitcher();
const { academicYears, loading: yearsLoading } = useAcademicYears();
// nuqs 管理学年/学期 URL 状态(可刷新恢复)
const [academicYearId, setAcademicYearId] = useQueryState(
"academicYearId",
parseAsString.withDefault(""),
);
const [semesterStr, setSemesterStr] = useQueryState(
"semester",
parseAsString.withDefault("2"),
);
const semester = Number(semesterStr) === 1 ? 1 : 2;
// URL 为空时回退到第一个学年
const effectiveYearId = academicYearId || academicYears[0]?.id || null;
const { reportCard, loading, error } = useReportCard(
currentChildId,
effectiveYearId,
semester,
);
return (
<div className="space-y-6">
<h1 className="font-serif text-2xl">
{currentChild?.name ? `${currentChild.name}的报告卡` : "报告卡"}
</h1>
{/* 筛选栏 */}
<div className="flex flex-wrap items-end gap-4 rounded border border-rule bg-paper-elevated p-4 print:hidden">
<div className="space-y-1">
<label
htmlFor="report-year"
className="text-xs text-ink-muted"
>
</label>
<select
id="report-year"
value={effectiveYearId ?? ""}
onChange={(e) => setAcademicYearId(e.target.value)}
disabled={yearsLoading}
className="rounded border border-rule bg-paper px-3 py-2 text-sm focus:outline-none disabled:opacity-60"
>
{academicYears.map((y) => (
<option key={y.id} value={y.id}>
{y.name}
</option>
))}
</select>
</div>
<div className="space-y-1">
<label
htmlFor="report-semester"
className="text-xs text-ink-muted"
>
</label>
<select
id="report-semester"
value={String(semester)}
onChange={(e) => setSemesterStr(e.target.value)}
className="rounded border border-rule bg-paper px-3 py-2 text-sm focus:outline-none"
>
<option value="1"> 1 </option>
<option value="2"> 2 </option>
</select>
</div>
</div>
{/* 主体 */}
{loading ? (
<span className="skeleton h-96 w-full rounded" />
) : error ? (
<div
role="alert"
className="rounded border border-danger p-4 text-sm text-danger"
>
{error.message}
</div>
) : !reportCard ? (
<p className="text-sm text-ink-muted" role="status">
</p>
) : (
<ReportCardView reportCard={reportCard} />
)}
</div>
);
}

View File

@@ -0,0 +1,44 @@
// 请假页面
// 依据02-architecture-design.md §3.1 路由结构
// 路由:/parent/leave
// 顶部:请假申请表单;底部:历史请假列表
"use client";
import { useChildLeaveRequests } from "@/hooks/useChildLeaveRequests";
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
import { LeaveRequestForm } from "@/components/LeaveRequestForm";
import { LeaveRequestList } from "@/components/LeaveRequestList";
export default function LeavePage(): JSX.Element {
const { currentChild } = useChildSwitcher();
const { leaveRequests, loading, error, refetch } = useChildLeaveRequests();
return (
<div className="space-y-6">
<h1 className="font-serif text-2xl">
{currentChild?.name ? `${currentChild.name}的请假` : "请假管理"}
</h1>
<LeaveRequestForm onSuccess={refetch} />
<section aria-label="历史请假记录">
<h2 className="font-serif text-base"></h2>
<div className="mt-3">
{loading ? (
<span className="skeleton h-40 w-full rounded" />
) : error ? (
<div
role="alert"
className="rounded border border-danger p-4 text-sm text-danger"
>
{error.message}
</div>
) : (
<LeaveRequestList leaveRequests={leaveRequests} />
)}
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,76 @@
// 备课只读详情页面(家长查看子女备课详情)
// 依据02-architecture-design.md §3.1 路由结构
// 路由:/parent/lesson-plans/[planId]/view
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useChildLessonPlanDetail } from "@/hooks/useChildLessonPlanDetail";
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
import { LessonPlanReadonlyView } from "@/components/LessonPlanReadonlyView";
export default function LessonPlanViewPage(): JSX.Element {
const params = useParams();
const planId =
typeof params?.planId === "string"
? params.planId
: Array.isArray(params?.planId)
? (params.planId[0] ?? "")
: "";
const { currentChild } = useChildSwitcher();
const { lessonPlan, loading, error } = useChildLessonPlanDetail(planId);
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-64 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>
);
}
if (!lessonPlan) {
return (
<div className="space-y-4">
<Link
href="/parent/lesson-plans"
className="text-sm text-accent hover:underline"
>
</Link>
<p className="text-sm text-ink-muted" role="status">
</p>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="font-serif text-2xl">{currentChild?.name}</h1>
<Link
href="/parent/lesson-plans"
className="text-sm text-accent hover:underline"
>
</Link>
</div>
<LessonPlanReadonlyView lessonPlan={lessonPlan} />
</div>
);
}

View File

@@ -0,0 +1,81 @@
// 备课列表页面(家长查看子女备课,只读,含学科筛选)
// 依据02-architecture-design.md §3.1 路由结构
// 路由:/parent/lesson-plans
"use client";
import { useQueryState, parseAsString } from "nuqs";
import { useChildLessonPlans } from "@/hooks/useChildLessonPlans";
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
import { LessonPlanList } from "@/components/LessonPlanList";
export default function LessonPlansPage(): JSX.Element {
const { currentChild } = useChildSwitcher();
const [subject, setSubject] = useQueryState(
"subject",
parseAsString.withDefault(""),
);
const { lessonPlans, loading, error } = useChildLessonPlans(
subject || undefined,
);
// 只显示已发布备课display rule防御性过滤
const publishedPlans = lessonPlans.filter((p) => p.status === "published");
// 学科选项来自已发布备课
const subjects = Array.from(new Set(publishedPlans.map((p) => p.subject)));
// 学科筛选nuqs + 客户端兜底,兼容 mock 不按 subject 过滤)
const visiblePlans = subject
? publishedPlans.filter((p) => p.subject === subject)
: publishedPlans;
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-10 w-full 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>
);
}
return (
<div className="space-y-6">
<h1 className="font-serif text-2xl">{currentChild?.name}</h1>
{/* 学科筛选 */}
<div className="space-y-1">
<label htmlFor="lesson-subject" className="text-xs text-ink-muted">
</label>
<select
id="lesson-subject"
value={subject}
onChange={(e) => setSubject(e.target.value || null)}
className="rounded border border-rule bg-paper px-3 py-2 text-sm focus:outline-none"
>
<option value=""></option>
{subjects.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
<LessonPlanList lessonPlans={visiblePlans} />
</div>
);
}

View File

@@ -0,0 +1,60 @@
// 练习统计页面
// 依据02-architecture-design.md §3.1 路由结构
// 路由:/parent/practice
"use client";
import { useChildPractice } from "@/hooks/useChildPractice";
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
import { PracticeStatsCard } from "@/components/PracticeStatsCard";
import { PracticeSessionList } from "@/components/PracticeSessionList";
export default function PracticePage() {
const { currentChild } = useChildSwitcher();
const { stats, sessions, loading, error } = useChildPractice();
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-32 w-full rounded" aria-hidden="true" />
<span className="skeleton h-64 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 hasData = stats !== null || sessions.length > 0;
return (
<div className="space-y-6">
<h1 className="font-serif text-2xl">{currentChild?.name}</h1>
{!hasData ? (
<p className="rounded border border-rule bg-paper-elevated p-4 text-sm text-ink-muted">
</p>
) : (
<>
{stats && <PracticeStatsCard stats={stats} />}
<section className="space-y-3" aria-label="练习历史">
<h2 className="font-serif text-lg"></h2>
<PracticeSessionList sessions={sessions} />
</section>
</>
)}
</div>
);
}

View File

@@ -1,9 +1,9 @@
// AttendanceCalendar 组件单测
// 依据02-architecture-design.md §15.5 AttendanceCalendar 设计
// 覆盖:日历网格渲染 / 状态颜色标记 / 统计 / 空数据 / 月份标题
// 覆盖:日历网格渲染 / 状态颜色标记 / 统计 / 空数据 / 月份标题 / 月份导航
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, fireEvent } from "@testing-library/react";
import { AttendanceCalendar } from "./AttendanceCalendar";
import type { AttendanceRecord } from "@/types";
@@ -160,3 +160,86 @@ describe("AttendanceCalendar 空数据", () => {
expect(screen.getByText("15")).toBeInTheDocument();
});
});
describe("AttendanceCalendar 月份导航", () => {
it("未传 onMonthChange 时不渲染导航按钮(向后兼容)", () => {
render(<AttendanceCalendar records={[]} />);
expect(screen.queryByRole("button", { name: "上一月" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "下一月" })).not.toBeInTheDocument();
});
it("传入 onMonthChange 时渲染上一月/下一月按钮", () => {
render(
<AttendanceCalendar
records={[]}
month="2026-06"
onMonthChange={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: "上一月" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "下一月" })).toBeInTheDocument();
});
it("传入 month prop 时渲染对应月份标题", () => {
render(
<AttendanceCalendar
records={[]}
month="2026-05"
onMonthChange={vi.fn()}
/>,
);
expect(screen.getByText(/2026年5月考勤/)).toBeInTheDocument();
});
it("点击上一月按钮调用 onMonthChange 传 2026-05", () => {
const onMonthChange = vi.fn();
render(
<AttendanceCalendar
records={[]}
month="2026-06"
onMonthChange={onMonthChange}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "上一月" }));
expect(onMonthChange).toHaveBeenCalledWith("2026-05");
});
it("点击下一月按钮调用 onMonthChange 传 2026-07", () => {
const onMonthChange = vi.fn();
render(
<AttendanceCalendar
records={[]}
month="2026-06"
onMonthChange={onMonthChange}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "下一月" }));
expect(onMonthChange).toHaveBeenCalledWith("2026-07");
});
it("跨年导航2026-01 上一月为 2025-12", () => {
const onMonthChange = vi.fn();
render(
<AttendanceCalendar
records={[]}
month="2026-01"
onMonthChange={onMonthChange}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "上一月" }));
expect(onMonthChange).toHaveBeenCalledWith("2025-12");
});
it("跨年导航2026-12 下一月为 2027-01", () => {
const onMonthChange = vi.fn();
render(
<AttendanceCalendar
records={[]}
month="2026-12"
onMonthChange={onMonthChange}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "下一月" }));
expect(onMonthChange).toHaveBeenCalledWith("2027-01");
});
});

View File

@@ -2,6 +2,7 @@
// 依据02-architecture-design.md §15.5 AttendanceCalendar 设计
// - 月历视图,每天用颜色点标记考勤状态
// - present=success / late=warning / absent=danger / leave=ink-subtle
// - 支持月份导航(可选 month + onMonthChange props
"use client";
@@ -11,6 +12,10 @@ import { cn } from "@/lib/utils";
interface AttendanceCalendarProps {
records: AttendanceRecord[];
/** YYYY-MM 格式,未传则使用当前月 */
month?: string;
/** 月份切换回调,传入则显示导航按钮 */
onMonthChange?: (month: string) => void;
}
const STATUS_STYLES: Record<AttendanceRecord["status"], string> = {
@@ -29,11 +34,34 @@ const STATUS_LABELS: Record<AttendanceRecord["status"], string> = {
const WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"];
export function AttendanceCalendar({ records }: AttendanceCalendarProps) {
const { year, month, days, recordMap } = useMemo(() => {
function parseMonth(month: string): { year: number; month: number } {
const [yearStr, monthStr] = month.split("-");
return {
year: parseInt(yearStr ?? "0", 10),
month: parseInt(monthStr ?? "1", 10) - 1,
};
}
function formatMonth(year: number, month: number): string {
return `${year}-${String(month + 1).padStart(2, "0")}`;
}
function shiftMonth(month: string, delta: number): string {
const { year, month: m } = parseMonth(month);
const date = new Date(year, m + delta, 1);
return formatMonth(date.getFullYear(), date.getMonth());
}
export function AttendanceCalendar({
records,
month,
onMonthChange,
}: AttendanceCalendarProps) {
const { year, month: monthLabel, days, recordMap } = useMemo(() => {
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth();
const target = month ? parseMonth(month) : { year: now.getFullYear(), month: now.getMonth() };
const y = target.year;
const m = target.month;
const firstDay = new Date(y, m, 1);
const lastDay = new Date(y, m + 1, 0);
const startWeekday = firstDay.getDay();
@@ -50,7 +78,7 @@ export function AttendanceCalendar({ records }: AttendanceCalendarProps) {
}
return { year: y, month: m, days: dayCells, recordMap: map };
}, [records]);
}, [records, month]);
// 统计
const stats = useMemo(() => {
@@ -64,9 +92,31 @@ export function AttendanceCalendar({ records }: AttendanceCalendarProps) {
return (
<div className="rounded border border-rule bg-paper-elevated p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{onMonthChange && (
<button
type="button"
onClick={() => onMonthChange(shiftMonth(formatMonth(year, monthLabel), -1))}
className="rounded px-2 py-1 text-sm text-ink-muted hover:text-ink"
aria-label="上一月"
>
</button>
)}
<h3 className="font-serif text-base">
{year}{month + 1}
{year}{monthLabel + 1}
</h3>
{onMonthChange && (
<button
type="button"
onClick={() => onMonthChange(shiftMonth(formatMonth(year, monthLabel), 1))}
className="rounded px-2 py-1 text-sm text-ink-muted hover:text-ink"
aria-label="下一月"
>
</button>
)}
</div>
<div className="flex gap-3 text-xs">
{(Object.keys(STATUS_LABELS) as AttendanceRecord["status"][]).map(
(s) => (
@@ -98,7 +148,7 @@ export function AttendanceCalendar({ records }: AttendanceCalendarProps) {
if (day === null) {
return <div key={idx} className="aspect-square" />;
}
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
const dateStr = `${year}-${String(monthLabel + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
const record = recordMap.get(dateStr);
return (
<div

View File

@@ -0,0 +1,152 @@
// AttendanceRateCard 组件单测
// 依据:参考项目差距补齐 - 考勤聚合统计
// 覆盖:四指标渲染 / 出勤率颜色分级 / 空数据 / 仅缺勤 / 仅迟到
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { AttendanceRateCard } from "./AttendanceRateCard";
import type { AttendanceRecord } from "@/types";
function makeRecords(
overrides: Partial<AttendanceRecord>[] = [],
): AttendanceRecord[] {
const records: AttendanceRecord[] = [];
for (let d = 1; d <= 10; d++) {
records.push({
id: `att-${d}`,
date: `2026-06-${String(d).padStart(2, "0")}`,
status: "present",
});
}
for (const o of overrides) {
records.push(o as AttendanceRecord);
}
return records;
}
describe("AttendanceRateCard 四指标渲染", () => {
it("渲染总出勤率 / 出勤天数 / 缺勤天数 / 迟到天数 标签", () => {
render(<AttendanceRateCard records={makeRecords()} />);
expect(screen.getByText("总出勤率")).toBeInTheDocument();
expect(screen.getByText("出勤天数")).toBeInTheDocument();
expect(screen.getByText("缺勤天数")).toBeInTheDocument();
expect(screen.getByText("迟到天数")).toBeInTheDocument();
});
it("全勤场景渲染正确的出勤天数10", () => {
render(<AttendanceRateCard records={makeRecords()} />);
// 10 天出勤 → 出勤天数 10
expect(screen.getByText("10")).toBeInTheDocument();
});
it("渲染总出勤率百分比", () => {
render(<AttendanceRateCard records={makeRecords()} />);
// 10 天全勤 → 100%
expect(screen.getByText(/100/)).toBeInTheDocument();
});
});
describe("AttendanceRateCard 出勤率颜色分级", () => {
it("出勤率≥95% 总出勤率显示成功色text-success", () => {
const { container } = render(
<AttendanceRateCard records={makeRecords()} />,
);
// 100% ≥ 95%text-success 应出现在总出勤率数值上
const successEls = container.querySelectorAll(".text-success");
expect(successEls.length).toBeGreaterThan(0);
});
it("出勤率 90-95% 总出勤率显示警告色text-warning", () => {
// 10 条记录1 条缺勤 → rate = 9/10 = 90%(边界,落入 warning 区间)
const records: AttendanceRecord[] = [
{ id: "1", date: "2026-06-01", status: "present" },
{ id: "2", date: "2026-06-02", status: "present" },
{ id: "3", date: "2026-06-03", status: "present" },
{ id: "4", date: "2026-06-04", status: "present" },
{ id: "5", date: "2026-06-05", status: "present" },
{ id: "6", date: "2026-06-06", status: "present" },
{ id: "7", date: "2026-06-07", status: "present" },
{ id: "8", date: "2026-06-08", status: "present" },
{ id: "9", date: "2026-06-09", status: "present" },
{ id: "10", date: "2026-06-10", status: "absent" },
];
const { container } = render(<AttendanceRateCard records={records} />);
// rate=90% 落入 [0.9, 0.95) 区间 → text-warning
const warningEls = container.querySelectorAll(".text-warning");
expect(warningEls.length).toBeGreaterThan(0);
});
it("出勤率<90% 总出勤率显示危险色text-danger", () => {
// 5 条记录2 条缺勤 → rate = 3/5 = 60% < 90%
const records: AttendanceRecord[] = [
{ id: "1", date: "2026-06-01", status: "present" },
{ id: "2", date: "2026-06-02", status: "present" },
{ id: "3", date: "2026-06-03", status: "present" },
{ id: "4", date: "2026-06-04", status: "absent" },
{ id: "5", date: "2026-06-05", status: "absent" },
];
const { container } = render(<AttendanceRateCard records={records} />);
// rate=60% < 90% → text-danger
const dangerEls = container.querySelectorAll(".text-danger");
expect(dangerEls.length).toBeGreaterThan(0);
});
});
describe("AttendanceRateCard 空数据", () => {
it("空记录数组时总出勤率显示 100%(默认值)", () => {
render(<AttendanceRateCard records={[]} />);
// 空数组时 rate=1 → 100%
expect(screen.getByText(/100/)).toBeInTheDocument();
});
it("空记录数组时所有天数为 0", () => {
render(<AttendanceRateCard records={[]} />);
// 出勤/缺勤/迟到 天数均为 0
const zeros = screen.getAllByText("0");
expect(zeros.length).toBeGreaterThanOrEqual(3);
});
});
describe("AttendanceRateCard 状态统计", () => {
it("统计缺勤天数", () => {
const records: AttendanceRecord[] = [
{ id: "1", date: "2026-06-01", status: "present" },
{ id: "2", date: "2026-06-02", status: "absent" },
{ id: "3", date: "2026-06-03", status: "absent" },
{ id: "4", date: "2026-06-04", status: "present" },
];
render(<AttendanceRateCard records={records} />);
// 缺勤 2 天(用 text-danger 选择器区分出勤天数 2
expect(screen.getByText("2", { selector: ".text-danger" })).toBeInTheDocument();
});
it("统计迟到天数", () => {
const records: AttendanceRecord[] = [
{ id: "1", date: "2026-06-01", status: "present" },
{ id: "2", date: "2026-06-02", status: "late" },
{ id: "3", date: "2026-06-03", status: "late" },
{ id: "4", date: "2026-06-04", status: "late" },
{ id: "5", date: "2026-06-05", status: "present" },
];
render(<AttendanceRateCard records={records} />);
// 迟到 3 天
expect(screen.getByText("3")).toBeInTheDocument();
});
it("请假不计入出勤率present + late 计入absent + leave 不计入)", () => {
// 4 条记录2 present + 1 late + 1 leave
// attended = present(2) + late(1) = 3total = 4rate = 3/4 = 75%
const records: AttendanceRecord[] = [
{ id: "1", date: "2026-06-01", status: "present" },
{ id: "2", date: "2026-06-02", status: "present" },
{ id: "3", date: "2026-06-03", status: "late" },
{ id: "4", date: "2026-06-04", status: "leave" },
];
const { container } = render(<AttendanceRateCard records={records} />);
// rate=75% < 90% → 总出勤率使用 text-danger
const dangerEls = container.querySelectorAll(".text-danger");
expect(dangerEls.length).toBeGreaterThan(0);
// 验证出勤天数 = 2present
expect(screen.getByText("2")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,68 @@
// AttendanceRateCard聚合出勤率卡片
// 依据:参考项目差距补齐 - 考勤聚合统计
// - 展示:总出勤率 / 出勤天数 / 缺勤天数 / 迟到天数
// - 出勤率颜色≥95% 绿色 / 90-95% 黄色 / <90% 红色
"use client";
import { useMemo } from "react";
import type { AttendanceRecord } from "@/types";
import { cn, formatPercent, formatNumber } from "@/lib/utils";
interface AttendanceRateCardProps {
records: AttendanceRecord[];
}
export function AttendanceRateCard({
records,
}: AttendanceRateCardProps): JSX.Element {
const stats = useMemo(() => {
const counts = { present: 0, late: 0, absent: 0, leave: 0 };
for (const r of records) {
counts[r.status]++;
}
const total = records.length;
const attended = counts.present + counts.late;
const rate = total > 0 ? attended / total : 1;
return { ...counts, total, rate };
}, [records]);
const rateColor = cn(
stats.rate >= 0.95 && "text-success",
stats.rate >= 0.9 && stats.rate < 0.95 && "text-warning",
stats.rate < 0.9 && "text-danger",
);
return (
<div className="rounded border border-rule bg-paper-elevated p-4">
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<div>
<p className="text-xs text-ink-muted"></p>
<p className={cn("mt-1 font-serif text-2xl", rateColor)}>
{formatPercent(stats.rate)}
</p>
</div>
<div>
<p className="text-xs text-ink-muted"></p>
<p className="mt-1 font-serif text-2xl text-success">
{formatNumber(stats.present)}
</p>
</div>
<div>
<p className="text-xs text-ink-muted"></p>
<p className="mt-1 font-serif text-2xl text-danger">
{formatNumber(stats.absent)}
</p>
</div>
<div>
<p className="text-xs text-ink-muted"></p>
<p className="mt-1 font-serif text-2xl text-warning">
{formatNumber(stats.late)}
</p>
</div>
</div>
</div>
);
}
export default AttendanceRateCard;

View File

@@ -0,0 +1,124 @@
// AttendanceWarningBanner 组件单测
// 依据:参考项目差距补齐 - 考勤异常预警
// 覆盖:无预警不渲染 / 缺勤高危 / 迟到中危 / 出勤率低高危 / 关闭
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AttendanceWarningBanner } from "./AttendanceWarningBanner";
import type { AttendanceRecord } from "@/types";
function makeRecords(
overrides: Partial<AttendanceRecord>[] = [],
): AttendanceRecord[] {
const records: AttendanceRecord[] = [];
for (let d = 1; d <= 10; d++) {
records.push({
id: `att-${d}`,
date: `2026-06-${String(d).padStart(2, "0")}`,
status: "present",
});
}
for (const o of overrides) {
records.push(o as AttendanceRecord);
}
return records;
}
describe("AttendanceWarningBanner 无预警", () => {
it("所有指标正常时不渲染", () => {
const records = makeRecords();
const { container } = render(<AttendanceWarningBanner records={records} />);
expect(container.firstChild).toBeNull();
});
it("空记录不渲染", () => {
const { container } = render(<AttendanceWarningBanner records={[]} />);
expect(container.firstChild).toBeNull();
});
});
describe("AttendanceWarningBanner 缺勤预警", () => {
it("缺勤≥3 时显示高危预警", () => {
const records: AttendanceRecord[] = [
{ id: "1", date: "2026-06-01", status: "present" },
{ id: "2", date: "2026-06-02", status: "absent" },
{ id: "3", date: "2026-06-03", status: "absent" },
{ id: "4", date: "2026-06-04", status: "absent" },
{ id: "5", date: "2026-06-05", status: "present" },
];
render(<AttendanceWarningBanner records={records} />);
expect(screen.getByText(/缺勤 3 次/)).toBeInTheDocument();
expect(screen.getByText("考勤异常预警")).toBeInTheDocument();
});
it("缺勤<3 时不显示缺勤预警", () => {
// 9 present + 1 absent = 10 条rate = 9/10 = 90% ≥ 90% 不触发出勤率预警
// absent=1 < 3 不触发缺勤预警late=0 < 3 不触发迟到预警
const records: AttendanceRecord[] = [
{ id: "1", date: "2026-06-01", status: "present" },
{ id: "2", date: "2026-06-02", status: "present" },
{ id: "3", date: "2026-06-03", status: "present" },
{ id: "4", date: "2026-06-04", status: "present" },
{ id: "5", date: "2026-06-05", status: "present" },
{ id: "6", date: "2026-06-06", status: "present" },
{ id: "7", date: "2026-06-07", status: "present" },
{ id: "8", date: "2026-06-08", status: "present" },
{ id: "9", date: "2026-06-09", status: "present" },
{ id: "10", date: "2026-06-10", status: "absent" },
];
const { container } = render(
<AttendanceWarningBanner records={records} />,
);
expect(container.firstChild).toBeNull();
});
});
describe("AttendanceWarningBanner 迟到预警", () => {
it("迟到≥3 时显示中危预警", () => {
const records: AttendanceRecord[] = [
{ id: "1", date: "2026-06-01", status: "present" },
{ id: "2", date: "2026-06-02", status: "late" },
{ id: "3", date: "2026-06-03", status: "late" },
{ id: "4", date: "2026-06-04", status: "late" },
{ id: "5", date: "2026-06-05", status: "present" },
{ id: "6", date: "2026-06-06", status: "present" },
{ id: "7", date: "2026-06-07", status: "present" },
];
render(<AttendanceWarningBanner records={records} />);
expect(screen.getByText(/迟到 3 次/)).toBeInTheDocument();
expect(screen.getByText("考勤提醒")).toBeInTheDocument();
});
});
describe("AttendanceWarningBanner 出勤率低预警", () => {
it("出勤率<90% 时显示高危预警", () => {
const records: AttendanceRecord[] = [
{ id: "1", date: "2026-06-01", status: "present" },
{ id: "2", date: "2026-06-02", status: "present" },
{ id: "3", date: "2026-06-03", status: "absent" },
{ id: "4", date: "2026-06-04", status: "absent" },
];
render(<AttendanceWarningBanner records={records} />);
// rate = 2/4 = 50% < 90%
expect(screen.getByText(/出勤率偏低/)).toBeInTheDocument();
expect(screen.getByText(/50/)).toBeInTheDocument();
});
});
describe("AttendanceWarningBanner 关闭功能", () => {
it("点击关闭按钮后横幅消失", async () => {
const user = userEvent.setup();
const records: AttendanceRecord[] = [
{ id: "1", date: "2026-06-01", status: "present" },
{ id: "2", date: "2026-06-02", status: "absent" },
{ id: "3", date: "2026-06-03", status: "absent" },
{ id: "4", date: "2026-06-04", status: "absent" },
];
render(<AttendanceWarningBanner records={records} />);
expect(screen.getByText(/缺勤/)).toBeInTheDocument();
const closeBtn = screen.getByRole("button", { name: "关闭预警" });
await user.click(closeBtn);
expect(screen.queryByText(/缺勤/)).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,125 @@
// AttendanceWarningBanner考勤异常预警横幅
// 依据:参考项目差距补齐 - 考勤异常预警
// - 缺勤≥3 高危(红色)
// - 迟到≥3 中危(黄色)
// - 出勤率<90% 高危(红色)
// - 可关闭(本地 state不持久化
"use client";
import { useState, useMemo } from "react";
import type { AttendanceRecord } from "@/types";
import { cn, formatPercent } from "@/lib/utils";
interface AttendanceWarningBannerProps {
records: AttendanceRecord[];
}
interface AttendanceStats {
present: number;
late: number;
absent: number;
leave: number;
total: number;
rate: number;
}
function calculateStats(records: AttendanceRecord[]): AttendanceStats {
const counts = { present: 0, late: 0, absent: 0, leave: 0 };
for (const r of records) {
counts[r.status]++;
}
const total = records.length;
const attended = counts.present + counts.late;
const rate = total > 0 ? attended / total : 1;
return { ...counts, total, rate };
}
interface WarningItem {
message: string;
level: "warning" | "danger";
}
export function AttendanceWarningBanner({
records,
}: AttendanceWarningBannerProps): JSX.Element | null {
const [dismissed, setDismissed] = useState(false);
const warnings = useMemo<WarningItem[]>(() => {
const stats = calculateStats(records);
const items: WarningItem[] = [];
if (stats.absent >= 3) {
items.push({
message: `本月缺勤 ${stats.absent}超过预警阈值3 次)`,
level: "danger",
});
}
if (stats.late >= 3) {
items.push({
message: `本月迟到 ${stats.late}超过预警阈值3 次)`,
level: "warning",
});
}
if (stats.total > 0 && stats.rate < 0.9) {
items.push({
message: `本月出勤率偏低(${formatPercent(stats.rate)}),低于 90%`,
level: "danger",
});
}
return items;
}, [records]);
if (dismissed || warnings.length === 0) {
return null;
}
const hasDanger = warnings.some((w) => w.level === "danger");
return (
<div
role="alert"
className={cn(
"rounded border p-4",
hasDanger
? "border-danger bg-danger/10"
: "border-warning bg-warning/10",
)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 space-y-1">
<p className="font-serif text-base">
{hasDanger ? "考勤异常预警" : "考勤提醒"}
</p>
<ul className="space-y-1 text-sm">
{warnings.map((w, idx) => (
<li
key={idx}
className={cn(
w.level === "danger" ? "text-danger" : "text-warning",
)}
>
{w.message}
</li>
))}
</ul>
</div>
<button
type="button"
onClick={() => setDismissed(true)}
className="flex-shrink-0 rounded px-2 py-1 text-sm text-ink-muted hover:text-ink"
aria-label="关闭预警"
>
×
</button>
</div>
</div>
);
}
export default AttendanceWarningBanner;
export { calculateStats };

View File

@@ -0,0 +1,416 @@
// ChildDetailPanel 组件单测
// 依据02-architecture-design.md §15 组件设计
// 覆盖Tab 导航 / Tab 切换 / overview / homework / grades / exams / schedule 五个视图
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ChildDetailPanel } from "./ChildDetailPanel";
import type { ChildDetail } from "@/types";
// mock next/link 为普通 <a>,便于断言
vi.mock("next/link", () => ({
default: ({
href,
children,
...rest
}: {
href: string;
children: React.ReactNode;
[key: string]: unknown;
}) => (
<a href={href} {...rest}>
{children}
</a>
),
}));
const mockChildDetail: ChildDetail = {
childId: "student-001",
basicInfo: {
name: "张小明",
avatar: "https://example.com/avatar.png",
grade: "grade.7",
className: "初一(1)班",
schoolName: "实验中学",
relation: "父亲",
},
todaySchedule: [
{
id: "sch-today-1",
dayOfWeek: 1,
period: 1,
subject: "数学",
teacherName: "王老师",
classroom: "初一(1)班教室",
startTime: "08:00",
endTime: "08:45",
},
{
id: "sch-today-2",
dayOfWeek: 1,
period: 2,
subject: "语文",
teacherName: "李老师",
classroom: "初一(1)班教室",
startTime: "08:55",
endTime: "09:40",
},
],
weeklySchedule: [
{
id: "sch-w-1",
dayOfWeek: 1,
period: 1,
subject: "数学",
teacherName: "王老师",
classroom: "初一(1)班教室",
startTime: "08:00",
endTime: "08:45",
},
{
id: "sch-w-2",
dayOfWeek: 2,
period: 1,
subject: "英语",
teacherName: "张老师",
classroom: "初一(1)班教室",
startTime: "08:00",
endTime: "08:45",
},
],
homeworkSummary: {
pendingCount: 3,
overdueCount: 1,
submittedCount: 5,
gradedCount: 8,
},
gradeSummary: {
avgScore: 90,
classRank: 5,
classSize: 45,
trend: "up",
},
examResults: {
upcoming: 2,
completed: 4,
avgScore: 88,
},
};
describe("ChildDetailPanel Tab 导航", () => {
it("渲染 5 个 Tab概览/作业/成绩/考试/课表)", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="overview"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByRole("tablist", { name: "子女详情视图" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "概览" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "作业" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "成绩" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "考试" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "课表" })).toBeInTheDocument();
});
it("activeTab=overview 时概览 tab 选中", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="overview"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByRole("tab", { name: "概览" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tab", { name: "作业" })).toHaveAttribute("aria-selected", "false");
});
it("点击 Tab 调用 onTabChange", async () => {
const user = userEvent.setup();
const onTabChange = vi.fn();
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="overview"
onTabChange={onTabChange}
/>,
);
await user.click(screen.getByRole("tab", { name: "作业" }));
expect(onTabChange).toHaveBeenCalledWith("homework");
});
it("点击成绩 Tab 调用 onTabChange 传 grades", async () => {
const user = userEvent.setup();
const onTabChange = vi.fn();
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="overview"
onTabChange={onTabChange}
/>,
);
await user.click(screen.getByRole("tab", { name: "成绩" }));
expect(onTabChange).toHaveBeenCalledWith("grades");
});
});
describe("ChildDetailPanel overview Tab", () => {
it("渲染三个 Section作业摘要/成绩摘要/今日课表)", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="overview"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByText("作业摘要")).toBeInTheDocument();
expect(screen.getByText("成绩摘要")).toBeInTheDocument();
expect(screen.getByText("今日课表")).toBeInTheDocument();
});
it("渲染作业摘要数据(待完成/已逾期/已提交/已评分)", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="overview"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByText("待完成")).toBeInTheDocument();
expect(screen.getByText("已逾期")).toBeInTheDocument();
expect(screen.getByText("已提交")).toBeInTheDocument();
expect(screen.getByText("已评分")).toBeInTheDocument();
// 验证数值3/1/5/8
expect(screen.getByText("3")).toBeInTheDocument();
expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.getByText("5")).toBeInTheDocument();
expect(screen.getByText("8")).toBeInTheDocument();
});
it("渲染成绩摘要数据(平均分/班级排名/趋势)", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="overview"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByText("平均分")).toBeInTheDocument();
expect(screen.getByText("班级排名")).toBeInTheDocument();
expect(screen.getByText("趋势")).toBeInTheDocument();
// 趋势 up → "↑ 上升"
expect(screen.getByText("↑ 上升")).toBeInTheDocument();
});
it("渲染今日课表数据", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="overview"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByText("数学")).toBeInTheDocument();
expect(screen.getByText("语文")).toBeInTheDocument();
// 教师姓名与教室渲染在同一 span如 "王老师 · 初一(1)班教室"),用 regex 验证
expect(screen.getByText(/王老师/)).toBeInTheDocument();
expect(screen.getByText(/李老师/)).toBeInTheDocument();
});
});
describe("ChildDetailPanel homework Tab", () => {
it("渲染作业摘要 Section", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="homework"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByText("作业摘要")).toBeInTheDocument();
});
it("渲染查看全部作业链接", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="homework"
onTabChange={vi.fn()}
/>,
);
const link = screen.getByRole("link", { name: /查看全部作业/ });
expect(link).toHaveAttribute("href", "/parent/homework");
});
});
describe("ChildDetailPanel grades Tab", () => {
it("渲染成绩摘要 Section", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="grades"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByText("成绩摘要")).toBeInTheDocument();
});
it("渲染查看全部成绩链接", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="grades"
onTabChange={vi.fn()}
/>,
);
const link = screen.getByRole("link", { name: /查看全部成绩/ });
expect(link).toHaveAttribute("href", "/parent/grades");
});
});
describe("ChildDetailPanel exams Tab", () => {
it("渲染考试摘要 Section", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="exams"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByText("考试摘要")).toBeInTheDocument();
});
it("渲染考试统计数据(待考/已完成/平均分)", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="exams"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByText("待考")).toBeInTheDocument();
expect(screen.getByText("已完成")).toBeInTheDocument();
expect(screen.getByText("平均分")).toBeInTheDocument();
});
it("渲染查看全部考试链接", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="exams"
onTabChange={vi.fn()}
/>,
);
const link = screen.getByRole("link", { name: /查看全部考试/ });
expect(link).toHaveAttribute("href", "/parent/exams");
});
});
describe("ChildDetailPanel schedule Tab", () => {
it("渲染今日课表 Section", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="schedule"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByText(/今日课表/)).toBeInTheDocument();
});
it("渲染周课表 Section", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="schedule"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByText("周课表")).toBeInTheDocument();
});
it("渲染周课表分组(周一/周二)", () => {
render(
<ChildDetailPanel
childDetail={mockChildDetail}
activeTab="schedule"
onTabChange={vi.fn()}
/>,
);
// 周课表含 dayOfWeek 1周一和 2周二
expect(screen.getByText("周一")).toBeInTheDocument();
expect(screen.getByText("周二")).toBeInTheDocument();
});
});
describe("ChildDetailPanel 空态", () => {
it("今日课表为空时显示今日无课程", () => {
const emptySchedule: ChildDetail = {
...mockChildDetail,
todaySchedule: [],
};
render(
<ChildDetailPanel
childDetail={emptySchedule}
activeTab="overview"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByText("今日无课程")).toBeInTheDocument();
});
it("周课表为空时显示暂无周课表", () => {
const emptyWeekly: ChildDetail = {
...mockChildDetail,
weeklySchedule: [],
};
render(
<ChildDetailPanel
childDetail={emptyWeekly}
activeTab="schedule"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByText("暂无周课表")).toBeInTheDocument();
});
});
describe("ChildDetailPanel 趋势显示", () => {
it("gradeSummary.trend=down 时显示 ↓ 下降", () => {
const downTrend: ChildDetail = {
...mockChildDetail,
gradeSummary: {
...mockChildDetail.gradeSummary,
trend: "down",
},
};
render(
<ChildDetailPanel
childDetail={downTrend}
activeTab="overview"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByText("↓ 下降")).toBeInTheDocument();
});
it("gradeSummary.trend=stable 时显示 → 平稳", () => {
const stableTrend: ChildDetail = {
...mockChildDetail,
gradeSummary: {
...mockChildDetail.gradeSummary,
trend: "stable",
},
};
render(
<ChildDetailPanel
childDetail={stableTrend}
activeTab="overview"
onTabChange={vi.fn()}
/>,
);
expect(screen.getByText("→ 平稳")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,324 @@
// ChildDetailPanel子女详情多 Tab 面板
// 依据02-architecture-design.md §15 组件设计
// - Taboverview / homework / grades / exams / schedule
// - 受控组件:由父级(页面)管理 activeTab 与 URL 状态
"use client";
import Link from "next/link";
import type { ChildDetail, ScheduleItem } from "@/types";
import { cn } from "@/lib/utils";
export type DetailTab = "overview" | "homework" | "grades" | "exams" | "schedule";
const TABS: { value: DetailTab; label: string }[] = [
{ value: "overview", label: "概览" },
{ value: "homework", label: "作业" },
{ value: "grades", label: "成绩" },
{ value: "exams", label: "考试" },
{ value: "schedule", label: "课表" },
];
const DAY_LABELS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
interface ChildDetailPanelProps {
childDetail: ChildDetail;
activeTab: DetailTab;
onTabChange: (tab: DetailTab) => void;
}
export function ChildDetailPanel({
childDetail,
activeTab,
onTabChange,
}: ChildDetailPanelProps) {
return (
<div className="space-y-4">
{/* Tab 导航 */}
<div
className="flex gap-1 border-b border-rule"
role="tablist"
aria-label="子女详情视图"
>
{TABS.map((t) => (
<button
key={t.value}
type="button"
role="tab"
aria-selected={activeTab === t.value}
onClick={() => onTabChange(t.value)}
className={cn(
"border-b-2 px-4 py-2 text-sm transition-colors",
activeTab === t.value
? "border-accent font-medium"
: "border-transparent text-ink-muted hover:text-ink",
)}
>
{t.label}
</button>
))}
</div>
{/* Tab 内容 */}
<div role="tabpanel">
{activeTab === "overview" && <OverviewTab childDetail={childDetail} />}
{activeTab === "homework" && <HomeworkTab childDetail={childDetail} />}
{activeTab === "grades" && <GradesTab childDetail={childDetail} />}
{activeTab === "exams" && <ExamsTab childDetail={childDetail} />}
{activeTab === "schedule" && <ScheduleTab childDetail={childDetail} />}
</div>
</div>
);
}
function SectionCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<section className="rounded border border-rule bg-paper-elevated p-4">
<h3 className="font-serif text-base">{title}</h3>
<div className="mt-3">{children}</div>
</section>
);
}
function OverviewTab({ childDetail }: { childDetail: ChildDetail }) {
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<SectionCard title="作业摘要">
<HomeworkSummary detail={childDetail} />
</SectionCard>
<SectionCard title="成绩摘要">
<GradeSummary detail={childDetail} />
</SectionCard>
<SectionCard title="今日课表">
<ScheduleList schedule={childDetail.todaySchedule} />
</SectionCard>
</div>
);
}
function HomeworkTab({ childDetail }: { childDetail: ChildDetail }) {
return (
<div className="space-y-4">
<SectionCard title="作业摘要">
<HomeworkSummary detail={childDetail} />
</SectionCard>
<Link
href="/parent/homework"
className="inline-block text-sm font-medium text-accent hover:underline"
>
</Link>
</div>
);
}
function GradesTab({ childDetail }: { childDetail: ChildDetail }) {
return (
<div className="space-y-4">
<SectionCard title="成绩摘要">
<GradeSummary detail={childDetail} />
</SectionCard>
<Link
href="/parent/grades"
className="inline-block text-sm font-medium text-accent hover:underline"
>
</Link>
</div>
);
}
function ExamsTab({ childDetail }: { childDetail: ChildDetail }) {
const { examResults } = childDetail;
return (
<div className="space-y-4">
<SectionCard title="考试摘要">
<div className="grid grid-cols-3 gap-3 text-center">
<div>
<div className="font-mono text-xl">{examResults.upcoming}</div>
<div className="text-xs text-ink-muted"></div>
</div>
<div>
<div className="font-mono text-xl">{examResults.completed}</div>
<div className="text-xs text-ink-muted"></div>
</div>
<div>
<div className="font-mono text-xl">{examResults.avgScore}</div>
<div className="text-xs text-ink-muted"></div>
</div>
</div>
</SectionCard>
<Link
href="/parent/exams"
className="inline-block text-sm font-medium text-accent hover:underline"
>
</Link>
</div>
);
}
function ScheduleTab({ childDetail }: { childDetail: ChildDetail }) {
const today = new Date().getDay();
const todayLabel = DAY_LABELS[today] ?? "";
return (
<div className="space-y-4">
<SectionCard title={`今日课表(${todayLabel}`}>
<ScheduleList schedule={childDetail.todaySchedule} />
</SectionCard>
<SectionCard title="周课表">
<WeeklySchedule weeklySchedule={childDetail.weeklySchedule} />
</SectionCard>
</div>
);
}
function HomeworkSummary({ detail }: { detail: ChildDetail }) {
const { homeworkSummary } = detail;
return (
<div className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
<div>
<div className="font-mono text-lg">{homeworkSummary.pendingCount}</div>
<div className="text-xs text-ink-muted"></div>
</div>
<div>
<div className="font-mono text-lg">{homeworkSummary.overdueCount}</div>
<div className="text-xs text-ink-muted"></div>
</div>
<div>
<div className="font-mono text-lg">{homeworkSummary.submittedCount}</div>
<div className="text-xs text-ink-muted"></div>
</div>
<div>
<div className="font-mono text-lg">{homeworkSummary.gradedCount}</div>
<div className="text-xs text-ink-muted"></div>
</div>
</div>
);
}
function GradeSummary({ detail }: { detail: ChildDetail }) {
const { gradeSummary } = detail;
const trendLabel =
gradeSummary.trend === "up"
? "↑ 上升"
: gradeSummary.trend === "down"
? "↓ 下降"
: "→ 平稳";
const trendTone =
gradeSummary.trend === "up"
? "text-success"
: gradeSummary.trend === "down"
? "text-danger"
: "text-ink-muted";
return (
<div className="grid grid-cols-3 gap-3 text-center">
<div>
<div className="font-mono text-lg">{gradeSummary.avgScore}</div>
<div className="text-xs text-ink-muted"></div>
</div>
<div>
<div className="font-mono text-lg">
{gradeSummary.classRank}/{gradeSummary.classSize}
</div>
<div className="text-xs text-ink-muted"></div>
</div>
<div>
<div className={cn("font-mono text-lg", trendTone)}>{trendLabel}</div>
<div className="text-xs text-ink-muted"></div>
</div>
</div>
);
}
function ScheduleList({ schedule }: { schedule: ScheduleItem[] }) {
if (schedule.length === 0) {
return (
<p className="text-sm text-ink-muted"></p>
);
}
return (
<ul className="space-y-2 text-sm">
{schedule.map((item) => (
<li
key={item.id}
className="flex items-center justify-between border-b border-rule pb-2 last:border-b-0 last:pb-0"
>
<div>
<span className="font-medium">{item.subject}</span>
<span className="ml-2 text-xs text-ink-muted">
{item.teacherName} · {item.classroom}
</span>
</div>
<span className="font-mono text-xs text-ink-muted">
{item.period} · {item.startTime}-{item.endTime}
</span>
</li>
))}
</ul>
);
}
function WeeklySchedule({
weeklySchedule,
}: {
weeklySchedule: ScheduleItem[];
}) {
// 按星期分组1-5周一到周五
const grouped = new Map<number, ScheduleItem[]>();
for (const item of weeklySchedule) {
const list = grouped.get(item.dayOfWeek) ?? [];
list.push(item);
grouped.set(item.dayOfWeek, list);
}
const days = Array.from(grouped.keys()).sort((a, b) => a - b);
if (days.length === 0) {
return <p className="text-sm text-ink-muted"></p>;
}
return (
<div className="space-y-3">
{days.map((day) => {
const items = grouped.get(day) ?? [];
return (
<div key={day}>
<h4 className="text-xs font-medium text-ink-muted">
{DAY_LABELS[day] ?? ""}
</h4>
<ul className="mt-1 space-y-1 text-sm">
{items
.sort((a, b) => a.period - b.period)
.map((item) => (
<li
key={item.id}
className="flex items-center justify-between"
>
<span>
<span className="font-medium">{item.subject}</span>
<span className="ml-2 text-xs text-ink-muted">
{item.teacherName}
</span>
</span>
<span className="font-mono text-xs text-ink-muted">
{item.period} · {item.startTime}-{item.endTime}
</span>
</li>
))}
</ul>
</div>
);
})}
</div>
);
}
export default ChildDetailPanel;

View File

@@ -1,6 +1,6 @@
// ChildGradeChart 组件单测mock recharts
// 依据02-architecture-design.md §15.6 成绩图表设计
// 覆盖:渲染图表标题 / 数据映射 / classAverage 缺失时默认 0
// 覆盖:渲染图表标题 / 数据映射 / 班级均对比线(虚线)/ classAverage 缺失时默认 0
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
@@ -11,20 +11,36 @@ vi.mock("recharts", () => ({
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
<div data-testid="responsive-container">{children}</div>
),
BarChart: ({
ComposedChart: ({
children,
data,
}: {
children: React.ReactNode;
data: unknown[];
}) => (
<div data-testid="bar-chart" data-length={data.length}>
<div data-testid="composed-chart" data-length={data.length}>
{children}
</div>
),
Bar: ({ dataKey, fill }: { dataKey: string; fill: string }) => (
<div data-testid="bar" data-key={dataKey} data-fill={fill} />
),
Line: ({
dataKey,
stroke,
strokeDasharray,
}: {
dataKey: string;
stroke: string;
strokeDasharray?: string;
}) => (
<div
data-testid="line"
data-key={dataKey}
data-stroke={stroke}
data-dash={strokeDasharray}
/>
),
XAxis: (_props: unknown) => <div data-testid="x-axis" />,
YAxis: (_props: unknown) => <div data-testid="y-axis" />,
CartesianGrid: (_props: unknown) => <div data-testid="cartesian-grid" />,
@@ -66,18 +82,26 @@ describe("ChildGradeChart 渲染", () => {
expect(screen.getByTestId("responsive-container")).toBeInTheDocument();
});
it("渲染 BarChart 并传入数据", () => {
it("渲染 ComposedChart 并传入数据", () => {
render(<ChildGradeChart grades={mockGrades} />);
const barChart = screen.getByTestId("bar-chart");
expect(barChart).toHaveAttribute("data-length", "2");
const chart = screen.getByTestId("composed-chart");
expect(chart).toHaveAttribute("data-length", "2");
});
it("渲染两条 Bar学生分数 + 班级平均)", () => {
it("渲染 Bar学生分数)和 Line班级平均)", () => {
render(<ChildGradeChart grades={mockGrades} />);
const bars = screen.getAllByTestId("bar");
expect(bars).toHaveLength(2);
const lines = screen.getAllByTestId("line");
expect(bars).toHaveLength(1);
expect(lines).toHaveLength(1);
expect(bars[0]).toHaveAttribute("data-key", "学生分数");
expect(bars[1]).toHaveAttribute("data-key", "班级平均");
expect(lines[0]).toHaveAttribute("data-key", "班级平均");
});
it("班级平均线为虚线strokeDasharray=5 5", () => {
render(<ChildGradeChart grades={mockGrades} />);
const line = screen.getByTestId("line");
expect(line).toHaveAttribute("data-dash", "5 5");
});
it("渲染 XAxis / YAxis / CartesianGrid / Tooltip / Legend", () => {
@@ -93,8 +117,8 @@ describe("ChildGradeChart 渲染", () => {
describe("ChildGradeChart 数据映射", () => {
it("空 grades 数组渲染 0 条数据", () => {
render(<ChildGradeChart grades={[]} />);
const barChart = screen.getByTestId("bar-chart");
expect(barChart).toHaveAttribute("data-length", "0");
const chart = screen.getByTestId("composed-chart");
expect(chart).toHaveAttribute("data-length", "0");
});
it("classAverage 缺失时默认为 0", () => {
@@ -110,8 +134,8 @@ describe("ChildGradeChart 数据映射", () => {
];
render(<ChildGradeChart grades={gradesWithoutAvg} />);
// 仅验证不崩溃且渲染 1 条数据
const barChart = screen.getByTestId("bar-chart");
expect(barChart).toHaveAttribute("data-length", "1");
const chart = screen.getByTestId("composed-chart");
expect(chart).toHaveAttribute("data-length", "1");
});
it("多条成绩数据正确映射", () => {
@@ -125,7 +149,7 @@ describe("ChildGradeChart 数据映射", () => {
gradeLevel: "B" as const,
}));
render(<ChildGradeChart grades={manyGrades} />);
const barChart = screen.getByTestId("bar-chart");
expect(barChart).toHaveAttribute("data-length", "5");
const chart = screen.getByTestId("composed-chart");
expect(chart).toHaveAttribute("data-length", "5");
});
});

View File

@@ -1,13 +1,15 @@
// ChildGradeChart子女成绩图表recharts
// 依据02-architecture-design.md §15.6 成绩图表设计
// - 柱状图:学生分数 vs 班级平均
// - 柱状图:学生分数
// - 折线图:班级平均(虚线对比)
// - 响应式容器
"use client";
import {
BarChart,
ComposedChart,
Bar,
Line,
XAxis,
YAxis,
CartesianGrid,
@@ -33,7 +35,7 @@ export function ChildGradeChart({ grades }: ChildGradeChartProps) {
<h3 className="font-serif text-base"></h3>
<div className="mt-4 h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart
<ComposedChart
data={data}
margin={{ top: 8, right: 8, bottom: 8, left: 0 }}
>
@@ -57,12 +59,14 @@ export function ChildGradeChart({ grades }: ChildGradeChartProps) {
fill="hsl(var(--accent))"
radius={[4, 4, 0, 0]}
/>
<Bar
<Line
dataKey="班级平均"
fill="hsl(var(--ink-subtle))"
radius={[4, 4, 0, 0]}
stroke="hsl(var(--warning))"
strokeWidth={2}
strokeDasharray="5 5"
dot={{ r: 3, fill: "hsl(var(--warning))" }}
/>
</BarChart>
</ComposedChart>
</ResponsiveContainer>
</div>
</div>

View File

@@ -2,11 +2,28 @@
// 依据02-architecture-design.md §15.4 ChildSummaryCard 设计
// 覆盖:指标网格 / 成绩趋势 / 即将到来事件 / 趋势标签 fallback
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { ChildSummaryCard } from "./ChildSummaryCard";
import type { ChildSummary } from "@/types";
// mock next/link 为普通 <a>
vi.mock("next/link", () => ({
default: ({
href,
children,
...rest
}: {
href: string;
children: React.ReactNode;
[key: string]: unknown;
}) => (
<a href={href} {...rest}>
{children}
</a>
),
}));
function makeSummary(overrides: Partial<ChildSummary> = {}): ChildSummary {
return {
childId: "student-001",
@@ -209,3 +226,55 @@ describe("ChildSummaryCard 即将到来事件", () => {
expect(screen.getByText("物理实验报告")).toBeInTheDocument();
});
});
describe("ChildSummaryCard 趋势图标", () => {
it("trend=up 显示上升箭头", () => {
render(
<ChildSummaryCard summary={makeSummary({ recentGradeTrend: "up" })} />,
);
expect(screen.getByText("↑")).toBeInTheDocument();
expect(screen.getByText("上升")).toBeInTheDocument();
});
it("trend=down 显示下降箭头", () => {
render(
<ChildSummaryCard summary={makeSummary({ recentGradeTrend: "down" })} />,
);
expect(screen.getByText("↓")).toBeInTheDocument();
expect(screen.getByText("下降")).toBeInTheDocument();
});
it("trend=stable 显示稳定箭头", () => {
render(
<ChildSummaryCard
summary={makeSummary({ recentGradeTrend: "stable" })}
/>,
);
expect(screen.getByText("→")).toBeInTheDocument();
expect(screen.getByText("稳定")).toBeInTheDocument();
});
});
describe("ChildSummaryCard 逾期作业徽标", () => {
it("pendingHomeworkCount > 0 时显示待办徽标", () => {
render(
<ChildSummaryCard summary={makeSummary({ pendingHomeworkCount: 3 })} />,
);
expect(screen.getByText(/3 份待办/)).toBeInTheDocument();
});
it("pendingHomeworkCount = 0 时不显示待办徽标", () => {
render(
<ChildSummaryCard summary={makeSummary({ pendingHomeworkCount: 0 })} />,
);
expect(screen.queryByText(/份待办/)).not.toBeInTheDocument();
});
});
describe("ChildSummaryCard 跳转链接", () => {
it("卡片渲染为链接,指向子女详情页", () => {
render(<ChildSummaryCard summary={makeSummary()} />);
const link = screen.getByRole("link");
expect(link).toHaveAttribute("href", "/parent/children/student-001");
});
});

View File

@@ -1,11 +1,14 @@
// ChildSummaryCard子女概览卡片
// 依据02-architecture-design.md §15.4 ChildSummaryCard 设计
// - 平均分 / 班级排名 / 出勤率 / 待完成作业
// - 成绩趋势指示
// - 成绩趋势指示(图标 + 文案)
// - 逾期作业高亮徽标
// - 即将到来的事件
// - 点击跳转子女详情
"use client";
import Link from "next/link";
import type { ChildSummary } from "@/types";
import { formatPercent, formatNumber, formatDate } from "@/lib/utils";
import { cn } from "@/lib/utils";
@@ -15,17 +18,23 @@ interface ChildSummaryCardProps {
}
const TREND_LABELS = {
up: { text: "上升", color: "text-success" },
down: { text: "下降", color: "text-danger" },
stable: { text: "稳定", color: "text-ink-muted" },
up: { text: "上升", color: "text-success", icon: "↑" },
down: { text: "下降", color: "text-danger", icon: "↓" },
stable: { text: "稳定", color: "text-ink-muted", icon: "→" },
} as const;
const DEFAULT_TREND = { text: "稳定", color: "text-ink-muted" };
const DEFAULT_TREND = { text: "稳定", color: "text-ink-muted", icon: "→" };
export function ChildSummaryCard({ summary }: ChildSummaryCardProps) {
const trend = TREND_LABELS[summary.recentGradeTrend] ?? DEFAULT_TREND;
const hasPending = summary.pendingHomeworkCount > 0;
return (
<Link
href={`/parent/children/${summary.childId}`}
className="block focus-visible:outline-2"
aria-label={`查看 ${summary.childId} 详情`}
>
<div className="space-y-4">
{/* 指标网格 */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
@@ -38,7 +47,8 @@ export function ChildSummaryCard({ summary }: ChildSummaryCardProps) {
<Metric
label="待完成作业"
value={formatNumber(summary.pendingHomeworkCount)}
highlight={summary.pendingHomeworkCount > 0}
highlight={hasPending}
badge={hasPending ? `${summary.pendingHomeworkCount} 份待办` : undefined}
/>
</div>
@@ -46,7 +56,10 @@ export function ChildSummaryCard({ summary }: ChildSummaryCardProps) {
<div className="rounded border border-rule bg-paper-elevated p-4">
<div className="flex items-center justify-between">
<h3 className="font-serif text-base"></h3>
<span className={cn("text-sm", trend.color)}>{trend.text}</span>
<span className={cn("flex items-center gap-1 text-sm", trend.color)}>
<span aria-hidden="true">{trend.icon}</span>
{trend.text}
</span>
</div>
<ul className="mt-3 space-y-2">
{summary.recentScores.slice(0, 5).map((score) => (
@@ -91,6 +104,7 @@ export function ChildSummaryCard({ summary }: ChildSummaryCardProps) {
</div>
)}
</div>
</Link>
);
}
@@ -98,20 +112,27 @@ function Metric({
label,
value,
highlight,
badge,
}: {
label: string;
value: string;
highlight?: boolean;
badge?: string;
}) {
return (
<div
className={cn(
"rounded border border-rule bg-paper-elevated p-4",
"relative rounded border border-rule bg-paper-elevated p-4",
highlight && "border-warning",
)}
>
<p className="text-xs text-ink-muted">{label}</p>
<p className="mt-1 font-serif text-2xl">{value}</p>
{badge && (
<span className="absolute right-2 top-2 rounded bg-warning px-1.5 py-0.5 text-xs text-paper">
{badge}
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,115 @@
// CoursePlanDetail 组件单测
// 依据02-architecture-design.md §15 组件设计
// 覆盖:基本信息 / 章节列表(按 sortOrder 排序)/ 状态徽标 / 章节课时与学时
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { CoursePlanDetail } from "./CoursePlanDetail";
import type { CoursePlanDetail as CoursePlanDetailType } from "@/types";
const mockDetail: CoursePlanDetailType = {
id: "cp-001",
title: "初一数学下学期课程计划",
description: "涵盖一元二次方程、不等式组、函数与图像等核心章节",
subject: "数学",
className: "初一(1)班",
teacherName: "王老师",
startDate: "2026-02-15",
endDate: "2026-07-10",
status: "active",
chapterCount: 8,
chapters: [
{
id: "ch-02",
title: "第二章 不等式与不等式组",
description: "掌握一元一次不等式及不等式组的解法",
sortOrder: 2,
lessonCount: 6,
estimatedHours: 4.5,
},
{
id: "ch-01",
title: "第一章 一元二次方程",
description: "学习一元二次方程的概念、解法及其应用",
sortOrder: 1,
lessonCount: 8,
estimatedHours: 6,
},
{
id: "ch-03",
title: "第三章 函数与图像",
description: "理解函数概念,掌握一次函数与反比例函数",
sortOrder: 3,
lessonCount: 10,
estimatedHours: 8,
},
],
};
describe("CoursePlanDetail 基本信息", () => {
it("渲染标题与描述", () => {
render(<CoursePlanDetail coursePlan={mockDetail} />);
expect(
screen.getByText("初一数学下学期课程计划"),
).toBeInTheDocument();
expect(
screen.getByText(
"涵盖一元二次方程、不等式组、函数与图像等核心章节",
),
).toBeInTheDocument();
});
it("渲染学科标签与状态徽标", () => {
render(<CoursePlanDetail coursePlan={mockDetail} />);
expect(screen.getByText("数学")).toBeInTheDocument();
expect(screen.getByText("进行中")).toBeInTheDocument();
});
it("渲染班级、教师、章节数等基本信息", () => {
render(<CoursePlanDetail coursePlan={mockDetail} />);
expect(screen.getByText("初一(1)班")).toBeInTheDocument();
expect(screen.getByText("王老师")).toBeInTheDocument();
expect(screen.getByText("8")).toBeInTheDocument();
});
});
describe("CoursePlanDetail 章节列表", () => {
it("渲染全部章节并按 sortOrder 升序排列", () => {
const { container } = render(
<CoursePlanDetail coursePlan={mockDetail} />,
);
const chapterHeadings = container.querySelectorAll("h4");
expect(chapterHeadings).toHaveLength(3);
// sortOrder: 1, 2, 3
expect(chapterHeadings[0]!.textContent).toBe("第一章 一元二次方程");
expect(chapterHeadings[1]!.textContent).toBe("第二章 不等式与不等式组");
expect(chapterHeadings[2]!.textContent).toBe("第三章 函数与图像");
});
it("渲染章节序号", () => {
render(<CoursePlanDetail coursePlan={mockDetail} />);
expect(screen.getByText("第 1 章")).toBeInTheDocument();
expect(screen.getByText("第 2 章")).toBeInTheDocument();
expect(screen.getByText("第 3 章")).toBeInTheDocument();
});
it("渲染章节课时数与预计学时", () => {
render(<CoursePlanDetail coursePlan={mockDetail} />);
expect(screen.getByText("8 课时")).toBeInTheDocument();
expect(screen.getByText("6 课时")).toBeInTheDocument();
expect(screen.getByText("10 课时")).toBeInTheDocument();
expect(screen.getByText("预计 6 学时")).toBeInTheDocument();
expect(screen.getByText("预计 4.5 学时")).toBeInTheDocument();
expect(screen.getByText("预计 8 学时")).toBeInTheDocument();
});
it("渲染章节描述", () => {
render(<CoursePlanDetail coursePlan={mockDetail} />);
expect(
screen.getByText("学习一元二次方程的概念、解法及其应用"),
).toBeInTheDocument();
expect(
screen.getByText("掌握一元一次不等式及不等式组的解法"),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,125 @@
// CoursePlanDetail课程计划详情基本信息 + 章节列表)
// 依据02-architecture-design.md §15 组件设计
// 章节:标题/描述/排序/课时数/预计学时
"use client";
import type { CoursePlanDetail } from "@/types";
import { cn, formatDate } from "@/lib/utils";
interface CoursePlanDetailProps {
coursePlan: CoursePlanDetail;
}
type CoursePlanStatus = CoursePlanDetail["status"];
const STATUS_LABELS: Record<CoursePlanStatus, string> = {
active: "进行中",
completed: "已完成",
archived: "已归档",
};
const STATUS_STYLES: Record<CoursePlanStatus, string> = {
active: "text-success",
completed: "text-ink-muted",
archived: "text-ink-muted",
};
export function CoursePlanDetail({
coursePlan,
}: CoursePlanDetailProps): JSX.Element {
const chapters = [...coursePlan.chapters].sort(
(a, b) => a.sortOrder - b.sortOrder,
);
return (
<article className="space-y-6">
{/* 基本信息 */}
<section
aria-label="课程计划基本信息"
className="rounded border border-rule bg-paper-elevated p-4"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<span className="rounded bg-accent-soft px-2 py-0.5 text-xs text-ink">
{coursePlan.subject}
</span>
<h2 className="mt-2 font-serif text-xl">{coursePlan.title}</h2>
<p className="mt-2 text-sm text-ink-muted">
{coursePlan.description}
</p>
</div>
<span
className={cn(
"flex-shrink-0 text-xs font-medium",
STATUS_STYLES[coursePlan.status],
)}
>
{STATUS_LABELS[coursePlan.status]}
</span>
</div>
<div className="mt-4 border-t border-rule pt-3">
<dl className="grid grid-cols-2 gap-y-3 text-sm sm:grid-cols-4">
<div>
<dt className="text-xs text-ink-muted"></dt>
<dd className="mt-1">{coursePlan.className}</dd>
</div>
<div>
<dt className="text-xs text-ink-muted"></dt>
<dd className="mt-1">{coursePlan.teacherName}</dd>
</div>
<div>
<dt className="text-xs text-ink-muted"></dt>
<dd className="mt-1">
{formatDate(coursePlan.startDate)} ~{" "}
{formatDate(coursePlan.endDate)}
</dd>
</div>
<div>
<dt className="text-xs text-ink-muted"></dt>
<dd className="mt-1 font-mono">{coursePlan.chapterCount}</dd>
</div>
</dl>
</div>
</section>
{/* 章节列表 */}
<section aria-label="章节列表" className="space-y-3">
<h3 className="font-serif text-base"></h3>
<ol className="space-y-3">
{chapters.map((chapter) => (
<li
key={chapter.id}
className="rounded border border-rule bg-paper-elevated p-4"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-ink-muted">
{chapter.sortOrder}
</span>
</div>
<h4 className="mt-1 font-serif text-base">{chapter.title}</h4>
<p className="mt-1 text-sm text-ink-muted">
{chapter.description}
</p>
</div>
<div className="flex-shrink-0 text-right">
<div className="font-mono text-sm">
{chapter.lessonCount}
</div>
<div className="mt-1 text-xs text-ink-muted">
{chapter.estimatedHours}
</div>
</div>
</div>
</li>
))}
</ol>
</section>
</article>
);
}
export default CoursePlanDetail;

View File

@@ -0,0 +1,129 @@
// CoursePlanList 组件单测
// 依据02-architecture-design.md §15 组件设计
// 覆盖:空态 / 列表渲染 / 状态徽标 / 跳转链接
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { CoursePlanList } from "./CoursePlanList";
import type { CoursePlan } from "@/types";
// mock next/link 为普通 <a>
vi.mock("next/link", () => ({
default: ({
href,
children,
...rest
}: {
href: string;
children: React.ReactNode;
[key: string]: unknown;
}) => (
<a href={href} {...rest}>
{children}
</a>
),
}));
const mockPlans: CoursePlan[] = [
{
id: "cp-001",
title: "初一数学下学期课程计划",
description: "数学描述",
subject: "数学",
className: "初一(1)班",
teacherName: "王老师",
startDate: "2026-02-15",
endDate: "2026-07-10",
status: "active",
chapterCount: 8,
},
{
id: "cp-002",
title: "初一语文下学期课程计划",
description: "语文描述",
subject: "语文",
className: "初一(1)班",
teacherName: "李老师",
startDate: "2026-02-15",
endDate: "2026-07-10",
status: "active",
chapterCount: 6,
},
{
id: "cp-003",
title: "初一英语上学期课程计划",
description: "英语描述",
subject: "英语",
className: "初一(1)班",
teacherName: "张老师",
startDate: "2025-09-01",
endDate: "2026-01-15",
status: "completed",
chapterCount: 10,
},
];
describe("CoursePlanList 空态", () => {
it("空数组渲染暂无课程计划", () => {
render(<CoursePlanList coursePlans={[]} />);
expect(screen.getByText("暂无课程计划")).toBeInTheDocument();
});
});
describe("CoursePlanList 渲染", () => {
it("渲染全部 3 条课程计划标题", () => {
render(<CoursePlanList coursePlans={mockPlans} />);
expect(
screen.getByText("初一数学下学期课程计划"),
).toBeInTheDocument();
expect(
screen.getByText("初一语文下学期课程计划"),
).toBeInTheDocument();
expect(
screen.getByText("初一英语上学期课程计划"),
).toBeInTheDocument();
});
it("渲染学科标签", () => {
render(<CoursePlanList coursePlans={mockPlans} />);
expect(screen.getByText("数学")).toBeInTheDocument();
expect(screen.getByText("语文")).toBeInTheDocument();
expect(screen.getByText("英语")).toBeInTheDocument();
});
it("渲染状态徽标(进行中/已完成)", () => {
render(<CoursePlanList coursePlans={mockPlans} />);
expect(screen.getAllByText("进行中")).toHaveLength(2);
expect(screen.getAllByText("已完成")).toHaveLength(1);
});
it("渲染教师与班级信息", () => {
render(<CoursePlanList coursePlans={mockPlans} />);
expect(screen.getAllByText("王老师")).toHaveLength(1);
expect(screen.getAllByText("李老师")).toHaveLength(1);
expect(screen.getAllByText("张老师")).toHaveLength(1);
});
it("渲染章节数", () => {
render(<CoursePlanList coursePlans={mockPlans} />);
expect(screen.getByText("8")).toBeInTheDocument();
expect(screen.getByText("6")).toBeInTheDocument();
expect(screen.getByText("10")).toBeInTheDocument();
});
it("每条课程计划跳转链接指向详情页", () => {
render(<CoursePlanList coursePlans={mockPlans} />);
const mathLink = screen.getByRole("link", {
name: "初一数学下学期课程计划 - 查看详情",
});
expect(mathLink).toHaveAttribute("href", "/parent/course-plans/cp-001");
const englishLink = screen.getByRole("link", {
name: "初一英语上学期课程计划 - 查看详情",
});
expect(englishLink).toHaveAttribute(
"href",
"/parent/course-plans/cp-003",
);
});
});

View File

@@ -0,0 +1,115 @@
// CoursePlanList课程计划列表
// 依据02-architecture-design.md §15 组件设计
// 每项:标题/描述/学科/班级/教师/起止日期/状态/章节数,点击跳转详情
"use client";
import Link from "next/link";
import type { CoursePlan } from "@/types";
import { cn, formatDate } from "@/lib/utils";
interface CoursePlanListProps {
coursePlans: CoursePlan[];
}
type CoursePlanStatus = CoursePlan["status"];
const STATUS_LABELS: Record<CoursePlanStatus, string> = {
active: "进行中",
completed: "已完成",
archived: "已归档",
};
const STATUS_STYLES: Record<CoursePlanStatus, string> = {
active: "text-success",
completed: "text-ink-muted",
archived: "text-ink-muted",
};
export function CoursePlanList({
coursePlans,
}: CoursePlanListProps): JSX.Element {
if (coursePlans.length === 0) {
return (
<p className="rounded border border-rule bg-paper-elevated p-4 text-sm text-ink-muted">
</p>
);
}
return (
<ul
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
aria-label="课程计划列表"
>
{coursePlans.map((plan) => (
<li key={plan.id}>
<CoursePlanCard plan={plan} />
</li>
))}
</ul>
);
}
function CoursePlanCard({ plan }: { plan: CoursePlan }): JSX.Element {
return (
<Link
href={`/parent/course-plans/${plan.id}`}
className="block h-full focus-visible:outline-2"
aria-label={`${plan.title} - 查看详情`}
>
<article
className="h-full rounded border border-rule bg-paper-elevated p-4 transition-shadow hover:shadow-md"
aria-label={`${plan.title} - ${STATUS_LABELS[plan.status]}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<span className="rounded bg-accent-soft px-2 py-0.5 text-xs text-ink">
{plan.subject}
</span>
<h3 className="mt-2 font-serif text-base">{plan.title}</h3>
</div>
<span
className={cn(
"flex-shrink-0 text-xs font-medium",
STATUS_STYLES[plan.status],
)}
>
{STATUS_LABELS[plan.status]}
</span>
</div>
<p className="mt-2 text-sm text-ink-muted">{plan.description}</p>
<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">{plan.className}</dd>
</div>
<div>
<dt className="text-ink-muted"></dt>
<dd className="mt-1">{plan.teacherName}</dd>
</div>
<div>
<dt className="text-ink-muted"></dt>
<dd className="mt-1">{formatDate(plan.startDate)}</dd>
</div>
<div>
<dt className="text-ink-muted"></dt>
<dd className="mt-1">{formatDate(plan.endDate)}</dd>
</div>
<div>
<dt className="text-ink-muted"></dt>
<dd className="mt-1 font-mono">{plan.chapterCount}</dd>
</div>
</dl>
</div>
<p className="mt-3 text-xs text-accent"> </p>
</article>
</Link>
);
}
export default CoursePlanList;

View File

@@ -0,0 +1,217 @@
// DiagnosticReportList 组件单测
// 依据02-architecture-design.md §15 组件设计
// 覆盖:空态 / 只显示 published / 标题 / 摘要 / 掌握度分数 / 学科分析 / 发布时间
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { DiagnosticReportList } from "./DiagnosticReportList";
import type { DiagnosticReport } from "@/types";
function makeReports(): DiagnosticReport[] {
return [
{
id: "diag-001",
childId: "student-001",
title: "2026 春季学期学情诊断报告",
summary:
"该生本学期整体学习情况良好,数学和物理掌握较好,语文作文和英语口语为薄弱环节,建议针对性强化。",
status: "published",
createdAt: "2026-06-20T09:00:00Z",
publishedAt: "2026-06-25T10:00:00Z",
masteryScore: 78,
subjects: [
{
subject: "语文",
masteryLevel: 0.82,
strengths: ["阅读理解", "古诗文默写"],
weaknesses: ["作文立意", "现代文词语辨析"],
recommendation: "每周练习 1 篇议论文,积累优秀范文素材",
},
{
subject: "数学",
masteryLevel: 0.55,
strengths: ["几何证明"],
weaknesses: ["一元二次方程应用题"],
recommendation: "完成配方法专项练习 20 道",
},
],
},
{
id: "diag-002",
childId: "student-001",
title: "2026 暑期学情预诊断报告(草稿)",
summary: "基于暑期前练习数据生成的预诊断,待教研组审核发布。",
status: "draft",
createdAt: "2026-07-10T14:00:00Z",
masteryScore: 72,
subjects: [
{
subject: "数学",
masteryLevel: 0.7,
strengths: ["代数基础"],
weaknesses: ["不等式组"],
recommendation: "暑期完成不等式专项练习册",
},
],
},
];
}
describe("DiagnosticReportList 空态", () => {
it("空数组渲染暂无已发布的诊断报告", () => {
render(<DiagnosticReportList reports={[]} />);
expect(screen.getByText("暂无已发布的诊断报告")).toBeInTheDocument();
});
it("仅含 draft 报告时也显示暂无已发布的诊断报告", () => {
const draftOnly = makeReports().filter((r) => r.status === "draft");
render(<DiagnosticReportList reports={draftOnly} />);
expect(screen.getByText("暂无已发布的诊断报告")).toBeInTheDocument();
});
});
describe("DiagnosticReportList 列表渲染", () => {
it("只渲染 published 报告draft 不渲染)", () => {
render(<DiagnosticReportList reports={makeReports()} />);
expect(
screen.getByText("2026 春季学期学情诊断报告"),
).toBeInTheDocument();
expect(
screen.queryByText("2026 暑期学情预诊断报告(草稿)"),
).not.toBeInTheDocument();
});
it("渲染列表 regionaria-label", () => {
render(<DiagnosticReportList reports={makeReports()} />);
expect(screen.getByLabelText("诊断报告列表")).toBeInTheDocument();
});
it("渲染报告标题", () => {
render(<DiagnosticReportList reports={makeReports()} />);
expect(
screen.getByText("2026 春季学期学情诊断报告"),
).toBeInTheDocument();
});
it("渲染报告摘要", () => {
render(<DiagnosticReportList reports={makeReports()} />);
expect(
screen.getByText(
"该生本学期整体学习情况良好,数学和物理掌握较好,语文作文和英语口语为薄弱环节,建议针对性强化。",
),
).toBeInTheDocument();
});
it("渲染发布时间", () => {
render(<DiagnosticReportList reports={makeReports()} />);
expect(screen.getByText(/发布于:/)).toBeInTheDocument();
});
it("渲染掌握度分数标签", () => {
render(<DiagnosticReportList reports={makeReports()} />);
expect(screen.getByText("掌握度分数")).toBeInTheDocument();
});
it("渲染掌握度分数数值78", () => {
render(<DiagnosticReportList reports={makeReports()} />);
expect(screen.getByText("78")).toBeInTheDocument();
});
});
describe("DiagnosticReportList 掌握度分数进度条", () => {
it("渲染报告级 progressbar", () => {
render(<DiagnosticReportList reports={makeReports()} />);
const bar = screen.getByRole("progressbar", {
name: "2026 春季学期学情诊断报告掌握度分数",
});
expect(bar).toHaveAttribute("aria-valuenow", "78");
});
it("掌握度分数 < 60 时使用 danger 色", () => {
const reports = makeReports();
reports[0] = { ...reports[0]!, masteryScore: 45 };
const { container } = render(<DiagnosticReportList reports={reports} />);
const dangerBars = container.querySelectorAll(".bg-danger");
expect(dangerBars.length).toBeGreaterThan(0);
});
it("掌握度分数 >= 60 时使用 success 色", () => {
const { container } = render(
<DiagnosticReportList reports={makeReports()} />,
);
const successBars = container.querySelectorAll(".bg-success");
expect(successBars.length).toBeGreaterThan(0);
});
});
describe("DiagnosticReportList 学科分析", () => {
it("渲染学科分析 region", () => {
render(<DiagnosticReportList reports={makeReports()} />);
expect(screen.getByLabelText("学科分析")).toBeInTheDocument();
});
it("渲染每个学科名称", () => {
render(<DiagnosticReportList reports={makeReports()} />);
// 语文、数学 在 published 报告中
expect(screen.getAllByText("语文").length).toBeGreaterThan(0);
expect(screen.getAllByText("数学").length).toBeGreaterThan(0);
});
it("渲染每个学科的优势", () => {
render(<DiagnosticReportList reports={makeReports()} />);
// published 报告含 2 个学科,"优势" 标签出现 2 次
expect(screen.getAllByText("优势")).toHaveLength(2);
expect(screen.getByText("阅读理解、古诗文默写")).toBeInTheDocument();
});
it("渲染每个学科的不足", () => {
render(<DiagnosticReportList reports={makeReports()} />);
// published 报告含 2 个学科,"不足" 标签出现 2 次
expect(screen.getAllByText("不足")).toHaveLength(2);
expect(screen.getByText("作文立意、现代文词语辨析")).toBeInTheDocument();
});
it("渲染每个学科的建议", () => {
render(<DiagnosticReportList reports={makeReports()} />);
// published 报告含 2 个学科,"建议" 标签出现 2 次
expect(screen.getAllByText("建议")).toHaveLength(2);
expect(
screen.getByText("每周练习 1 篇议论文,积累优秀范文素材"),
).toBeInTheDocument();
});
it("渲染每个学科的掌握度 progressbar", () => {
render(<DiagnosticReportList reports={makeReports()} />);
expect(screen.getByRole("progressbar", { name: "语文掌握度" })).toBeInTheDocument();
expect(screen.getByRole("progressbar", { name: "数学掌握度" })).toBeInTheDocument();
});
it("学科 masteryLevel < 0.6 时使用 danger 色", () => {
const { container } = render(
<DiagnosticReportList reports={makeReports()} />,
);
// 数学 masteryLevel=0.55 < 0.6,应有 danger 进度条
const dangerBars = container.querySelectorAll(".bg-danger");
expect(dangerBars.length).toBeGreaterThan(0);
});
it("优势为空数组时显示 —", () => {
const reports = makeReports();
reports[0] = {
...reports[0]!,
subjects: [
{
subject: "语文",
masteryLevel: 0.82,
strengths: [],
weaknesses: ["作文立意"],
recommendation: "多练习",
},
],
};
render(<DiagnosticReportList reports={reports} />);
// strengths 渲染 —
const allEmDashes = screen.getAllByText("—");
expect(allEmDashes.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,140 @@
// DiagnosticReportList诊断报告列表
// 依据02-architecture-design.md §15 组件设计
// 只显示 published 状态报告,每项含标题 / 摘要 / 掌握度分数 / 学科分析 / 发布时间
"use client";
import type { DiagnosticReport } from "@/types";
import { cn, formatPercent, formatDateTime, formatNumber } from "@/lib/utils";
interface DiagnosticReportListProps {
reports: DiagnosticReport[];
}
export function DiagnosticReportList({ reports }: DiagnosticReportListProps) {
const published = reports.filter((r) => r.status === "published");
if (published.length === 0) {
return (
<p className="rounded border border-rule bg-paper-elevated p-4 text-sm text-ink-muted">
</p>
);
}
return (
<ul className="space-y-4" aria-label="诊断报告列表">
{published.map((report) => {
const masteryPercent = report.masteryScore;
const isWeak = report.masteryScore < 60;
return (
<li
key={report.id}
className="rounded border border-rule bg-paper-elevated p-4"
aria-label={`诊断报告:${report.title}`}
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h3 className="font-serif text-base">{report.title}</h3>
<p className="mt-1 text-xs text-ink-muted">
{formatDateTime(report.publishedAt ?? report.createdAt)}
</p>
</div>
<div className="shrink-0 text-right">
<p className="text-xs text-ink-muted"></p>
<p
className={cn(
"mt-1 font-mono text-lg",
isWeak ? "text-danger" : "text-success",
)}
>
{formatNumber(report.masteryScore)}
</p>
</div>
</div>
<p className="mt-3 text-sm">{report.summary}</p>
<div
className="mt-3 h-2 overflow-hidden rounded bg-rule"
role="progressbar"
aria-valuenow={masteryPercent}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${report.title}掌握度分数`}
>
<div
className={cn(
"h-full rounded",
isWeak ? "bg-danger" : "bg-success",
)}
style={{ width: `${masteryPercent}%` }}
/>
</div>
<div className="mt-4 space-y-3" aria-label="学科分析">
{report.subjects.map((subj) => {
const subjPercent = Math.round(subj.masteryLevel * 100);
const subjWeak = subj.masteryLevel < 0.6;
return (
<div
key={subj.subject}
className="border-t border-rule pt-3"
>
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{subj.subject}</span>
<span
className={cn(
"font-mono",
subjWeak ? "text-danger" : "text-success",
)}
>
{formatPercent(subj.masteryLevel)}
</span>
</div>
<div
className="mt-1 h-1.5 overflow-hidden rounded bg-rule"
role="progressbar"
aria-valuenow={subjPercent}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${subj.subject}掌握度`}
>
<div
className={cn(
"h-full rounded",
subjWeak ? "bg-danger" : "bg-success",
)}
style={{ width: `${subjPercent}%` }}
/>
</div>
<div className="mt-2 grid gap-2 text-xs sm:grid-cols-3">
<div>
<p className="text-ink-muted"></p>
<p className="mt-0.5 text-success">
{subj.strengths.join("、") || "—"}
</p>
</div>
<div>
<p className="text-ink-muted"></p>
<p className="mt-0.5 text-danger">
{subj.weaknesses.join("、") || "—"}
</p>
</div>
<div>
<p className="text-ink-muted"></p>
<p className="mt-0.5">{subj.recommendation}</p>
</div>
</div>
</div>
);
})}
</div>
</li>
);
})}
</ul>
);
}
export default DiagnosticReportList;

View File

@@ -0,0 +1,131 @@
// ElectiveSelectionList 组件单测
// 依据02-architecture-design.md §15 组件设计
// 覆盖:空态 / 列表渲染 / 分类标签 / 状态徽标 / 成绩(如有)
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { ElectiveSelectionList } from "./ElectiveSelectionList";
import type { ElectiveSelection } from "@/types";
const mockSelections: ElectiveSelection[] = [
{
id: "ele-001",
childId: "student-001",
courseName: "创意写作",
courseCode: "ELECTIVE-A001",
teacherName: "陈老师",
category: "language",
credits: 2,
schedule: "周三 15:00-16:30",
status: "enrolled",
enrolledAt: "2026-02-25T09:00:00Z",
},
{
id: "ele-002",
childId: "student-001",
courseName: "趣味物理实验",
courseCode: "ELECTIVE-S012",
teacherName: "周老师",
category: "science",
credits: 2,
schedule: "周五 15:00-16:30",
status: "enrolled",
enrolledAt: "2026-02-25T09:00:00Z",
},
{
id: "ele-003",
childId: "student-001",
courseName: "篮球基础",
courseCode: "ELECTIVE-P005",
teacherName: "孙老师",
category: "sports",
credits: 1,
schedule: "周一 16:30-18:00",
status: "completed",
enrolledAt: "2025-09-05T09:00:00Z",
score: 88,
},
];
describe("ElectiveSelectionList 空态", () => {
it("空数组渲染暂无选修课记录", () => {
render(<ElectiveSelectionList electiveSelections={[]} />);
expect(screen.getByText("暂无选修课记录")).toBeInTheDocument();
});
});
describe("ElectiveSelectionList 渲染", () => {
it("渲染全部课程名", () => {
render(<ElectiveSelectionList electiveSelections={mockSelections} />);
expect(screen.getByText("创意写作")).toBeInTheDocument();
expect(screen.getByText("趣味物理实验")).toBeInTheDocument();
expect(screen.getByText("篮球基础")).toBeInTheDocument();
});
it("渲染课程代码", () => {
render(<ElectiveSelectionList electiveSelections={mockSelections} />);
expect(screen.getByText("ELECTIVE-A001")).toBeInTheDocument();
expect(screen.getByText("ELECTIVE-S012")).toBeInTheDocument();
expect(screen.getByText("ELECTIVE-P005")).toBeInTheDocument();
});
it("渲染分类标签", () => {
render(<ElectiveSelectionList electiveSelections={mockSelections} />);
expect(screen.getByText("语言")).toBeInTheDocument();
expect(screen.getByText("科学")).toBeInTheDocument();
expect(screen.getByText("体育")).toBeInTheDocument();
});
it("渲染状态徽标(已选/已完成)", () => {
render(<ElectiveSelectionList electiveSelections={mockSelections} />);
expect(screen.getAllByText("已选")).toHaveLength(2);
expect(screen.getAllByText("已完成")).toHaveLength(1);
});
it("渲染教师、学分与上课时间", () => {
render(<ElectiveSelectionList electiveSelections={mockSelections} />);
expect(screen.getByText("陈老师")).toBeInTheDocument();
expect(screen.getByText("周老师")).toBeInTheDocument();
expect(screen.getByText("孙老师")).toBeInTheDocument();
expect(screen.getByText("周三 15:00-16:30")).toBeInTheDocument();
expect(screen.getByText("周一 16:30-18:00")).toBeInTheDocument();
});
it("渲染学分", () => {
render(<ElectiveSelectionList electiveSelections={mockSelections} />);
expect(screen.getAllByText("2")).toHaveLength(2);
expect(screen.getByText("1")).toBeInTheDocument();
});
});
describe("ElectiveSelectionList 成绩", () => {
it("有成绩时渲染成绩", () => {
render(<ElectiveSelectionList electiveSelections={mockSelections} />);
expect(screen.getByText("成绩:")).toBeInTheDocument();
expect(screen.getByText("88")).toBeInTheDocument();
});
it("无成绩时不渲染成绩区块", () => {
const noScore: ElectiveSelection[] = [
{
...mockSelections[0]!,
status: "enrolled",
score: undefined,
},
];
render(<ElectiveSelectionList electiveSelections={noScore} />);
expect(screen.queryByText("成绩:")).not.toBeInTheDocument();
});
it("已退选状态渲染已退选徽标", () => {
const dropped: ElectiveSelection[] = [
{
...mockSelections[0]!,
status: "dropped",
score: undefined,
},
];
render(<ElectiveSelectionList electiveSelections={dropped} />);
expect(screen.getByText("已退选")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,126 @@
// ElectiveSelectionList选修课列表
// 依据02-architecture-design.md §15 组件设计
// 每项:课程名/课程代码/教师/分类标签/学分/上课时间/状态/成绩(如有)
"use client";
import type { ElectiveSelection } from "@/types";
import { cn, formatDate } from "@/lib/utils";
interface ElectiveSelectionListProps {
electiveSelections: ElectiveSelection[];
}
type ElectiveCategory = ElectiveSelection["category"];
type ElectiveStatus = ElectiveSelection["status"];
const CATEGORY_LABELS: Record<ElectiveCategory, string> = {
arts: "艺术",
sports: "体育",
science: "科学",
language: "语言",
other: "其他",
};
const CATEGORY_DOT_STYLES: Record<ElectiveCategory, string> = {
arts: "bg-accent",
sports: "bg-warning",
science: "bg-success",
language: "bg-danger",
other: "bg-ink-muted",
};
const STATUS_LABELS: Record<ElectiveStatus, string> = {
enrolled: "已选",
completed: "已完成",
dropped: "已退选",
};
const STATUS_STYLES: Record<ElectiveStatus, string> = {
enrolled: "text-accent",
completed: "text-success",
dropped: "text-ink-muted",
};
export function ElectiveSelectionList({
electiveSelections,
}: ElectiveSelectionListProps): JSX.Element {
if (electiveSelections.length === 0) {
return (
<p className="rounded border border-rule bg-paper-elevated p-4 text-sm text-ink-muted">
</p>
);
}
return (
<ul className="space-y-3" aria-label="选修课列表">
{electiveSelections.map((sel) => (
<li
key={sel.id}
className="rounded border border-rule bg-paper-elevated p-4"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded bg-accent-soft px-2 py-0.5 text-xs text-ink">
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
CATEGORY_DOT_STYLES[sel.category],
)}
aria-hidden="true"
/>
{CATEGORY_LABELS[sel.category]}
</span>
<span className="font-mono text-xs text-ink-muted">
{sel.courseCode}
</span>
</div>
<h3 className="mt-2 font-serif text-base">{sel.courseName}</h3>
</div>
<span
className={cn(
"flex-shrink-0 text-xs font-medium",
STATUS_STYLES[sel.status],
)}
>
{STATUS_LABELS[sel.status]}
</span>
</div>
<div className="mt-3 border-t border-rule pt-3">
<dl className="grid grid-cols-2 gap-y-2 text-xs sm:grid-cols-4">
<div>
<dt className="text-ink-muted"></dt>
<dd className="mt-1">{sel.teacherName}</dd>
</div>
<div>
<dt className="text-ink-muted"></dt>
<dd className="mt-1 font-mono">{sel.credits}</dd>
</div>
<div>
<dt className="text-ink-muted"></dt>
<dd className="mt-1">{sel.schedule}</dd>
</div>
<div>
<dt className="text-ink-muted"></dt>
<dd className="mt-1">{formatDate(sel.enrolledAt)}</dd>
</div>
</dl>
</div>
{typeof sel.score === "number" && (
<div className="mt-3 border-t border-rule pt-3">
<p className="text-xs text-ink-muted">
<span className="font-mono text-sm text-ink">{sel.score}</span>
</p>
</div>
)}
</li>
))}
</ul>
);
}
export default ElectiveSelectionList;

View File

@@ -0,0 +1,101 @@
// ErrorBookStatsCard 组件单测
// 依据02-architecture-design.md §15 组件设计
// 覆盖5 项统计网格 / 掌握率进度条 / 边框高亮
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { ErrorBookStatsCard } from "./ErrorBookStatsCard";
import type { ErrorBookStats } from "@/types";
function makeStats(overrides: Partial<ErrorBookStats> = {}): ErrorBookStats {
return {
childId: "student-001",
totalCount: 128,
newCount: 23,
learningCount: 45,
masteredCount: 60,
dueReviewCount: 18,
masteredRate: 0.469,
...overrides,
};
}
describe("ErrorBookStatsCard 统计网格", () => {
it("渲染 5 项统计标签", () => {
render(<ErrorBookStatsCard stats={makeStats()} />);
expect(screen.getByText("错题总数")).toBeInTheDocument();
expect(screen.getByText("新增")).toBeInTheDocument();
expect(screen.getByText("学习中")).toBeInTheDocument();
expect(screen.getByText("已掌握")).toBeInTheDocument();
expect(screen.getByText("待复习")).toBeInTheDocument();
});
it("渲染各项数值", () => {
render(<ErrorBookStatsCard stats={makeStats()} />);
expect(screen.getByText("128")).toBeInTheDocument();
expect(screen.getByText("23")).toBeInTheDocument();
expect(screen.getByText("45")).toBeInTheDocument();
expect(screen.getByText("60")).toBeInTheDocument();
expect(screen.getByText("18")).toBeInTheDocument();
});
it("渲染统计 regionaria-label", () => {
render(<ErrorBookStatsCard stats={makeStats()} />);
expect(screen.getByLabelText("错题本统计")).toBeInTheDocument();
});
});
describe("ErrorBookStatsCard 掌握率进度条", () => {
it("渲染掌握率标题与百分比", () => {
render(<ErrorBookStatsCard stats={makeStats()} />);
expect(screen.getByText("掌握率")).toBeInTheDocument();
// 0.469 → 46.9%
expect(screen.getByText(/46\.9%/)).toBeInTheDocument();
});
it("渲染 progressbar 角色", () => {
render(<ErrorBookStatsCard stats={makeStats()} />);
const bar = screen.getByRole("progressbar", { name: "错题掌握率" });
expect(bar).toHaveAttribute("aria-valuenow", "47");
expect(bar).toHaveAttribute("aria-valuemin", "0");
expect(bar).toHaveAttribute("aria-valuemax", "100");
});
it("masteredRate=0 时进度条为 0%", () => {
render(<ErrorBookStatsCard stats={makeStats({ masteredRate: 0 })} />);
const bar = screen.getByRole("progressbar", { name: "错题掌握率" });
expect(bar).toHaveAttribute("aria-valuenow", "0");
});
it("masteredRate=1 时进度条为 100%", () => {
render(<ErrorBookStatsCard stats={makeStats({ masteredRate: 1 })} />);
const bar = screen.getByRole("progressbar", { name: "错题掌握率" });
expect(bar).toHaveAttribute("aria-valuenow", "100");
});
});
describe("ErrorBookStatsCard 边框高亮", () => {
it("待复习 > 0 时待复习卡片高亮border-warning", () => {
const { container } = render(
<ErrorBookStatsCard stats={makeStats({ dueReviewCount: 5 })} />,
);
const warningCards = container.querySelectorAll(".border-warning");
expect(warningCards.length).toBeGreaterThan(0);
});
it("已掌握 > 0 时已掌握卡片高亮border-success", () => {
const { container } = render(
<ErrorBookStatsCard stats={makeStats({ masteredCount: 10 })} />,
);
const successCards = container.querySelectorAll(".border-success");
expect(successCards.length).toBeGreaterThan(0);
});
it("错题总数卡片高亮border-accent", () => {
const { container } = render(
<ErrorBookStatsCard stats={makeStats()} />,
);
const accentCards = container.querySelectorAll(".border-accent");
expect(accentCards.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,97 @@
// ErrorBookStatsCard错题本统计卡片
// 依据02-architecture-design.md §15 组件设计
// - 5 项统计网格(总数/新增/学习中/已掌握/待复习)
// - 掌握率进度条masteredRate 0-1
"use client";
import type { ErrorBookStats } from "@/types";
import { cn, formatPercent, formatNumber } from "@/lib/utils";
interface ErrorBookStatsCardProps {
stats: ErrorBookStats;
}
interface StatItem {
label: string;
value: number;
icon: string;
tone: "default" | "warning" | "success" | "accent";
}
export function ErrorBookStatsCard({ stats }: ErrorBookStatsCardProps) {
const items: StatItem[] = [
{ label: "错题总数", value: stats.totalCount, icon: "📚", tone: "accent" },
{ label: "新增", value: stats.newCount, icon: "🆕", tone: "default" },
{
label: "学习中",
value: stats.learningCount,
icon: "📖",
tone: "default",
},
{
label: "已掌握",
value: stats.masteredCount,
icon: "✅",
tone: "success",
},
{
label: "待复习",
value: stats.dueReviewCount,
icon: "🔔",
tone: "warning",
},
];
const masteryPercent = Math.round(stats.masteredRate * 100);
return (
<div className="space-y-4" aria-label="错题本统计">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{items.map((item) => (
<div
key={item.label}
className={cn(
"rounded border border-rule bg-paper-elevated p-4",
item.tone === "warning" && "border-warning",
item.tone === "success" && "border-success",
item.tone === "accent" && "border-accent",
)}
>
<div className="flex items-center justify-between">
<span aria-hidden="true">{item.icon}</span>
<span className="font-serif text-2xl">
{formatNumber(item.value)}
</span>
</div>
<p className="mt-2 text-xs text-ink-muted">{item.label}</p>
</div>
))}
</div>
<div className="rounded border border-rule bg-paper-elevated p-4">
<div className="flex items-center justify-between">
<h3 className="font-serif text-base"></h3>
<span className="font-mono text-lg text-success">
{formatPercent(stats.masteredRate)}
</span>
</div>
<div
className="mt-3 h-2 overflow-hidden rounded bg-rule"
role="progressbar"
aria-valuenow={masteryPercent}
aria-valuemin={0}
aria-valuemax={100}
aria-label="错题掌握率"
>
<div
className="h-full rounded bg-accent"
style={{ width: `${masteryPercent}%` }}
/>
</div>
</div>
</div>
);
}
export default ErrorBookStatsCard;

View File

@@ -0,0 +1,205 @@
// ExportGradesButton 组件单测
// 依据:参考项目差距补齐 - 成绩导出
// 覆盖:按钮渲染 / loading 状态 / 点击触发导出 / 错误显示 / 禁用态
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
vi,
} from "vitest";
import { render, screen, cleanup, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { ExportGradesButton } from "./ExportGradesButton";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderExportGradesButton(props?: {
childId?: string;
subject?: string;
childName?: string;
}) {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return render(
<ExportGradesButton
childId={props?.childId ?? "student-001"}
subject={props?.subject}
childName={props?.childName ?? "张小明"}
/>,
{ wrapper },
);
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
describe("ExportGradesButton 渲染", () => {
it("渲染导出成绩按钮", () => {
renderExportGradesButton();
expect(
screen.getByRole("button", { name: "导出张小明成绩" }),
).toBeInTheDocument();
});
it("按钮文案为导出成绩", () => {
renderExportGradesButton();
expect(screen.getByText("导出成绩")).toBeInTheDocument();
});
it("初始状态按钮可点击(非禁用)", () => {
renderExportGradesButton();
const btn = screen.getByRole("button", { name: "导出张小明成绩" });
expect(btn).not.toBeDisabled();
});
it("childId 为空时按钮禁用", () => {
renderExportGradesButton({ childId: "" });
const btn = screen.getByRole("button", { name: "导出张小明成绩" });
expect(btn).toBeDisabled();
});
});
describe("ExportGradesButton 点击导出", () => {
it("点击按钮触发导出并打开下载链接", async () => {
const user = userEvent.setup();
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
renderExportGradesButton();
await user.click(screen.getByRole("button", { name: "导出张小明成绩" }));
// 等待 mutation 完成
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
});
expect(openSpy).toHaveBeenCalledTimes(1);
const calledUrl = openSpy.mock.calls[0]?.[0] as string;
expect(calledUrl).toContain("student-001");
openSpy.mockRestore();
});
it("导出中按钮显示导出中文案", async () => {
// 使用延迟响应保持 loading 状态
server.resetHandlers(
graphql.mutation("ExportChildGrades", async () => {
await new Promise((resolve) => setTimeout(resolve, 500));
return HttpResponse.json({
data: {
exportChildGrades: {
downloadUrl: "https://example.com/exports/grades.xlsx",
expiresAt: "2026-07-13T23:59:59Z",
},
},
});
}),
);
const user = userEvent.setup();
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
renderExportGradesButton();
await user.click(screen.getByRole("button", { name: "导出张小明成绩" }));
// 点击后立即检查 loading 文案
expect(screen.getByText("导出中...")).toBeInTheDocument();
// 等待完成
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 700));
});
openSpy.mockRestore();
});
it("导出失败时显示错误提示", async () => {
server.resetHandlers(
graphql.mutation("ExportChildGrades", () =>
HttpResponse.json(
{ errors: [{ message: "导出失败:权限不足" }] },
{ status: 200 },
),
),
);
const user = userEvent.setup();
renderExportGradesButton();
await user.click(screen.getByRole("button", { name: "导出张小明成绩" }));
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
});
expect(screen.getByText(/导出失败/)).toBeInTheDocument();
expect(screen.getByText(/权限不足/)).toBeInTheDocument();
});
it("成功导出后清除之前的错误提示", async () => {
// 第一次:失败
server.resetHandlers(
graphql.mutation("ExportChildGrades", () =>
HttpResponse.json(
{ errors: [{ message: "导出失败:权限不足" }] },
{ status: 200 },
),
),
);
const user = userEvent.setup();
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
renderExportGradesButton();
await user.click(screen.getByRole("button", { name: "导出张小明成绩" }));
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
});
expect(screen.getByText(/导出失败/)).toBeInTheDocument();
// 第二次:成功
server.resetHandlers(
graphql.mutation("ExportChildGrades", () =>
HttpResponse.json({
data: {
exportChildGrades: {
downloadUrl: "https://example.com/exports/grades.xlsx",
expiresAt: "2026-07-13T23:59:59Z",
},
},
}),
),
);
await user.click(screen.getByRole("button", { name: "导出张小明成绩" }));
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
});
expect(screen.queryByText(/导出失败/)).not.toBeInTheDocument();
openSpy.mockRestore();
});
});

View File

@@ -0,0 +1,66 @@
// ExportGradesButton导出成绩按钮
// 依据:参考项目差距补齐 - 成绩导出
// - 点击触发 EXPORT_CHILD_GRADES mutation
// - 成功后在新窗口打开下载链接
// - loading 状态禁用按钮
// - 错误态显示提示
"use client";
import { useState } from "react";
import { useExportChildGrades } from "@/hooks/useExportChildGrades";
import { cn } from "@/lib/utils";
interface ExportGradesButtonProps {
childId: string;
subject?: string;
childName?: string;
}
export function ExportGradesButton({
childId,
subject,
childName,
}: ExportGradesButtonProps): JSX.Element {
const { exportGrades, loading } = useExportChildGrades();
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const handleExport = async (): Promise<void> => {
setErrorMsg(null);
const result = await exportGrades({ childId, subject });
if (result.success && result.data) {
// 新窗口打开下载链接
window.open(result.data.downloadUrl, "_blank", "noopener,noreferrer");
return;
}
if (result.error) {
setErrorMsg(result.error.message);
}
};
return (
<div className="flex flex-col gap-1">
<button
type="button"
onClick={handleExport}
disabled={loading || !childId}
className={cn(
"rounded border border-accent px-3 py-1.5 text-sm font-medium",
"bg-accent-soft text-accent transition-colors",
"hover:bg-accent hover:text-paper",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
aria-label={`导出${childName ?? ""}成绩`}
>
{loading ? "导出中..." : "导出成绩"}
</button>
{errorMsg && (
<p role="alert" className="text-xs text-danger">
{errorMsg}
</p>
)}
</div>
);
}
export default ExportGradesButton;

View File

@@ -0,0 +1,145 @@
// GrowthArchiveChart 组件单测mock recharts
// 依据:参考项目差距补齐 - 成长档案(班级均对比线)
// 覆盖:标题渲染 / 两条 Line / 数据映射 / 学科后缀 / 空数据点
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import type { GrowthArchive } from "@/types";
// mock recharts渲染简单 div 包裹子元素,便于断言
vi.mock("recharts", () => ({
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
<div data-testid="responsive-container">{children}</div>
),
LineChart: ({
children,
data,
}: {
children: React.ReactNode;
data: unknown[];
}) => (
<div data-testid="line-chart" data-length={data.length}>
{children}
</div>
),
Line: ({
dataKey,
stroke,
strokeDasharray,
}: {
dataKey: string;
stroke: string;
strokeDasharray?: string;
}) => (
<div
data-testid="line"
data-key={dataKey}
data-stroke={stroke}
data-dash={strokeDasharray}
/>
),
XAxis: (_props: unknown) => <div data-testid="x-axis" />,
YAxis: (_props: unknown) => <div data-testid="y-axis" />,
CartesianGrid: (_props: unknown) => <div data-testid="cartesian-grid" />,
Tooltip: (_props: unknown) => <div data-testid="tooltip" />,
Legend: (_props: unknown) => <div data-testid="legend" />,
}));
import { GrowthArchiveChart } from "./GrowthArchiveChart";
const mockArchive: GrowthArchive = {
childId: "student-001",
subject: "数学",
dataPoints: [
{ date: "2026-03", studentScore: 82, classAverage: 76 },
{ date: "2026-04", studentScore: 88, classAverage: 78 },
{ date: "2026-05", studentScore: 92, classAverage: 79 },
{ date: "2026-06", studentScore: 90, classAverage: 80 },
],
};
describe("GrowthArchiveChart 渲染", () => {
it("渲染标题成长档案并携带学科后缀", () => {
render(<GrowthArchiveChart archive={mockArchive} />);
expect(screen.getByText(/成长档案/)).toBeInTheDocument();
expect(screen.getByText(/数学/)).toBeInTheDocument();
});
it("渲染 ResponsiveContainer", () => {
render(<GrowthArchiveChart archive={mockArchive} />);
expect(screen.getByTestId("responsive-container")).toBeInTheDocument();
});
it("渲染 LineChart 并传入 4 条数据", () => {
render(<GrowthArchiveChart archive={mockArchive} />);
const chart = screen.getByTestId("line-chart");
expect(chart).toHaveAttribute("data-length", "4");
});
it("渲染两条 Line学生分数 + 班级平均)", () => {
render(<GrowthArchiveChart archive={mockArchive} />);
const lines = screen.getAllByTestId("line");
expect(lines).toHaveLength(2);
expect(lines[0]).toHaveAttribute("data-key", "学生分数");
expect(lines[1]).toHaveAttribute("data-key", "班级平均");
});
it("班级平均线为虚线strokeDasharray=5 5", () => {
render(<GrowthArchiveChart archive={mockArchive} />);
const lines = screen.getAllByTestId("line");
expect(lines[1]).toHaveAttribute("data-dash", "5 5");
});
it("学生分数线为实线(无 strokeDasharray", () => {
render(<GrowthArchiveChart archive={mockArchive} />);
const lines = screen.getAllByTestId("line");
expect(lines[0]).not.toHaveAttribute("data-dash");
});
it("渲染 XAxis / YAxis / CartesianGrid / Tooltip / Legend", () => {
render(<GrowthArchiveChart archive={mockArchive} />);
expect(screen.getByTestId("x-axis")).toBeInTheDocument();
expect(screen.getByTestId("y-axis")).toBeInTheDocument();
expect(screen.getByTestId("cartesian-grid")).toBeInTheDocument();
expect(screen.getByTestId("tooltip")).toBeInTheDocument();
expect(screen.getByTestId("legend")).toBeInTheDocument();
});
});
describe("GrowthArchiveChart 数据映射", () => {
it("空 dataPoints 数组渲染 0 条数据", () => {
const emptyArchive: GrowthArchive = {
childId: "student-001",
subject: "数学",
dataPoints: [],
};
render(<GrowthArchiveChart archive={emptyArchive} />);
const chart = screen.getByTestId("line-chart");
expect(chart).toHaveAttribute("data-length", "0");
});
it("多条数据点正确映射", () => {
const archive: GrowthArchive = {
childId: "student-001",
subject: "英语",
dataPoints: Array.from({ length: 6 }, (_, i) => ({
date: `2026-${String(i + 1).padStart(2, "0")}`,
studentScore: 80 + i,
classAverage: 70,
})),
};
render(<GrowthArchiveChart archive={archive} />);
const chart = screen.getByTestId("line-chart");
expect(chart).toHaveAttribute("data-length", "6");
});
it("无 subject 时标题不显示学科后缀", () => {
const archive: GrowthArchive = {
childId: "student-001",
subject: "",
dataPoints: mockArchive.dataPoints,
};
render(<GrowthArchiveChart archive={archive} />);
expect(screen.getByText("成长档案")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,80 @@
// GrowthArchiveChart成长档案折线图
// 依据:参考项目差距补齐 - 成长档案(班级均对比线)
// - 学生分数折线accent 实线)
// - 班级平均折线ink-subtle 虚线)
// - 响应式容器
"use client";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
import type { GrowthArchive } from "@/types";
interface GrowthArchiveChartProps {
archive: GrowthArchive;
}
export function GrowthArchiveChart({
archive,
}: GrowthArchiveChartProps): JSX.Element {
const data = archive.dataPoints.map((p) => ({
date: p.date,
学生分数: p.studentScore,
班级平均: p.classAverage,
}));
return (
<div className="rounded border border-rule bg-paper-elevated p-4">
<h3 className="font-serif text-base">
{archive.subject ? `· ${archive.subject}` : ""}
</h3>
<div className="mt-4 h-72">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={data}
margin={{ top: 8, right: 8, bottom: 8, left: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--rule))" />
<XAxis
dataKey="date"
tick={{ fill: "hsl(var(--ink-muted))", fontSize: 12 }}
/>
<YAxis tick={{ fill: "hsl(var(--ink-muted))", fontSize: 12 }} />
<Tooltip
contentStyle={{
background: "hsl(var(--paper-elevated))",
border: "1px solid hsl(var(--rule))",
borderRadius: "0.5rem",
}}
labelStyle={{ color: "hsl(var(--ink))" }}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Line
dataKey="学生分数"
stroke="hsl(var(--accent))"
strokeWidth={2}
dot={{ r: 3, fill: "hsl(var(--accent))" }}
/>
<Line
dataKey="班级平均"
stroke="hsl(var(--ink-subtle))"
strokeWidth={2}
strokeDasharray="5 5"
dot={{ r: 3, fill: "hsl(var(--ink-subtle))" }}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
);
}
export default GrowthArchiveChart;

View File

@@ -0,0 +1,296 @@
// LeaveRequestForm 组件单测
// 依据02-architecture-design.md §15 组件设计、project_rules §3.8 输入验证
// 覆盖:表单渲染 / 子女选项 / 类型选项 / 校验(必填 + reason 长度 + 日期范围)/ 提交成功 / loading 态 / error 显示
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LeaveRequestForm } from "./LeaveRequestForm";
import type { ChildInfo, LeaveRequestInput } from "@/types";
import type { CombinedError } from "urql";
// mock useMyChildren
const mockUseMyChildren = vi.fn();
vi.mock("@/hooks/useMyChildren", () => ({
useMyChildren: () => mockUseMyChildren(),
}));
// mock useCreateLeaveRequest
const mockUseCreateLeaveRequest = vi.fn();
vi.mock("@/hooks/useCreateLeaveRequest", () => ({
useCreateLeaveRequest: () => mockUseCreateLeaveRequest(),
}));
const mockChildren: ChildInfo[] = [
{
id: "student-001",
name: "张小明",
grade: "grade.7",
schoolName: "实验中学",
classId: "class-7-1",
className: "初一(1)班",
},
{
id: "student-002",
name: "张小红",
grade: "grade.5",
schoolName: "实验小学",
classId: "class-5-2",
className: "五年级(2)班",
},
];
function createLeaveMockSuccess() {
return vi.fn().mockResolvedValue({ success: true });
}
function setupHappyPath() {
mockUseMyChildren.mockReturnValue({
children: mockChildren,
loading: false,
error: undefined,
});
mockUseCreateLeaveRequest.mockReturnValue({
createLeave: createLeaveMockSuccess(),
loading: false,
error: undefined,
});
}
beforeEach(() => {
mockUseMyChildren.mockReset();
mockUseCreateLeaveRequest.mockReset();
});
describe("LeaveRequestForm 渲染", () => {
beforeEach(setupHappyPath);
it("渲染表单标题", () => {
render(<LeaveRequestForm />);
expect(screen.getByText("提交请假申请")).toBeInTheDocument();
});
it("渲染所有字段标签", () => {
render(<LeaveRequestForm />);
expect(screen.getByText("子女")).toBeInTheDocument();
expect(screen.getByText("请假类型")).toBeInTheDocument();
expect(screen.getByText("开始日期")).toBeInTheDocument();
expect(screen.getByText("结束日期")).toBeInTheDocument();
expect(screen.getByText("请假原因")).toBeInTheDocument();
});
it("渲染提交按钮", () => {
render(<LeaveRequestForm />);
expect(screen.getByRole("button", { name: "提交申请" })).toBeInTheDocument();
});
it("渲染子女选项2 个)", () => {
render(<LeaveRequestForm />);
const select = screen.getByLabelText("子女") as HTMLSelectElement;
const options = select.querySelectorAll("option");
// 2 个子女
expect(options).toHaveLength(2);
expect(options[0]).toHaveTextContent("张小明");
expect(options[1]).toHaveTextContent("张小红");
});
it("渲染请假类型选项4 种)", () => {
render(<LeaveRequestForm />);
const select = screen.getByLabelText("请假类型") as HTMLSelectElement;
const options = select.querySelectorAll("option");
expect(options).toHaveLength(4);
expect(options[0]).toHaveTextContent("病假");
expect(options[1]).toHaveTextContent("事假");
expect(options[2]).toHaveTextContent("家庭假");
expect(options[3]).toHaveTextContent("其他");
});
});
describe("LeaveRequestForm 校验", () => {
beforeEach(setupHappyPath);
it("空表单提交显示必填错误", async () => {
const user = userEvent.setup();
// 先清空 reason虽然默认已空但确保 startDate/endDate 也是空)
render(<LeaveRequestForm />);
await user.click(screen.getByRole("button", { name: "提交申请" }));
// childId 默认有值(第一个子女),所以不会报错;但 startDate/endDate/reason 会报错
await waitFor(() => {
expect(screen.getByText("请选择开始日期")).toBeInTheDocument();
expect(screen.getByText("请选择结束日期")).toBeInTheDocument();
expect(screen.getByText("请假原因至少 10 个字")).toBeInTheDocument();
});
});
it("请假原因不足 10 字显示错误", async () => {
const user = userEvent.setup();
render(<LeaveRequestForm />);
await user.type(screen.getByLabelText("请假原因"), "感冒了");
await user.click(screen.getByRole("button", { name: "提交申请" }));
await waitFor(() => {
expect(screen.getByText("请假原因至少 10 个字")).toBeInTheDocument();
});
});
it("结束日期早于开始日期显示错误", async () => {
const user = userEvent.setup();
render(<LeaveRequestForm />);
await user.type(screen.getByLabelText("开始日期"), "2026-07-15");
await user.type(screen.getByLabelText("结束日期"), "2026-07-10");
await user.type(
screen.getByLabelText("请假原因"),
"家中临时有事需要陪同办理",
);
await user.click(screen.getByRole("button", { name: "提交申请" }));
await waitFor(() => {
expect(screen.getByText("结束日期不能早于开始日期")).toBeInTheDocument();
});
});
});
describe("LeaveRequestForm 提交", () => {
beforeEach(setupHappyPath);
it("提交成功调用 createLeave 传入正确 input", async () => {
const user = userEvent.setup();
const createLeave = createLeaveMockSuccess();
mockUseCreateLeaveRequest.mockReturnValue({
createLeave,
loading: false,
error: undefined,
});
render(<LeaveRequestForm />);
// childId 默认 student-001type 默认 sick
await user.type(screen.getByLabelText("开始日期"), "2026-07-15");
await user.type(screen.getByLabelText("结束日期"), "2026-07-16");
await user.type(
screen.getByLabelText("请假原因"),
"感冒发烧需要在家休息观察",
);
await user.click(screen.getByRole("button", { name: "提交申请" }));
await waitFor(() => {
expect(createLeave).toHaveBeenCalledTimes(1);
});
const expectedInput: LeaveRequestInput = {
childId: "student-001",
type: "sick",
startDate: "2026-07-15",
endDate: "2026-07-16",
reason: "感冒发烧需要在家休息观察",
};
expect(createLeave).toHaveBeenCalledWith(expectedInput);
});
it("提交成功后调用 onSuccess 回调", async () => {
const user = userEvent.setup();
const onSuccess = vi.fn();
mockUseCreateLeaveRequest.mockReturnValue({
createLeave: createLeaveMockSuccess(),
loading: false,
error: undefined,
});
render(<LeaveRequestForm onSuccess={onSuccess} />);
await user.type(screen.getByLabelText("开始日期"), "2026-07-15");
await user.type(screen.getByLabelText("结束日期"), "2026-07-16");
await user.type(
screen.getByLabelText("请假原因"),
"感冒发烧需要在家休息观察",
);
await user.click(screen.getByRole("button", { name: "提交申请" }));
await waitFor(() => {
expect(onSuccess).toHaveBeenCalledTimes(1);
});
});
it("提交成功后重置表单reason 清空)", async () => {
const user = userEvent.setup();
mockUseCreateLeaveRequest.mockReturnValue({
createLeave: createLeaveMockSuccess(),
loading: false,
error: undefined,
});
render(<LeaveRequestForm />);
const reasonInput = screen.getByLabelText("请假原因") as HTMLTextAreaElement;
await user.type(reasonInput, "感冒发烧需要在家休息观察");
await user.type(screen.getByLabelText("开始日期"), "2026-07-15");
await user.type(screen.getByLabelText("结束日期"), "2026-07-16");
await user.click(screen.getByRole("button", { name: "提交申请" }));
await waitFor(() => {
expect(reasonInput.value).toBe("");
});
});
it("createLeave 返回 failure 时不调用 onSuccess", async () => {
const user = userEvent.setup();
const onSuccess = vi.fn();
const createLeave = vi
.fn()
.mockResolvedValue({ success: false, error: { message: "失败" } as unknown as CombinedError });
mockUseCreateLeaveRequest.mockReturnValue({
createLeave,
loading: false,
error: undefined,
});
render(<LeaveRequestForm onSuccess={onSuccess} />);
await user.type(screen.getByLabelText("开始日期"), "2026-07-15");
await user.type(screen.getByLabelText("结束日期"), "2026-07-16");
await user.type(
screen.getByLabelText("请假原因"),
"感冒发烧需要在家休息观察",
);
await user.click(screen.getByRole("button", { name: "提交申请" }));
await waitFor(() => {
expect(createLeave).toHaveBeenCalled();
});
// 等待一拍确保 onSuccess 不会被调用
await new Promise((resolve) => setTimeout(resolve, 50));
expect(onSuccess).not.toHaveBeenCalled();
});
});
describe("LeaveRequestForm loading 态", () => {
it("loading=true 时按钮禁用并显示提交中", () => {
mockUseMyChildren.mockReturnValue({
children: mockChildren,
loading: false,
error: undefined,
});
mockUseCreateLeaveRequest.mockReturnValue({
createLeave: createLeaveMockSuccess(),
loading: true,
error: undefined,
});
render(<LeaveRequestForm />);
const button = screen.getByRole("button");
expect(button).toBeDisabled();
expect(button).toHaveTextContent("提交中...");
});
});
describe("LeaveRequestForm error 显示", () => {
it("有 error 时渲染错误信息", () => {
mockUseMyChildren.mockReturnValue({
children: mockChildren,
loading: false,
error: undefined,
});
const mockError = { message: "服务器错误" } as unknown as CombinedError;
mockUseCreateLeaveRequest.mockReturnValue({
createLeave: createLeaveMockSuccess(),
loading: false,
error: mockError,
});
render(<LeaveRequestForm />);
expect(screen.getByText("提交失败:服务器错误")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,214 @@
// LeaveRequestForm请假申请表单
// 依据02-architecture-design.md §15 组件设计、project_rules §3.8 输入验证
// - react-hook-form + zod 校验
// - 字段:子女 / 请假类型 / 起止日期 / 原因
// - 提交成功后回调 onSuccess用于刷新列表并重置表单
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useCreateLeaveRequest } from "@/hooks/useCreateLeaveRequest";
import { useMyChildren } from "@/hooks/useMyChildren";
import {
leaveRequestSchema,
LEAVE_TYPES,
type LeaveRequestFormValues,
} from "@/lib/schemas/leave-request";
import type { LeaveRequestInput, LeaveType } from "@/types";
import { cn } from "@/lib/utils";
const TYPE_LABELS: Record<LeaveType, string> = {
sick: "病假",
personal: "事假",
family: "家庭假",
other: "其他",
};
interface LeaveRequestFormProps {
/** 提交成功后回调(通常用于刷新请假列表) */
onSuccess?: () => void;
}
export function LeaveRequestForm({ onSuccess }: LeaveRequestFormProps) {
const { children } = useMyChildren();
const { createLeave, loading, error } = useCreateLeaveRequest();
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<LeaveRequestFormValues>({
resolver: zodResolver(leaveRequestSchema),
defaultValues: {
childId: children[0]?.id ?? "",
type: "sick",
startDate: "",
endDate: "",
reason: "",
},
});
const onSubmit = async (values: LeaveRequestFormValues): Promise<void> => {
const input: LeaveRequestInput = {
childId: values.childId,
type: values.type,
startDate: values.startDate,
endDate: values.endDate,
reason: values.reason,
};
const result = await createLeave(input);
if (result.success) {
reset();
onSuccess?.();
}
};
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="space-y-4 rounded border border-rule bg-paper-elevated p-4"
aria-label="请假申请表单"
>
<h2 className="font-serif text-base"></h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* 子女选择 */}
<div className="space-y-1">
<label
htmlFor="leave-child"
className="text-xs text-ink-muted"
>
</label>
<select
id="leave-child"
{...register("childId")}
className="w-full rounded border border-rule bg-paper px-3 py-2 text-sm focus:outline-none"
>
{children.map((child) => (
<option key={child.id} value={child.id}>
{child.name}
</option>
))}
</select>
{errors.childId && (
<p className="text-xs text-danger" role="alert">
{errors.childId.message}
</p>
)}
</div>
{/* 请假类型 */}
<div className="space-y-1">
<label
htmlFor="leave-type"
className="text-xs text-ink-muted"
>
</label>
<select
id="leave-type"
{...register("type")}
className="w-full rounded border border-rule bg-paper px-3 py-2 text-sm focus:outline-none"
>
{LEAVE_TYPES.map((t) => (
<option key={t} value={t}>
{TYPE_LABELS[t]}
</option>
))}
</select>
{errors.type && (
<p className="text-xs text-danger" role="alert">
{errors.type.message}
</p>
)}
</div>
{/* 开始日期 */}
<div className="space-y-1">
<label
htmlFor="leave-start"
className="text-xs text-ink-muted"
>
</label>
<input
id="leave-start"
type="date"
{...register("startDate")}
className="w-full rounded border border-rule bg-paper px-3 py-2 text-sm focus:outline-none"
/>
{errors.startDate && (
<p className="text-xs text-danger" role="alert">
{errors.startDate.message}
</p>
)}
</div>
{/* 结束日期 */}
<div className="space-y-1">
<label
htmlFor="leave-end"
className="text-xs text-ink-muted"
>
</label>
<input
id="leave-end"
type="date"
{...register("endDate")}
className="w-full rounded border border-rule bg-paper px-3 py-2 text-sm focus:outline-none"
/>
{errors.endDate && (
<p className="text-xs text-danger" role="alert">
{errors.endDate.message}
</p>
)}
</div>
</div>
{/* 原因 */}
<div className="space-y-1">
<label
htmlFor="leave-reason"
className="text-xs text-ink-muted"
>
</label>
<textarea
id="leave-reason"
rows={3}
{...register("reason")}
placeholder="请详细说明请假原因(至少 10 个字)"
className="w-full rounded border border-rule bg-paper px-3 py-2 text-sm focus:outline-none"
/>
{errors.reason && (
<p className="text-xs text-danger" role="alert">
{errors.reason.message}
</p>
)}
</div>
{error && (
<p className="text-sm text-danger" role="alert">
{error.message}
</p>
)}
<button
type="submit"
disabled={loading}
className={cn(
"rounded bg-accent px-4 py-2 text-sm font-medium text-paper transition-colors",
loading ? "cursor-not-allowed opacity-60" : "hover:opacity-90",
)}
>
{loading ? "提交中..." : "提交申请"}
</button>
</form>
);
}
export default LeaveRequestForm;

View File

@@ -0,0 +1,208 @@
// LeaveRequestList 组件单测
// 依据02-architecture-design.md §15 组件设计
// 覆盖:空态 / 全部列表渲染 / 状态筛选all/pending/approved/rejected/ 类型标签 / 状态标签 / 审批评论
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LeaveRequestList } from "./LeaveRequestList";
import type { LeaveRequest } from "@/types";
const mockLeaveRequests: LeaveRequest[] = [
{
id: "leave-001",
childId: "student-001",
childName: "张小明",
className: "初一(1)班",
type: "sick",
startDate: "2026-05-10",
endDate: "2026-05-11",
reason: "感冒发烧需要在家休息观察",
status: "approved",
submittedAt: "2026-05-09T20:15:00Z",
reviewedAt: "2026-05-10T08:30:00Z",
reviewerName: "王老师",
reviewComment: "已批准,注意休息,按时复课",
},
{
id: "leave-002",
childId: "student-001",
childName: "张小明",
className: "初一(1)班",
type: "personal",
startDate: "2026-06-15",
endDate: "2026-06-15",
reason: "家中临时事务需要陪同办理",
status: "pending",
submittedAt: "2026-06-13T21:00:00Z",
},
{
id: "leave-003",
childId: "student-002",
childName: "张小红",
className: "五年级(2)班",
type: "family",
startDate: "2026-04-20",
endDate: "2026-04-22",
reason: "回老家参加亲属婚礼",
status: "approved",
submittedAt: "2026-04-15T19:30:00Z",
reviewedAt: "2026-04-16T09:00:00Z",
reviewerName: "李老师",
reviewComment: "已批准,请提前补做作业",
},
{
id: "leave-004",
childId: "student-001",
childName: "张小明",
className: "初一(1)班",
type: "other",
startDate: "2026-03-01",
endDate: "2026-03-02",
reason: "其他原因请假说明文字",
status: "rejected",
submittedAt: "2026-02-28T10:00:00Z",
reviewedAt: "2026-03-01T08:00:00Z",
reviewerName: "王老师",
reviewComment: "请假理由不充分,请补充材料",
},
];
describe("LeaveRequestList 空态", () => {
it("空数组渲染暂无请假记录", () => {
render(<LeaveRequestList leaveRequests={[]} />);
expect(screen.getByText("暂无请假记录")).toBeInTheDocument();
});
});
describe("LeaveRequestList 渲染", () => {
it("渲染筛选 tab全部/待审批/已批准/已驳回)", () => {
render(<LeaveRequestList leaveRequests={mockLeaveRequests} />);
expect(screen.getByRole("tablist", { name: "请假状态筛选" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "全部" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "待审批" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "已批准" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "已驳回" })).toBeInTheDocument();
});
it("默认全部 tab 选中", () => {
render(<LeaveRequestList leaveRequests={mockLeaveRequests} />);
const allTab = screen.getByRole("tab", { name: "全部" });
expect(allTab).toHaveAttribute("aria-selected", "true");
});
it("默认显示全部 4 条记录", () => {
const { container } = render(<LeaveRequestList leaveRequests={mockLeaveRequests} />);
// 每条记录的类型标签渲染在 h3 中,计数 h3 验证记录数
const typeHeadings = container.querySelectorAll("h3");
expect(typeHeadings).toHaveLength(4);
});
it("渲染每条记录的日期信息", () => {
render(<LeaveRequestList leaveRequests={mockLeaveRequests} />);
// 4 条记录,每条有起始/结束/提交日期
expect(screen.getAllByText(/起始:/)).toHaveLength(4);
expect(screen.getAllByText(/结束:/)).toHaveLength(4);
expect(screen.getAllByText(/提交于:/)).toHaveLength(4);
});
it("渲染每条记录的请假原因", () => {
render(<LeaveRequestList leaveRequests={mockLeaveRequests} />);
expect(screen.getByText("感冒发烧需要在家休息观察")).toBeInTheDocument();
expect(screen.getByText("家中临时事务需要陪同办理")).toBeInTheDocument();
expect(screen.getByText("回老家参加亲属婚礼")).toBeInTheDocument();
expect(screen.getByText("其他原因请假说明文字")).toBeInTheDocument();
});
it("渲染审批评论(当存在时)", () => {
render(<LeaveRequestList leaveRequests={mockLeaveRequests} />);
expect(screen.getByText("已批准,注意休息,按时复课")).toBeInTheDocument();
expect(screen.getByText("已批准,请提前补做作业")).toBeInTheDocument();
expect(screen.getByText("请假理由不充分,请补充材料")).toBeInTheDocument();
});
it("不渲染审批评论区块(当不存在时)", () => {
const pendingOnly: LeaveRequest[] = [mockLeaveRequests[1]!];
render(<LeaveRequestList leaveRequests={pendingOnly} />);
expect(screen.queryByText(/审批人/)).not.toBeInTheDocument();
});
it("渲染状态标签(待审批/已批准/已驳回)", () => {
render(<LeaveRequestList leaveRequests={mockLeaveRequests} />);
// "待审批" 同时出现在筛选 tab 和状态标签中,共 2 处1 tab + 1 status
const pendingAll = screen.getAllByText("待审批");
expect(pendingAll).toHaveLength(2);
// "已批准" 同时出现在筛选 tab 和状态标签中,共 3 处1 tab + 2 status
const approvedAll = screen.getAllByText("已批准");
expect(approvedAll).toHaveLength(3);
// "已驳回" 同时出现在筛选 tab 和状态标签中,共 2 处1 tab + 1 status
const rejectedAll = screen.getAllByText("已驳回");
expect(rejectedAll).toHaveLength(2);
});
it("渲染审批人姓名(当存在时)", () => {
render(<LeaveRequestList leaveRequests={mockLeaveRequests} />);
// 3 条记录有审批人王老师2 次)+ 李老师1 次)
expect(screen.getAllByText(/王老师/)).toHaveLength(2);
expect(screen.getAllByText(/李老师/)).toHaveLength(1);
});
});
describe("LeaveRequestList 状态筛选", () => {
it("点击待审批 tab 仅显示 pending 记录", async () => {
const user = userEvent.setup();
render(<LeaveRequestList leaveRequests={mockLeaveRequests} />);
await user.click(screen.getByRole("tab", { name: "待审批" }));
expect(screen.getByRole("tab", { name: "待审批" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tab", { name: "全部" })).toHaveAttribute("aria-selected", "false");
// 仅 1 条 pending 记录(事假)
expect(screen.getByText("事假")).toBeInTheDocument();
expect(screen.queryByText("病假")).not.toBeInTheDocument();
expect(screen.queryByText("家庭假")).not.toBeInTheDocument();
expect(screen.queryByText("其他")).not.toBeInTheDocument();
});
it("点击已批准 tab 仅显示 approved 记录", async () => {
const user = userEvent.setup();
render(<LeaveRequestList leaveRequests={mockLeaveRequests} />);
await user.click(screen.getByRole("tab", { name: "已批准" }));
// 2 条 approved 记录(病假 + 家庭假)
expect(screen.getByText("病假")).toBeInTheDocument();
expect(screen.getByText("家庭假")).toBeInTheDocument();
expect(screen.queryByText("事假")).not.toBeInTheDocument();
expect(screen.queryByText("其他")).not.toBeInTheDocument();
});
it("点击已驳回 tab 仅显示 rejected 记录", async () => {
const user = userEvent.setup();
render(<LeaveRequestList leaveRequests={mockLeaveRequests} />);
await user.click(screen.getByRole("tab", { name: "已驳回" }));
// 1 条 rejected 记录(其他)
expect(screen.getByText("其他")).toBeInTheDocument();
expect(screen.queryByText("病假")).not.toBeInTheDocument();
expect(screen.queryByText("事假")).not.toBeInTheDocument();
});
it("已驳回筛选无匹配记录时显示暂无请假记录", async () => {
const user = userEvent.setup();
const noRejected = mockLeaveRequests.filter((l) => l.status !== "rejected");
render(<LeaveRequestList leaveRequests={noRejected} />);
await user.click(screen.getByRole("tab", { name: "已驳回" }));
expect(screen.getByText("暂无请假记录")).toBeInTheDocument();
});
it("点击全部 tab 恢复显示全部记录", async () => {
const user = userEvent.setup();
render(<LeaveRequestList leaveRequests={mockLeaveRequests} />);
// 先切到待审批
await user.click(screen.getByRole("tab", { name: "待审批" }));
expect(screen.queryByText("病假")).not.toBeInTheDocument();
// 再切回全部
await user.click(screen.getByRole("tab", { name: "全部" }));
expect(screen.getByText("病假")).toBeInTheDocument();
expect(screen.getByText("事假")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,138 @@
// LeaveRequestList历史请假列表
// 依据02-architecture-design.md §15 组件设计
// - 按状态筛选all/pending/approved/rejected
// - 显示请假类型 / 日期 / 原因 / 状态徽标 / 审批评论
"use client";
import { useState } from "react";
import type { LeaveRequest, LeaveStatus, LeaveType } from "@/types";
import { cn, formatDate } from "@/lib/utils";
const TYPE_LABELS: Record<LeaveType, string> = {
sick: "病假",
personal: "事假",
family: "家庭假",
other: "其他",
};
const STATUS_LABELS: Record<LeaveStatus, string> = {
pending: "待审批",
approved: "已批准",
rejected: "已驳回",
cancelled: "已取消",
};
const STATUS_STYLES: Record<LeaveStatus, string> = {
pending: "text-warning",
approved: "text-success",
rejected: "text-danger",
cancelled: "text-ink-muted",
};
type FilterValue = "all" | LeaveStatus;
const FILTERS: { value: FilterValue; label: string }[] = [
{ value: "all", label: "全部" },
{ value: "pending", label: "待审批" },
{ value: "approved", label: "已批准" },
{ value: "rejected", label: "已驳回" },
];
interface LeaveRequestListProps {
leaveRequests: LeaveRequest[];
}
export function LeaveRequestList({ leaveRequests }: LeaveRequestListProps) {
const [filter, setFilter] = useState<FilterValue>("all");
const filtered =
filter === "all"
? leaveRequests
: leaveRequests.filter((l) => l.status === filter);
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-2" role="tablist" aria-label="请假状态筛选">
{FILTERS.map((f) => (
<button
key={f.value}
type="button"
role="tab"
aria-selected={filter === f.value}
onClick={() => setFilter(f.value)}
className={cn(
"rounded px-3 py-1 text-sm transition-colors",
filter === f.value
? "bg-accent text-paper"
: "border border-rule text-ink-muted hover:bg-paper-elevated",
)}
>
{f.label}
</button>
))}
</div>
{filtered.length === 0 ? (
<p className="rounded border border-rule bg-paper-elevated p-4 text-sm text-ink-muted">
</p>
) : (
<div className="space-y-3">
{filtered.map((leave) => (
<div
key={leave.id}
className="rounded border border-rule bg-paper-elevated p-4"
>
<div className="flex items-start justify-between">
<div>
<h3 className="font-serif text-base">
{TYPE_LABELS[leave.type]}
</h3>
<p className="mt-1 text-xs text-ink-muted">
{leave.childName ?? ""}
{leave.className ? ` · ${leave.className}` : ""}
</p>
</div>
<span
className={cn(
"text-sm font-medium",
STATUS_STYLES[leave.status],
)}
role="status"
>
{STATUS_LABELS[leave.status]}
</span>
</div>
<div className="mt-3 flex flex-wrap gap-x-6 gap-y-1 text-xs text-ink-muted">
<span>
{formatDate(leave.startDate)}
</span>
<span>
{formatDate(leave.endDate)}
</span>
<span>
{formatDate(leave.submittedAt)}
</span>
</div>
<p className="mt-2 text-sm">{leave.reason}</p>
{leave.reviewComment && (
<div className="mt-2 border-t border-rule pt-2">
<p className="text-xs text-ink-muted">
{leave.reviewerName ?? "—"}
</p>
<p className="mt-1 text-sm mark-left">{leave.reviewComment}</p>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
export default LeaveRequestList;

View File

@@ -0,0 +1,112 @@
// LessonPlanList 组件单测
// 依据02-architecture-design.md §15 组件设计
// 覆盖:空态 / 列表渲染 / 状态徽标 / 跳转链接
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { LessonPlanList } from "./LessonPlanList";
import type { LessonPlan } from "@/types";
// mock next/link 为普通 <a>
vi.mock("next/link", () => ({
default: ({
href,
children,
...rest
}: {
href: string;
children: React.ReactNode;
[key: string]: unknown;
}) => (
<a href={href} {...rest}>
{children}
</a>
),
}));
const mockPlans: LessonPlan[] = [
{
id: "lp-001",
title: "一元二次方程的概念",
subject: "数学",
className: "初一(1)班",
teacherName: "王老师",
textbookTitle: "人教版七年级数学下册",
chapterTitle: "第一章 一元二次方程",
status: "published",
publishedAt: "2026-02-18T10:00:00Z",
estimatedMinutes: 45,
},
{
id: "lp-003",
title: "文言文虚词专题(一)",
subject: "语文",
className: "初一(1)班",
teacherName: "李老师",
textbookTitle: "人教版七年级语文下册",
chapterTitle: "第二单元 文言文阅读",
status: "published",
publishedAt: "2026-03-05T10:00:00Z",
estimatedMinutes: 45,
},
];
describe("LessonPlanList 空态", () => {
it("空数组渲染暂无备课记录", () => {
render(<LessonPlanList lessonPlans={[]} />);
expect(screen.getByText("暂无备课记录")).toBeInTheDocument();
});
});
describe("LessonPlanList 渲染", () => {
it("渲染全部备课标题", () => {
render(<LessonPlanList lessonPlans={mockPlans} />);
expect(screen.getByText("一元二次方程的概念")).toBeInTheDocument();
expect(screen.getByText("文言文虚词专题(一)")).toBeInTheDocument();
});
it("渲染学科标签", () => {
render(<LessonPlanList lessonPlans={mockPlans} />);
// 学科标签 + 文本中均出现,至少 1 次
expect(screen.getAllByText("数学").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("语文").length).toBeGreaterThanOrEqual(1);
});
it("渲染已发布状态徽标", () => {
render(<LessonPlanList lessonPlans={mockPlans} />);
expect(screen.getAllByText("已发布")).toHaveLength(2);
});
it("渲染教师、教材与章节信息", () => {
render(<LessonPlanList lessonPlans={mockPlans} />);
expect(screen.getByText("王老师")).toBeInTheDocument();
expect(screen.getByText("李老师")).toBeInTheDocument();
expect(screen.getByText("人教版七年级数学下册")).toBeInTheDocument();
expect(screen.getByText("人教版七年级语文下册")).toBeInTheDocument();
expect(screen.getByText("第一章 一元二次方程")).toBeInTheDocument();
});
it("渲染预计时长", () => {
render(<LessonPlanList lessonPlans={mockPlans} />);
expect(screen.getAllByText("预计 45 分钟")).toHaveLength(2);
});
it("跳转链接指向只读详情页", () => {
render(<LessonPlanList lessonPlans={mockPlans} />);
const mathLink = screen.getByRole("link", {
name: "一元二次方程的概念 - 查看备课",
});
expect(mathLink).toHaveAttribute(
"href",
"/parent/lesson-plans/lp-001/view",
);
const chineseLink = screen.getByRole("link", {
name: "文言文虚词专题(一) - 查看备课",
});
expect(chineseLink).toHaveAttribute(
"href",
"/parent/lesson-plans/lp-003/view",
);
});
});

View File

@@ -0,0 +1,117 @@
// LessonPlanList备课列表只读
// 依据02-architecture-design.md §15 组件设计
// 每项:标题/学科/班级/教师/教材/章节/状态/预计时长,点击跳转只读详情
"use client";
import Link from "next/link";
import type { LessonPlan } from "@/types";
import { cn, formatDateTime } from "@/lib/utils";
interface LessonPlanListProps {
lessonPlans: LessonPlan[];
}
type LessonPlanStatus = LessonPlan["status"];
const STATUS_LABELS: Record<LessonPlanStatus, string> = {
draft: "草稿",
published: "已发布",
archived: "已归档",
};
const STATUS_STYLES: Record<LessonPlanStatus, string> = {
draft: "text-ink-muted",
published: "text-success",
archived: "text-ink-muted",
};
export function LessonPlanList({
lessonPlans,
}: LessonPlanListProps): JSX.Element {
if (lessonPlans.length === 0) {
return (
<p className="rounded border border-rule bg-paper-elevated p-4 text-sm text-ink-muted">
</p>
);
}
return (
<ul className="space-y-3" aria-label="备课列表">
{lessonPlans.map((plan) => (
<li key={plan.id}>
<LessonPlanCard plan={plan} />
</li>
))}
</ul>
);
}
function LessonPlanCard({ plan }: { plan: LessonPlan }): JSX.Element {
return (
<Link
href={`/parent/lesson-plans/${plan.id}/view`}
className="block h-full focus-visible:outline-2"
aria-label={`${plan.title} - 查看备课`}
>
<article
className="h-full rounded border border-rule bg-paper-elevated p-4 transition-shadow hover:shadow-md"
aria-label={`${plan.title} - ${STATUS_LABELS[plan.status]}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="rounded bg-accent-soft px-2 py-0.5 text-xs text-ink">
{plan.subject}
</span>
<span className="text-xs text-ink-muted">
{plan.estimatedMinutes}
</span>
</div>
<h3 className="mt-2 font-serif text-base">{plan.title}</h3>
</div>
<span
className={cn(
"flex-shrink-0 text-xs font-medium",
STATUS_STYLES[plan.status],
)}
>
{STATUS_LABELS[plan.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">{plan.className}</dd>
</div>
<div>
<dt className="text-ink-muted"></dt>
<dd className="mt-1">{plan.teacherName}</dd>
</div>
<div>
<dt className="text-ink-muted"></dt>
<dd className="mt-1">{plan.textbookTitle}</dd>
</div>
<div>
<dt className="text-ink-muted"></dt>
<dd className="mt-1">{plan.chapterTitle}</dd>
</div>
{plan.publishedAt && (
<div>
<dt className="text-ink-muted"></dt>
<dd className="mt-1">{formatDateTime(plan.publishedAt)}</dd>
</div>
)}
</dl>
</div>
<p className="mt-3 text-xs text-accent"> </p>
</article>
</Link>
);
}
export default LessonPlanList;

View File

@@ -0,0 +1,127 @@
// LessonPlanReadonlyView 组件单测
// 依据02-architecture-design.md §15 组件设计
// 覆盖:基本信息 / 教学目标 / 教学重点 / 教学难点 / 教学内容 / 作业说明(可选)/ 空列表
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { LessonPlanReadonlyView } from "./LessonPlanReadonlyView";
import type { LessonPlanDetail } from "@/types";
const fullDetail: LessonPlanDetail = {
id: "lp-002",
title: "配方法解一元二次方程",
subject: "数学",
className: "初一(1)班",
teacherName: "王老师",
textbookTitle: "人教版七年级数学下册",
chapterTitle: "第一章 一元二次方程",
status: "published",
publishedAt: "2026-02-22T10:00:00Z",
estimatedMinutes: 45,
objectives: [
"理解配方法的几何意义",
"掌握用配方法解一元二次方程的步骤",
],
keyPoints: ["配方法的核心步骤", "完全平方式的应用"],
difficultPoints: ["配方时如何确定常数项", "二次项系数不为 1 时的处理"],
content: "本节课主要讲解配方法解一元二次方程的完整步骤。",
homeworkDescription: "课本第 25 页练习 1-4 题。",
};
describe("LessonPlanReadonlyView 完整内容", () => {
it("渲染标题与学科", () => {
render(<LessonPlanReadonlyView lessonPlan={fullDetail} />);
expect(screen.getByText("配方法解一元二次方程")).toBeInTheDocument();
expect(screen.getByText("数学")).toBeInTheDocument();
});
it("渲染基本信息(班级/教师/教材/章节)", () => {
render(<LessonPlanReadonlyView lessonPlan={fullDetail} />);
expect(screen.getByText("初一(1)班")).toBeInTheDocument();
expect(screen.getByText("王老师")).toBeInTheDocument();
expect(screen.getByText("人教版七年级数学下册")).toBeInTheDocument();
expect(screen.getByText("第一章 一元二次方程")).toBeInTheDocument();
});
it("渲染教学目标列表", () => {
render(<LessonPlanReadonlyView lessonPlan={fullDetail} />);
expect(
screen.getByText("理解配方法的几何意义"),
).toBeInTheDocument();
expect(
screen.getByText("掌握用配方法解一元二次方程的步骤"),
).toBeInTheDocument();
});
it("渲染教学重点列表", () => {
render(<LessonPlanReadonlyView lessonPlan={fullDetail} />);
expect(screen.getByText("配方法的核心步骤")).toBeInTheDocument();
expect(screen.getByText("完全平方式的应用")).toBeInTheDocument();
});
it("渲染教学难点列表", () => {
render(<LessonPlanReadonlyView lessonPlan={fullDetail} />);
expect(
screen.getByText("配方时如何确定常数项"),
).toBeInTheDocument();
expect(
screen.getByText("二次项系数不为 1 时的处理"),
).toBeInTheDocument();
});
it("渲染教学内容文本", () => {
render(<LessonPlanReadonlyView lessonPlan={fullDetail} />);
expect(
screen.getByText(
"本节课主要讲解配方法解一元二次方程的完整步骤。",
),
).toBeInTheDocument();
});
it("渲染作业说明(当存在时)", () => {
render(<LessonPlanReadonlyView lessonPlan={fullDetail} />);
expect(screen.getByText("课本第 25 页练习 1-4 题。")).toBeInTheDocument();
});
});
describe("LessonPlanReadonlyView 作业说明可选", () => {
it("无作业说明时不渲染作业说明区块", () => {
const noHomework: LessonPlanDetail = {
...fullDetail,
homeworkDescription: undefined,
};
render(<LessonPlanReadonlyView lessonPlan={noHomework} />);
// "作业说明" 标题不应出现
const headings = screen.queryAllByText("作业说明");
expect(headings).toHaveLength(0);
});
});
describe("LessonPlanReadonlyView 空列表", () => {
it("教学目标为空时显示空态文案", () => {
const emptyObjectives: LessonPlanDetail = {
...fullDetail,
objectives: [],
};
render(<LessonPlanReadonlyView lessonPlan={emptyObjectives} />);
expect(screen.getByText("暂无教学目标")).toBeInTheDocument();
});
it("教学重点为空时显示空态文案", () => {
const emptyKeyPoints: LessonPlanDetail = {
...fullDetail,
keyPoints: [],
};
render(<LessonPlanReadonlyView lessonPlan={emptyKeyPoints} />);
expect(screen.getByText("暂无教学重点")).toBeInTheDocument();
});
it("教学难点为空时显示空态文案", () => {
const emptyDifficult: LessonPlanDetail = {
...fullDetail,
difficultPoints: [],
};
render(<LessonPlanReadonlyView lessonPlan={emptyDifficult} />);
expect(screen.getByText("暂无教学难点")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,133 @@
// LessonPlanReadonlyView备课只读详情
// 依据02-architecture-design.md §15 组件设计
// 内容:教学目标/教学重点/教学难点/教学内容/作业说明(可选)
"use client";
import type { LessonPlanDetail } from "@/types";
import { formatDateTime } from "@/lib/utils";
interface LessonPlanReadonlyViewProps {
lessonPlan: LessonPlanDetail;
}
function BulletList({
items,
emptyText,
}: {
items: string[];
emptyText: string;
}): JSX.Element {
if (items.length === 0) {
return <p className="text-sm text-ink-muted">{emptyText}</p>;
}
return (
<ul className="space-y-1">
{items.map((item, idx) => (
<li key={idx} className="flex gap-2 text-sm">
<span className="mt-1 h-1 w-1 flex-shrink-0 rounded-full bg-ink-muted" aria-hidden="true" />
<span>{item}</span>
</li>
))}
</ul>
);
}
export function LessonPlanReadonlyView({
lessonPlan,
}: LessonPlanReadonlyViewProps): JSX.Element {
return (
<article className="space-y-6">
{/* 头部信息 */}
<section
aria-label="备课基本信息"
className="rounded border border-rule bg-paper-elevated p-4"
>
<div className="flex items-center gap-2">
<span className="rounded bg-accent-soft px-2 py-0.5 text-xs text-ink">
{lessonPlan.subject}
</span>
<span className="text-xs text-ink-muted">
{lessonPlan.estimatedMinutes}
</span>
</div>
<h2 className="mt-2 font-serif text-xl">{lessonPlan.title}</h2>
<div className="mt-4 border-t border-rule pt-3">
<dl className="grid grid-cols-2 gap-y-3 text-sm sm:grid-cols-4">
<div>
<dt className="text-xs text-ink-muted"></dt>
<dd className="mt-1">{lessonPlan.className}</dd>
</div>
<div>
<dt className="text-xs text-ink-muted"></dt>
<dd className="mt-1">{lessonPlan.teacherName}</dd>
</div>
<div>
<dt className="text-xs text-ink-muted"></dt>
<dd className="mt-1">{lessonPlan.textbookTitle}</dd>
</div>
<div>
<dt className="text-xs text-ink-muted"></dt>
<dd className="mt-1">{lessonPlan.chapterTitle}</dd>
</div>
{lessonPlan.publishedAt && (
<div>
<dt className="text-xs text-ink-muted"></dt>
<dd className="mt-1">{formatDateTime(lessonPlan.publishedAt)}</dd>
</div>
)}
</dl>
</div>
</section>
{/* 教学目标 */}
<section aria-label="教学目标" className="space-y-2">
<h3 className="font-serif text-base"></h3>
<div className="rounded border border-rule bg-paper-elevated p-4">
<BulletList items={lessonPlan.objectives} emptyText="暂无教学目标" />
</div>
</section>
{/* 教学重点 */}
<section aria-label="教学重点" className="space-y-2">
<h3 className="font-serif text-base"></h3>
<div className="rounded border border-rule bg-paper-elevated p-4">
<BulletList items={lessonPlan.keyPoints} emptyText="暂无教学重点" />
</div>
</section>
{/* 教学难点 */}
<section aria-label="教学难点" className="space-y-2">
<h3 className="font-serif text-base"></h3>
<div className="rounded border border-rule bg-paper-elevated p-4">
<BulletList
items={lessonPlan.difficultPoints}
emptyText="暂无教学难点"
/>
</div>
</section>
{/* 教学内容 */}
<section aria-label="教学内容" className="space-y-2">
<h3 className="font-serif text-base"></h3>
<div className="rounded border border-rule bg-paper-elevated p-4">
<p className="text-sm leading-relaxed">{lessonPlan.content}</p>
</div>
</section>
{/* 作业说明(可选) */}
{lessonPlan.homeworkDescription && (
<section aria-label="作业说明" className="space-y-2">
<h3 className="font-serif text-base"></h3>
<div className="rounded border border-rule bg-paper-elevated p-4">
<p className="text-sm leading-relaxed">
{lessonPlan.homeworkDescription}
</p>
</div>
</section>
)}
</article>
);
}
export default LessonPlanReadonlyView;

View File

@@ -0,0 +1,134 @@
// MasterySummaryCard 组件单测
// 依据02-architecture-design.md §15 组件设计
// 覆盖:总体掌握度 / 知识点统计 / 各学科掌握度 / 进度条 / 弱项高亮
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { MasterySummaryCard } from "./MasterySummaryCard";
import type { MasterySummary } from "@/types";
function makeSummary(overrides: Partial<MasterySummary> = {}): MasterySummary {
return {
childId: "student-001",
overallMastery: 0.78,
subjectMastery: [
{ subject: "语文", mastery: 0.82 },
{ subject: "数学", mastery: 0.75 },
{ subject: "英语", mastery: 0.8 },
{ subject: "物理", mastery: 0.85 },
],
totalKps: 86,
masteredKps: 67,
...overrides,
};
}
describe("MasterySummaryCard 总体掌握度", () => {
it("渲染总体掌握度标签", () => {
render(<MasterySummaryCard summary={makeSummary()} />);
expect(screen.getByText("总体掌握度")).toBeInTheDocument();
});
it("渲染总体掌握度百分比0.78 → 78.0%", () => {
render(<MasterySummaryCard summary={makeSummary()} />);
expect(screen.getByText(/78\.0%/)).toBeInTheDocument();
});
it("渲染总体掌握度 progressbar", () => {
render(<MasterySummaryCard summary={makeSummary()} />);
const bar = screen.getByRole("progressbar", { name: "总体掌握度" });
expect(bar).toHaveAttribute("aria-valuenow", "78");
expect(bar).toHaveAttribute("aria-valuemin", "0");
expect(bar).toHaveAttribute("aria-valuemax", "100");
});
it("overallMastery=0 时 progressbar 为 0%", () => {
render(<MasterySummaryCard summary={makeSummary({ overallMastery: 0 })} />);
const bar = screen.getByRole("progressbar", { name: "总体掌握度" });
expect(bar).toHaveAttribute("aria-valuenow", "0");
});
it("overallMastery=1 时 progressbar 为 100%", () => {
render(<MasterySummaryCard summary={makeSummary({ overallMastery: 1 })} />);
const bar = screen.getByRole("progressbar", { name: "总体掌握度" });
expect(bar).toHaveAttribute("aria-valuenow", "100");
});
});
describe("MasterySummaryCard 知识点统计", () => {
it("渲染已掌握知识点标签", () => {
render(<MasterySummaryCard summary={makeSummary()} />);
expect(screen.getByText("已掌握知识点")).toBeInTheDocument();
});
it("渲染已掌握 / 总数67 / 86", () => {
render(<MasterySummaryCard summary={makeSummary()} />);
expect(screen.getByText("67 / 86")).toBeInTheDocument();
});
});
describe("MasterySummaryCard 各学科掌握度", () => {
it("渲染各学科掌握度标题", () => {
render(<MasterySummaryCard summary={makeSummary()} />);
expect(screen.getByText("各学科掌握度")).toBeInTheDocument();
});
it("渲染每个学科名称", () => {
render(<MasterySummaryCard summary={makeSummary()} />);
expect(screen.getByText("语文")).toBeInTheDocument();
expect(screen.getByText("数学")).toBeInTheDocument();
expect(screen.getByText("英语")).toBeInTheDocument();
expect(screen.getByText("物理")).toBeInTheDocument();
});
it("渲染每个学科掌握率百分比", () => {
render(<MasterySummaryCard summary={makeSummary()} />);
// 0.82 → 82.0%0.75 → 75.0%0.8 → 80.0%0.85 → 85.0%
expect(screen.getByText(/82\.0%/)).toBeInTheDocument();
expect(screen.getByText(/75\.0%/)).toBeInTheDocument();
expect(screen.getByText(/80\.0%/)).toBeInTheDocument();
expect(screen.getByText(/85\.0%/)).toBeInTheDocument();
});
it("每个学科渲染 progressbar", () => {
render(<MasterySummaryCard summary={makeSummary()} />);
expect(screen.getByRole("progressbar", { name: "语文掌握度" })).toBeInTheDocument();
expect(screen.getByRole("progressbar", { name: "数学掌握度" })).toBeInTheDocument();
expect(screen.getByRole("progressbar", { name: "英语掌握度" })).toBeInTheDocument();
expect(screen.getByRole("progressbar", { name: "物理掌握度" })).toBeInTheDocument();
});
it("掌握度 < 0.6 时用 danger 色isWeak", () => {
const { container } = render(
<MasterySummaryCard
summary={makeSummary({
subjectMastery: [
{ subject: "数学", mastery: 0.45 },
{ subject: "物理", mastery: 0.85 },
],
})}
/>,
);
const dangerBars = container.querySelectorAll(".bg-danger");
const successBars = container.querySelectorAll(".bg-success");
expect(dangerBars.length).toBeGreaterThanOrEqual(1);
expect(successBars.length).toBeGreaterThanOrEqual(1);
});
it("掌握度 >= 0.6 时用 success 色", () => {
const { container } = render(
<MasterySummaryCard
summary={makeSummary({
subjectMastery: [{ subject: "数学", mastery: 0.85 }],
})}
/>,
);
const successBars = container.querySelectorAll(".bg-success");
expect(successBars.length).toBeGreaterThanOrEqual(1);
});
it("渲染 regionaria-label", () => {
render(<MasterySummaryCard summary={makeSummary()} />);
expect(screen.getByLabelText("掌握度摘要")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,96 @@
// MasterySummaryCard掌握度摘要卡片
// 依据02-architecture-design.md §15 组件设计
// - 总体掌握度(大字号百分比)
// - 已掌握 / 总知识点数
// - 各学科掌握度进度条
"use client";
import type { MasterySummary } from "@/types";
import { cn, formatPercent, formatNumber } from "@/lib/utils";
interface MasterySummaryCardProps {
summary: MasterySummary;
}
export function MasterySummaryCard({ summary }: MasterySummaryCardProps) {
const overallPercent = Math.round(summary.overallMastery * 100);
return (
<div className="space-y-4" aria-label="掌握度摘要">
<div className="rounded border border-rule bg-paper-elevated p-4">
<div className="flex items-end justify-between">
<div>
<p className="text-xs text-ink-muted"></p>
<p className="mt-1 font-serif text-3xl text-accent">
{formatPercent(summary.overallMastery)}
</p>
</div>
<div className="text-right">
<p className="text-xs text-ink-muted"></p>
<p className="mt-1 font-mono text-lg">
{formatNumber(summary.masteredKps)} /{" "}
{formatNumber(summary.totalKps)}
</p>
</div>
</div>
<div
className="mt-3 h-2 overflow-hidden rounded bg-rule"
role="progressbar"
aria-valuenow={overallPercent}
aria-valuemin={0}
aria-valuemax={100}
aria-label="总体掌握度"
>
<div
className="h-full rounded bg-accent"
style={{ width: `${overallPercent}%` }}
/>
</div>
</div>
<div className="rounded border border-rule bg-paper-elevated p-4">
<h3 className="font-serif text-base"></h3>
<ul className="mt-3 space-y-3">
{summary.subjectMastery.map((sm) => {
const percent = Math.round(sm.mastery * 100);
const isWeak = sm.mastery < 0.6;
return (
<li key={sm.subject}>
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{sm.subject}</span>
<span
className={cn(
"font-mono",
isWeak ? "text-danger" : "text-success",
)}
>
{formatPercent(sm.mastery)}
</span>
</div>
<div
className="mt-1 h-1.5 overflow-hidden rounded bg-rule"
role="progressbar"
aria-valuenow={percent}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${sm.subject}掌握度`}
>
<div
className={cn(
"h-full rounded",
isWeak ? "bg-danger" : "bg-success",
)}
style={{ width: `${percent}%` }}
/>
</div>
</li>
);
})}
</ul>
</div>
</div>
);
}
export default MasterySummaryCard;

View File

@@ -0,0 +1,152 @@
// ParentAttentionBanner 组件单测
// 依据:参考项目差距补齐 - 仪表盘关注横幅
// 覆盖:无预警不渲染 / 逾期作业预警 / 出勤率低预警 / 关闭横幅 / 多子女预警
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ParentAttentionBanner } from "./ParentAttentionBanner";
interface AttentionItem {
childId: string;
childName: string;
pendingHomeworkCount: number;
attendanceRate: number;
}
function makeItem(overrides: Partial<AttentionItem> = {}): AttentionItem {
return {
childId: "student-001",
childName: "张小明",
pendingHomeworkCount: 0,
attendanceRate: 1,
...overrides,
};
}
describe("ParentAttentionBanner 无预警", () => {
it("所有子女均正常时不渲染横幅", () => {
const { container } = render(
<ParentAttentionBanner
items={[makeItem({ pendingHomeworkCount: 0, attendanceRate: 0.96 })]}
/>,
);
expect(container.firstChild).toBeNull();
});
it("空 items 数组不渲染横幅", () => {
const { container } = render(<ParentAttentionBanner items={[]} />);
expect(container.firstChild).toBeNull();
});
});
describe("ParentAttentionBanner 逾期作业预警", () => {
it("pendingHomeworkCount > 0 时显示待办作业预警", () => {
render(
<ParentAttentionBanner
items={[makeItem({ pendingHomeworkCount: 3 })]}
/>,
);
expect(screen.getByText(/张小明有 3 份待办作业/)).toBeInTheDocument();
});
it("预警级别为 warning温馨提醒", () => {
render(
<ParentAttentionBanner
items={[makeItem({ pendingHomeworkCount: 2 })]}
/>,
);
expect(screen.getByText("温馨提醒")).toBeInTheDocument();
});
});
describe("ParentAttentionBanner 出勤率低预警", () => {
it("attendanceRate < 0.9 时显示出勤率偏低预警", () => {
render(
<ParentAttentionBanner
items={[makeItem({ attendanceRate: 0.85 })]}
/>,
);
expect(screen.getByText(/张小明出勤率偏低/)).toBeInTheDocument();
expect(screen.getByText(/85/)).toBeInTheDocument();
});
it("出勤率低预警级别为 danger需要关注", () => {
render(
<ParentAttentionBanner
items={[makeItem({ attendanceRate: 0.85 })]}
/>,
);
expect(screen.getByText("需要关注")).toBeInTheDocument();
});
it("attendanceRate = 0.9 时不预警(边界值)", () => {
const { container } = render(
<ParentAttentionBanner
items={[makeItem({ attendanceRate: 0.9 })]}
/>,
);
expect(container.firstChild).toBeNull();
});
});
describe("ParentAttentionBanner 多子女预警", () => {
it("多个子女同时预警时显示多条信息", () => {
const items = [
makeItem({
childId: "student-001",
childName: "张小明",
pendingHomeworkCount: 3,
}),
makeItem({
childId: "student-002",
childName: "张小红",
attendanceRate: 0.8,
}),
];
render(<ParentAttentionBanner items={items} />);
expect(screen.getByText(/张小明有 3 份待办作业/)).toBeInTheDocument();
expect(screen.getByText(/张小红出勤率偏低/)).toBeInTheDocument();
});
it("有 danger 预警时标题为需要关注", () => {
const items = [
makeItem({
childId: "student-001",
childName: "张小明",
pendingHomeworkCount: 1,
}),
makeItem({
childId: "student-002",
childName: "张小红",
attendanceRate: 0.85,
}),
];
render(<ParentAttentionBanner items={items} />);
expect(screen.getByText("需要关注")).toBeInTheDocument();
});
});
describe("ParentAttentionBanner 关闭功能", () => {
it("点击关闭按钮后横幅消失", async () => {
const user = userEvent.setup();
render(
<ParentAttentionBanner
items={[makeItem({ pendingHomeworkCount: 1 })]}
/>,
);
expect(screen.getByText(/份待办作业/)).toBeInTheDocument();
const closeBtn = screen.getByRole("button", { name: "关闭横幅" });
await user.click(closeBtn);
expect(screen.queryByText(/份待办作业/)).not.toBeInTheDocument();
});
it("关闭按钮有 aria-label", () => {
render(
<ParentAttentionBanner
items={[makeItem({ pendingHomeworkCount: 1 })]}
/>,
);
expect(screen.getByRole("button", { name: "关闭横幅" })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,105 @@
// ParentAttentionBanner家长关注横幅
// 依据:参考项目差距补齐 - 仪表盘关注预警
// - 检测:任一子女 pendingHomeworkCount > 0 或 attendanceRate < 0.9
// - 显示预警信息(逾期作业 / 出勤率偏低)
// - 可关闭(本地 state不持久化
"use client";
import { useState, useMemo } from "react";
import { cn, formatPercent } from "@/lib/utils";
interface AttentionItem {
childId: string;
childName: string;
pendingHomeworkCount: number;
attendanceRate: number;
}
interface ParentAttentionBannerProps {
items: AttentionItem[];
}
interface Warning {
childId: string;
childName: string;
message: string;
level: "warning" | "danger";
}
export function ParentAttentionBanner({
items,
}: ParentAttentionBannerProps): JSX.Element | null {
const [dismissed, setDismissed] = useState(false);
const warnings = useMemo<Warning[]>(() => {
const result: Warning[] = [];
for (const item of items) {
if (item.pendingHomeworkCount > 0) {
result.push({
childId: item.childId,
childName: item.childName,
message: `${item.childName}${item.pendingHomeworkCount} 份待办作业`,
level: "warning",
});
}
if (item.attendanceRate < 0.9) {
result.push({
childId: item.childId,
childName: item.childName,
message: `${item.childName}出勤率偏低(${formatPercent(item.attendanceRate)}`,
level: "danger",
});
}
}
return result;
}, [items]);
if (dismissed || warnings.length === 0) {
return null;
}
const hasDanger = warnings.some((w) => w.level === "danger");
return (
<div
role="alert"
className={cn(
"rounded border p-4",
hasDanger
? "border-danger bg-danger/10"
: "border-warning bg-warning/10",
)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 space-y-1">
<p className="font-serif text-base">
{hasDanger ? "需要关注" : "温馨提醒"}
</p>
<ul className="space-y-1 text-sm">
{warnings.map((w, idx) => (
<li
key={`${w.childId}-${idx}`}
className={cn(
w.level === "danger" ? "text-danger" : "text-warning",
)}
>
{w.message}
</li>
))}
</ul>
</div>
<button
type="button"
onClick={() => setDismissed(true)}
className="flex-shrink-0 rounded px-2 py-1 text-sm text-ink-muted hover:text-ink"
aria-label="关闭横幅"
>
×
</button>
</div>
</div>
);
}
export default ParentAttentionBanner;

View File

@@ -0,0 +1,252 @@
// PracticeSessionList 组件单测
// 依据02-architecture-design.md §15 组件设计
// 覆盖:空态 / 列表渲染 / 学科标签 / 开始时间 / 题数+正确数 / 正确率 / 用时 / 知识点标签
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { PracticeSessionList } from "./PracticeSessionList";
import type { PracticeSession } from "@/types";
function makeSessions(): PracticeSession[] {
return [
{
id: "ps-001",
childId: "student-001",
subject: "数学",
startedAt: "2026-07-10T19:00:00Z",
completedAt: "2026-07-10T19:35:00Z",
questionCount: 20,
correctCount: 17,
accuracy: 0.85,
durationSeconds: 2100,
knowledgePoints: ["一元二次方程", "配方法"],
},
{
id: "ps-002",
childId: "student-001",
subject: "英语",
startedAt: "2026-07-09T20:10:00Z",
completedAt: "2026-07-09T20:40:00Z",
questionCount: 15,
correctCount: 9,
accuracy: 0.6,
durationSeconds: 1800,
knowledgePoints: ["现在完成时"],
},
{
id: "ps-003",
childId: "student-001",
subject: "物理",
startedAt: "2026-07-08T18:30:00Z",
completedAt: "2026-07-08T19:05:00Z",
questionCount: 10,
correctCount: 4,
accuracy: 0.4,
durationSeconds: 75,
knowledgePoints: ["浮力"],
},
];
}
describe("PracticeSessionList 空态", () => {
it("空数组渲染暂无练习记录", () => {
render(<PracticeSessionList sessions={[]} />);
expect(screen.getByText("暂无练习记录")).toBeInTheDocument();
});
});
describe("PracticeSessionList 列表渲染", () => {
it("渲染列表 regionaria-label", () => {
render(<PracticeSessionList sessions={makeSessions()} />);
expect(screen.getByLabelText("练习历史列表")).toBeInTheDocument();
});
it("渲染每条学科标签", () => {
render(<PracticeSessionList sessions={makeSessions()} />);
expect(screen.getByText("数学")).toBeInTheDocument();
expect(screen.getByText("英语")).toBeInTheDocument();
expect(screen.getByText("物理")).toBeInTheDocument();
});
it("渲染每条开始时间", () => {
render(<PracticeSessionList sessions={makeSessions()} />);
expect(screen.getAllByText(/题数:/)).toHaveLength(3);
});
it("渲染每条题数", () => {
render(<PracticeSessionList sessions={makeSessions()} />);
expect(screen.getByText("20")).toBeInTheDocument();
expect(screen.getByText("15")).toBeInTheDocument();
expect(screen.getByText("10")).toBeInTheDocument();
});
it("渲染每条正确数", () => {
render(<PracticeSessionList sessions={makeSessions()} />);
expect(screen.getByText("17")).toBeInTheDocument();
expect(screen.getByText("9")).toBeInTheDocument();
expect(screen.getByText("4")).toBeInTheDocument();
});
it("渲染每条正确率标签", () => {
render(<PracticeSessionList sessions={makeSessions()} />);
// "正确率" 标签每条 1 个
expect(screen.getAllByText("正确率")).toHaveLength(3);
});
it("渲染每条正确率百分比", () => {
render(<PracticeSessionList sessions={makeSessions()} />);
// 0.85 → 85.0%0.6 → 60.0%0.4 → 40.0%
expect(screen.getByText(/85\.0%/)).toBeInTheDocument();
expect(screen.getByText(/60\.0%/)).toBeInTheDocument();
expect(screen.getByText(/40\.0%/)).toBeInTheDocument();
});
it("渲染每条知识点 badge", () => {
render(<PracticeSessionList sessions={makeSessions()} />);
expect(screen.getByText("一元二次方程")).toBeInTheDocument();
expect(screen.getByText("配方法")).toBeInTheDocument();
expect(screen.getByText("现在完成时")).toBeInTheDocument();
expect(screen.getByText("浮力")).toBeInTheDocument();
});
});
describe("PracticeSessionList 用时格式化", () => {
it("2100 秒渲染为 35:00", () => {
render(<PracticeSessionList sessions={makeSessions()} />);
expect(screen.getByText("35:00")).toBeInTheDocument();
});
it("1800 秒渲染为 30:00", () => {
render(<PracticeSessionList sessions={makeSessions()} />);
expect(screen.getByText("30:00")).toBeInTheDocument();
});
it("75 秒渲染为 01:15", () => {
render(<PracticeSessionList sessions={makeSessions()} />);
expect(screen.getByText("01:15")).toBeInTheDocument();
});
it("0 秒渲染为 00:00", () => {
const sessions: PracticeSession[] = [
{
id: "ps-zero",
childId: "student-001",
subject: "数学",
startedAt: "2026-07-10T19:00:00Z",
questionCount: 0,
correctCount: 0,
accuracy: 0,
durationSeconds: 0,
knowledgePoints: [],
},
];
render(<PracticeSessionList sessions={sessions} />);
expect(screen.getByText("00:00")).toBeInTheDocument();
});
it("3600 秒渲染为 60:00", () => {
const sessions: PracticeSession[] = [
{
id: "ps-hour",
childId: "student-001",
subject: "数学",
startedAt: "2026-07-10T19:00:00Z",
questionCount: 30,
correctCount: 25,
accuracy: 0.83,
durationSeconds: 3600,
knowledgePoints: [],
},
];
render(<PracticeSessionList sessions={sessions} />);
expect(screen.getByText("60:00")).toBeInTheDocument();
});
});
describe("PracticeSessionList 正确率颜色区分", () => {
it("正确率 >= 0.8 使用 success 色", () => {
const { container } = render(
<PracticeSessionList sessions={makeSessions()} />,
);
// accuracy=0.85 → success
const successTexts = container.querySelectorAll(".text-success");
expect(successTexts.length).toBeGreaterThan(0);
});
it("正确率在 0.6 - 0.8 之间使用 warning 色", () => {
const { container } = render(
<PracticeSessionList sessions={makeSessions()} />,
);
// accuracy=0.6 → warning
const warningTexts = container.querySelectorAll(".text-warning");
expect(warningTexts.length).toBeGreaterThan(0);
});
it("正确率 < 0.6 使用 danger 色", () => {
const { container } = render(
<PracticeSessionList sessions={makeSessions()} />,
);
// accuracy=0.4 → danger
const dangerTexts = container.querySelectorAll(".text-danger");
expect(dangerTexts.length).toBeGreaterThan(0);
});
});
describe("PracticeSessionList 正确率进度条", () => {
it("每条记录渲染 progressbar", () => {
render(<PracticeSessionList sessions={makeSessions()} />);
const bars = screen.getAllByRole("progressbar");
expect(bars).toHaveLength(3);
});
it("progressbar aria-valuenow 反映正确率百分比", () => {
render(<PracticeSessionList sessions={makeSessions()} />);
const bars = screen.getAllByRole("progressbar");
// 0.85 → 850.6 → 600.4 → 40
expect(bars[0]).toHaveAttribute("aria-valuenow", "85");
expect(bars[1]).toHaveAttribute("aria-valuenow", "60");
expect(bars[2]).toHaveAttribute("aria-valuenow", "40");
});
});
describe("PracticeSessionList 知识点 badge", () => {
it("知识点为空数组时不渲染 badge 区块", () => {
const sessions: PracticeSession[] = [
{
id: "ps-no-kp",
childId: "student-001",
subject: "数学",
startedAt: "2026-07-10T19:00:00Z",
questionCount: 5,
correctCount: 5,
accuracy: 1,
durationSeconds: 100,
knowledgePoints: [],
},
];
const { container } = render(<PracticeSessionList sessions={sessions} />);
// 不应有知识点 badge
const badges = container.querySelectorAll(".border-rule.px-2");
expect(badges.length).toBe(0);
});
it("多个知识点都渲染为 badge", () => {
const sessions: PracticeSession[] = [
{
id: "ps-multi-kp",
childId: "student-001",
subject: "数学",
startedAt: "2026-07-10T19:00:00Z",
questionCount: 10,
correctCount: 8,
accuracy: 0.8,
durationSeconds: 600,
knowledgePoints: ["一元二次方程", "配方法", "韦达定理"],
},
];
render(<PracticeSessionList sessions={sessions} />);
expect(screen.getByText("一元二次方程")).toBeInTheDocument();
expect(screen.getByText("配方法")).toBeInTheDocument();
expect(screen.getByText("韦达定理")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,125 @@
// PracticeSessionList练习历史列表
// 依据02-architecture-design.md §15 组件设计
// 每项显示学科 / 开始时间 / 题数 / 正确数 / 正确率 / 用时mm:ss/ 知识点标签
"use client";
import type { PracticeSession } from "@/types";
import { cn, formatPercent, formatDateTime } from "@/lib/utils";
interface PracticeSessionListProps {
sessions: PracticeSession[];
}
/**
* 秒数格式化为 mm:ss
*/
function formatDuration(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const remaining = seconds % 60;
const mm = String(minutes).padStart(2, "0");
const ss = String(remaining).padStart(2, "0");
return `${mm}:${ss}`;
}
function accuracyTone(rate: number): "success" | "warning" | "danger" {
if (rate >= 0.8) return "success";
if (rate >= 0.6) return "warning";
return "danger";
}
export function PracticeSessionList({ sessions }: PracticeSessionListProps) {
if (sessions.length === 0) {
return (
<p className="rounded border border-rule bg-paper-elevated p-4 text-sm text-ink-muted">
</p>
);
}
return (
<ul className="space-y-3" aria-label="练习历史列表">
{sessions.map((s) => {
const accuracyPercent = Math.round(s.accuracy * 100);
const tone = accuracyTone(s.accuracy);
return (
<li
key={s.id}
className="rounded border border-rule bg-paper-elevated p-4"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="rounded bg-accent-soft px-2 py-0.5 text-xs text-ink">
{s.subject}
</span>
<span className="text-xs text-ink-muted">
{formatDateTime(s.startedAt)}
</span>
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
<span>
<span className="font-mono">{s.questionCount}</span>
</span>
<span>
<span className="font-mono">{s.correctCount}</span>
</span>
<span>
<span className="font-mono">{formatDuration(s.durationSeconds)}</span>
</span>
</div>
</div>
<div className="shrink-0 text-right">
<p className="text-xs text-ink-muted"></p>
<p
className={cn(
"mt-1 font-mono text-lg",
tone === "success" && "text-success",
tone === "warning" && "text-warning",
tone === "danger" && "text-danger",
)}
>
{formatPercent(s.accuracy)}
</p>
</div>
</div>
<div
className="mt-3 h-1.5 overflow-hidden rounded bg-rule"
role="progressbar"
aria-valuenow={accuracyPercent}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${s.subject}练习正确率`}
>
<div
className={cn(
"h-full rounded",
tone === "success" && "bg-success",
tone === "warning" && "bg-warning",
tone === "danger" && "bg-danger",
)}
style={{ width: `${accuracyPercent}%` }}
/>
</div>
{s.knowledgePoints.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{s.knowledgePoints.map((kp) => (
<span
key={kp}
className="rounded border border-rule px-2 py-0.5 text-xs text-ink-muted"
>
{kp}
</span>
))}
</div>
)}
</li>
);
})}
</ul>
);
}
export default PracticeSessionList;

View File

@@ -0,0 +1,116 @@
// PracticeStatsCard 组件单测
// 依据02-architecture-design.md §15 组件设计
// 覆盖4 项统计网格 / 数值渲染 / 正确率颜色区分success/warning/danger
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { PracticeStatsCard } from "./PracticeStatsCard";
import type { PracticeStats } from "@/types";
function makeStats(overrides: Partial<PracticeStats> = {}): PracticeStats {
return {
childId: "student-001",
totalSessions: 48,
completedSessions: 42,
totalQuestionsAnswered: 560,
overallAccuracy: 0.83,
...overrides,
};
}
describe("PracticeStatsCard 统计网格", () => {
it("渲染 4 项统计标签", () => {
render(<PracticeStatsCard stats={makeStats()} />);
expect(screen.getByText("总会话数")).toBeInTheDocument();
expect(screen.getByText("已完成会话")).toBeInTheDocument();
expect(screen.getByText("总答题数")).toBeInTheDocument();
expect(screen.getByText("正确率")).toBeInTheDocument();
});
it("渲染各项数值", () => {
render(<PracticeStatsCard stats={makeStats()} />);
expect(screen.getByText("48")).toBeInTheDocument();
expect(screen.getByText("42")).toBeInTheDocument();
expect(screen.getByText("560")).toBeInTheDocument();
// 0.83 → 83.0%
expect(screen.getByText(/83\.0%/)).toBeInTheDocument();
});
it("渲染统计 regionaria-label", () => {
render(<PracticeStatsCard stats={makeStats()} />);
expect(screen.getByLabelText("练习统计")).toBeInTheDocument();
});
});
describe("PracticeStatsCard 正确率颜色区分", () => {
it("正确率 >= 0.8 时使用 success 色border + text", () => {
const { container } = render(
<PracticeStatsCard stats={makeStats({ overallAccuracy: 0.85 })} />,
);
const successCards = container.querySelectorAll(".border-success");
const successTexts = container.querySelectorAll(".text-success");
expect(successCards.length).toBeGreaterThan(0);
expect(successTexts.length).toBeGreaterThan(0);
});
it("正确率 = 0.8 时使用 success 色(边界)", () => {
const { container } = render(
<PracticeStatsCard stats={makeStats({ overallAccuracy: 0.8 })} />,
);
const successCards = container.querySelectorAll(".border-success");
expect(successCards.length).toBeGreaterThan(0);
});
it("正确率在 0.6 - 0.8 之间使用 warning 色", () => {
const { container } = render(
<PracticeStatsCard stats={makeStats({ overallAccuracy: 0.7 })} />,
);
const warningCards = container.querySelectorAll(".border-warning");
const warningTexts = container.querySelectorAll(".text-warning");
expect(warningCards.length).toBeGreaterThan(0);
expect(warningTexts.length).toBeGreaterThan(0);
});
it("正确率 = 0.6 时使用 warning 色(边界)", () => {
const { container } = render(
<PracticeStatsCard stats={makeStats({ overallAccuracy: 0.6 })} />,
);
const warningCards = container.querySelectorAll(".border-warning");
expect(warningCards.length).toBeGreaterThan(0);
});
it("正确率 < 0.6 时使用 danger 色", () => {
const { container } = render(
<PracticeStatsCard stats={makeStats({ overallAccuracy: 0.45 })} />,
);
const dangerCards = container.querySelectorAll(".border-danger");
const dangerTexts = container.querySelectorAll(".text-danger");
expect(dangerCards.length).toBeGreaterThan(0);
expect(dangerTexts.length).toBeGreaterThan(0);
});
it("正确率 = 0 时使用 danger 色", () => {
const { container } = render(
<PracticeStatsCard stats={makeStats({ overallAccuracy: 0 })} />,
);
const dangerCards = container.querySelectorAll(".border-danger");
expect(dangerCards.length).toBeGreaterThan(0);
});
it("正确率 = 1 时使用 success 色", () => {
const { container } = render(
<PracticeStatsCard stats={makeStats({ overallAccuracy: 1 })} />,
);
const successCards = container.querySelectorAll(".border-success");
expect(successCards.length).toBeGreaterThan(0);
});
it("非正确率卡片不高亮(默认 border-rule", () => {
const { container } = render(
<PracticeStatsCard stats={makeStats({ overallAccuracy: 0.7 })} />,
);
// warning 卡片仅 1 个(正确率),其他 3 个为 default
const warningCards = container.querySelectorAll(".border-warning");
expect(warningCards.length).toBe(1);
});
});

View File

@@ -0,0 +1,82 @@
// PracticeStatsCard练习统计卡片
// 依据02-architecture-design.md §15 组件设计
// - 4 项统计网格(总会话数 / 已完成 / 总答题数 / 正确率%
// - 正确率按区间用语义色区分(>=80% success / 60-80% warning / <60% danger
"use client";
import type { PracticeStats } from "@/types";
import { cn, formatPercent, formatNumber } from "@/lib/utils";
interface PracticeStatsCardProps {
stats: PracticeStats;
}
interface StatItem {
label: string;
value: string;
tone: "default" | "success" | "warning" | "danger";
}
function accuracyTone(rate: number): "success" | "warning" | "danger" {
if (rate >= 0.8) return "success";
if (rate >= 0.6) return "warning";
return "danger";
}
export function PracticeStatsCard({ stats }: PracticeStatsCardProps) {
const accuracyToneValue = accuracyTone(stats.overallAccuracy);
const items: StatItem[] = [
{
label: "总会话数",
value: formatNumber(stats.totalSessions),
tone: "default",
},
{
label: "已完成会话",
value: formatNumber(stats.completedSessions),
tone: "default",
},
{
label: "总答题数",
value: formatNumber(stats.totalQuestionsAnswered),
tone: "default",
},
{
label: "正确率",
value: formatPercent(stats.overallAccuracy),
tone: accuracyToneValue,
},
];
return (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4" aria-label="练习统计">
{items.map((item) => (
<div
key={item.label}
className={cn(
"rounded border border-rule bg-paper-elevated p-4",
item.tone === "success" && "border-success",
item.tone === "warning" && "border-warning",
item.tone === "danger" && "border-danger",
)}
>
<p className="text-xs text-ink-muted">{item.label}</p>
<p
className={cn(
"mt-1 font-serif text-2xl",
item.tone === "success" && "text-success",
item.tone === "warning" && "text-warning",
item.tone === "danger" && "text-danger",
)}
>
{item.value}
</p>
</div>
))}
</div>
);
}
export default PracticeStatsCard;

View File

@@ -0,0 +1,232 @@
// ReportCardView 组件单测
// 依据02-architecture-design.md §15 组件设计
// 覆盖:头部信息 / 各科成绩表 / 总分与排名 / 教师评语 / 校长评语(可选)/ 打印按钮
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ReportCardView } from "./ReportCardView";
import type { ReportCard } from "@/types";
const mockReportCard: ReportCard = {
childId: "student-001",
childName: "张小明",
className: "初一(1)班",
academicYearId: "ay-2025-2026",
academicYearName: "2025-2026",
semester: 2,
subjects: [
{
subject: "语文",
score: 85,
maxScore: 100,
gradeLevel: "B",
classAverage: 75,
rank: 8,
classSize: 45,
teacherComment: "阅读理解能力较强,作文需加强立意深度",
},
{
subject: "数学",
score: 92,
maxScore: 100,
gradeLevel: "A",
classAverage: 78,
rank: 3,
classSize: 45,
teacherComment: "逻辑思维清晰,解题规范,继续保持",
},
{
subject: "英语",
score: 88,
maxScore: 100,
gradeLevel: "B",
classAverage: 72,
rank: 5,
classSize: 45,
teacherComment: "听力有进步,建议加强口语练习",
},
{
subject: "物理",
score: 95,
maxScore: 100,
gradeLevel: "A",
classAverage: 80,
rank: 2,
classSize: 45,
teacherComment: "实验探究能力突出,分析问题思路严谨",
},
],
overallScore: 90,
classRank: 5,
classSize: 45,
teacherComment: "本学期学习态度认真,成绩稳中有进,望继续保持,加强薄弱环节",
headTeacherComment: "该生品学兼优,关心集体,乐于助人,下学期可担任班干部锻炼能力",
issuedAt: "2026-07-05T10:00:00Z",
};
describe("ReportCardView 头部信息", () => {
it("渲染成绩报告单标题", () => {
render(<ReportCardView reportCard={mockReportCard} />);
expect(screen.getByText("成绩报告单")).toBeInTheDocument();
});
it("渲染学年与学期", () => {
render(<ReportCardView reportCard={mockReportCard} />);
expect(screen.getByText(/2025-2026 学年/)).toBeInTheDocument();
expect(screen.getByText(/第 2 学期/)).toBeInTheDocument();
});
it("渲染学生姓名", () => {
render(<ReportCardView reportCard={mockReportCard} />);
expect(screen.getByText("张小明")).toBeInTheDocument();
});
it("渲染班级", () => {
render(<ReportCardView reportCard={mockReportCard} />);
expect(screen.getByText("初一(1)班")).toBeInTheDocument();
});
it("渲染签发日期", () => {
render(<ReportCardView reportCard={mockReportCard} />);
expect(screen.getByText(/签发日期/)).toBeInTheDocument();
});
});
describe("ReportCardView 各科成绩表", () => {
it("渲染各科成绩 section", () => {
render(<ReportCardView reportCard={mockReportCard} />);
expect(screen.getByLabelText("各科成绩")).toBeInTheDocument();
});
it("渲染成绩明细表aria-label", () => {
render(<ReportCardView reportCard={mockReportCard} />);
expect(screen.getByLabelText("各科成绩明细表")).toBeInTheDocument();
});
it("渲染表头(科目/分数/满分/等级/班均/班级排名)", () => {
render(<ReportCardView reportCard={mockReportCard} />);
expect(screen.getByText("科目")).toBeInTheDocument();
expect(screen.getByText("分数")).toBeInTheDocument();
expect(screen.getByText("满分")).toBeInTheDocument();
expect(screen.getByText("等级")).toBeInTheDocument();
expect(screen.getByText("班均")).toBeInTheDocument();
// "班级排名" 同时出现在表头 th 和总分排名 section用 getAllByText
expect(screen.getAllByText("班级排名")).toHaveLength(2);
});
it("渲染 4 个学科行", () => {
render(<ReportCardView reportCard={mockReportCard} />);
expect(screen.getByText("语文")).toBeInTheDocument();
expect(screen.getByText("数学")).toBeInTheDocument();
expect(screen.getByText("英语")).toBeInTheDocument();
expect(screen.getByText("物理")).toBeInTheDocument();
});
it("渲染每科分数与等级", () => {
const { container } = render(<ReportCardView reportCard={mockReportCard} />);
// 通过表格单元格文本验证(表格中所有 td
const cells = container.querySelectorAll("td");
// 4 行 × 6 列 = 24 个 td
expect(cells).toHaveLength(24);
// 验证部分分数
expect(screen.getByText("85")).toBeInTheDocument();
expect(screen.getByText("92")).toBeInTheDocument();
});
it("渲染每科班级排名rank/classSize 格式)", () => {
render(<ReportCardView reportCard={mockReportCard} />);
// 4 个科目各自的 rank/classSize = 8/45, 3/45, 5/45, 2/45
expect(screen.getByText("8/45")).toBeInTheDocument();
expect(screen.getByText("3/45")).toBeInTheDocument();
expect(screen.getByText("5/45")).toBeInTheDocument();
expect(screen.getByText("2/45")).toBeInTheDocument();
});
});
describe("ReportCardView 总分与排名", () => {
it("渲染总分与排名 section", () => {
render(<ReportCardView reportCard={mockReportCard} />);
expect(screen.getByLabelText("总分与排名")).toBeInTheDocument();
});
it("渲染总分90", () => {
render(<ReportCardView reportCard={mockReportCard} />);
// 总分标签
expect(screen.getByText("总分")).toBeInTheDocument();
// 总分值90— 注意 90 也可能出现在其他地方(如 classAverage需要精确匹配
const overallScoreSection = screen.getByLabelText("总分与排名");
expect(overallScoreSection).toHaveTextContent("90");
});
it("渲染班级排名标签与数值5", () => {
render(<ReportCardView reportCard={mockReportCard} />);
// "班级排名" 同时出现在表头和总分排名 section
expect(screen.getAllByText("班级排名")).toHaveLength(2);
// 总分排名 section 中包含数值 5
const statsSection = screen.getByLabelText("总分与排名");
expect(statsSection).toHaveTextContent("5");
});
it("渲染班级人数45", () => {
render(<ReportCardView reportCard={mockReportCard} />);
expect(screen.getByText("班级人数")).toBeInTheDocument();
});
});
describe("ReportCardView 教师评语", () => {
it("渲染教师评语 section", () => {
render(<ReportCardView reportCard={mockReportCard} />);
expect(screen.getByLabelText("教师评语")).toBeInTheDocument();
});
it("渲染班主任评语", () => {
render(<ReportCardView reportCard={mockReportCard} />);
expect(screen.getByText("班主任评语")).toBeInTheDocument();
expect(
screen.getByText(
"本学期学习态度认真,成绩稳中有进,望继续保持,加强薄弱环节",
),
).toBeInTheDocument();
});
it("渲染校长评语(当存在时)", () => {
render(<ReportCardView reportCard={mockReportCard} />);
expect(screen.getByText("校长评语")).toBeInTheDocument();
expect(
screen.getByText(
"该生品学兼优,关心集体,乐于助人,下学期可担任班干部锻炼能力",
),
).toBeInTheDocument();
});
it("不渲染校长评语区块(当 headTeacherComment 缺失时)", () => {
const noHeadComment: ReportCard = {
...mockReportCard,
headTeacherComment: undefined,
};
render(<ReportCardView reportCard={noHeadComment} />);
expect(screen.queryByText("校长评语")).not.toBeInTheDocument();
});
});
describe("ReportCardView 打印按钮", () => {
it("渲染打印按钮", () => {
render(<ReportCardView reportCard={mockReportCard} />);
expect(
screen.getByRole("button", { name: "打印报告卡" }),
).toBeInTheDocument();
});
it("点击打印按钮调用 window.print", async () => {
const user = userEvent.setup();
const printSpy = vi.fn();
Object.defineProperty(window, "print", {
configurable: true,
value: printSpy,
});
render(<ReportCardView reportCard={mockReportCard} />);
await user.click(screen.getByRole("button", { name: "打印报告卡" }));
expect(printSpy).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,119 @@
// ReportCardView报告卡视图
// 依据02-architecture-design.md §15 组件设计
// - 学生信息 / 各科成绩表 / 总分 / 排名 / 教师评语 / 打印按钮
// - 打印样式:@media print 下隐藏打印按钮
"use client";
import type { ReportCard } from "@/types";
import { formatDate } from "@/lib/utils";
interface ReportCardViewProps {
reportCard: ReportCard;
}
export function ReportCardView({ reportCard }: ReportCardViewProps) {
return (
<article className="space-y-6 rounded border border-rule bg-paper-elevated p-6 print:border-0 print:p-0">
{/* 头部:学生信息 */}
<header className="space-y-1 text-center">
<h2 className="font-serif text-2xl"></h2>
<p className="text-sm text-ink-muted">
{reportCard.academicYearName} · {reportCard.semester}
</p>
<div className="mt-2 flex flex-wrap justify-center gap-x-6 gap-y-1 text-sm">
<span><span className="font-medium">{reportCard.childName}</span></span>
<span><span className="font-medium">{reportCard.className}</span></span>
<span>{formatDate(reportCard.issuedAt)}</span>
</div>
</header>
{/* 各科成绩表 */}
<section aria-label="各科成绩">
<h3 className="font-serif text-base"></h3>
<table
className="mt-3 w-full text-sm"
aria-label="各科成绩明细表"
>
<thead>
<tr className="border-b border-rule text-left text-xs text-ink-muted">
<th className="pb-2"></th>
<th className="pb-2 text-right"></th>
<th className="pb-2 text-right"></th>
<th className="pb-2 text-right"></th>
<th className="pb-2 text-right"></th>
<th className="pb-2 text-right"></th>
</tr>
</thead>
<tbody>
{reportCard.subjects.map((s) => (
<tr
key={s.subject}
className="border-b border-rule last:border-b-0"
>
<td className="py-2 font-medium">{s.subject}</td>
<td className="py-2 text-right font-mono">{s.score}</td>
<td className="py-2 text-right font-mono text-ink-muted">
{s.maxScore}
</td>
<td className="py-2 text-right font-medium">{s.gradeLevel}</td>
<td className="py-2 text-right font-mono text-ink-muted">
{s.classAverage}
</td>
<td className="py-2 text-right font-mono">
{s.rank}/{s.classSize}
</td>
</tr>
))}
</tbody>
</table>
</section>
{/* 总分与排名 */}
<section
aria-label="总分与排名"
className="grid grid-cols-2 gap-3 sm:grid-cols-3"
>
<div className="rounded border border-rule p-3 text-center">
<div className="font-mono text-xl">{reportCard.overallScore}</div>
<div className="text-xs text-ink-muted"></div>
</div>
<div className="rounded border border-rule p-3 text-center">
<div className="font-mono text-xl">{reportCard.classRank}</div>
<div className="text-xs text-ink-muted"></div>
</div>
<div className="rounded border border-rule p-3 text-center">
<div className="font-mono text-xl">{reportCard.classSize}</div>
<div className="text-xs text-ink-muted"></div>
</div>
</section>
{/* 教师评语 */}
<section aria-label="教师评语" className="space-y-3">
<div className="rounded border border-rule p-4">
<h3 className="font-serif text-sm"></h3>
<p className="mt-2 text-sm">{reportCard.teacherComment}</p>
</div>
{reportCard.headTeacherComment && (
<div className="rounded border border-rule p-4">
<h3 className="font-serif text-sm"></h3>
<p className="mt-2 text-sm">{reportCard.headTeacherComment}</p>
</div>
)}
</section>
{/* 打印按钮(打印时隐藏) */}
<div className="print:hidden">
<button
type="button"
onClick={() => window.print()}
className="rounded border border-accent px-4 py-2 text-sm font-medium text-accent transition-colors hover:bg-accent-soft"
>
</button>
</div>
</article>
);
}
export default ReportCardView;

View File

@@ -0,0 +1,158 @@
// TopWrongQuestionList 组件单测
// 依据02-architecture-design.md §15 组件设计
// 覆盖:空态 / 列表渲染 / 题干截断 / 学科标签 / 错误次数 / 掌握率 / 进度条 / 最后错误时间
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { TopWrongQuestionList } from "./TopWrongQuestionList";
import type { TopWrongQuestion } from "@/types";
function makeQuestions(overrides: Partial<TopWrongQuestion>[] = []): TopWrongQuestion[] {
const base: TopWrongQuestion[] = [
{
id: "wq-001",
questionContent: "已知一元二次方程 x² - 5x + 6 = 0求方程的解。",
subject: "数学",
knowledgePoint: "一元二次方程",
errorCount: 5,
lastErrorAt: "2026-07-08T15:20:00Z",
masteryRate: 0.4,
},
{
id: "wq-002",
questionContent: "下列句子中,加点词语使用正确的一项是( ",
subject: "语文",
knowledgePoint: "词语辨析",
errorCount: 4,
lastErrorAt: "2026-07-06T16:10:00Z",
masteryRate: 0.85,
},
];
if (overrides.length === 0) return base;
return base.map((q, i) => ({ ...q, ...overrides[i] }));
}
describe("TopWrongQuestionList 空态", () => {
it("空数组渲染暂无错题数据", () => {
render(<TopWrongQuestionList questions={[]} />);
expect(screen.getByText("暂无错题数据")).toBeInTheDocument();
});
});
describe("TopWrongQuestionList 列表渲染", () => {
it("渲染列表 regionaria-label", () => {
render(<TopWrongQuestionList questions={makeQuestions()} />);
expect(screen.getByLabelText("高频错题列表")).toBeInTheDocument();
});
it("渲染每条题干", () => {
render(<TopWrongQuestionList questions={makeQuestions()} />);
expect(
screen.getByText("已知一元二次方程 x² - 5x + 6 = 0求方程的解。"),
).toBeInTheDocument();
expect(
screen.getByText("下列句子中,加点词语使用正确的一项是( "),
).toBeInTheDocument();
});
it("渲染每条学科标签", () => {
render(<TopWrongQuestionList questions={makeQuestions()} />);
expect(screen.getByText("数学")).toBeInTheDocument();
expect(screen.getByText("语文")).toBeInTheDocument();
});
it("渲染每条知识点", () => {
render(<TopWrongQuestionList questions={makeQuestions()} />);
expect(screen.getByText("一元二次方程")).toBeInTheDocument();
expect(screen.getByText("词语辨析")).toBeInTheDocument();
});
it("渲染每条错误次数", () => {
render(<TopWrongQuestionList questions={makeQuestions()} />);
expect(screen.getByText("错 5 次")).toBeInTheDocument();
expect(screen.getByText("错 4 次")).toBeInTheDocument();
});
it("渲染每条掌握率(百分比)", () => {
render(<TopWrongQuestionList questions={makeQuestions()} />);
// 0.4 → 40.0%0.85 → 85.0%
expect(screen.getByText(/40\.0%/)).toBeInTheDocument();
expect(screen.getByText(/85\.0%/)).toBeInTheDocument();
});
it("渲染每条最后错误时间", () => {
render(<TopWrongQuestionList questions={makeQuestions()} />);
expect(screen.getAllByText(/最后错误:/)).toHaveLength(2);
});
});
describe("TopWrongQuestionList 题干截断", () => {
it("题干超过 80 字符时截断并加省略号", () => {
const longContent = "a".repeat(100);
const questions: TopWrongQuestion[] = [
{
id: "wq-long",
questionContent: longContent,
subject: "数学",
knowledgePoint: "测试",
errorCount: 1,
lastErrorAt: "2026-07-08T15:20:00Z",
masteryRate: 0.5,
},
];
render(<TopWrongQuestionList questions={questions} />);
const expected = `${"a".repeat(80)}`;
expect(screen.getByText(expected)).toBeInTheDocument();
});
it("题干等于 80 字符时不截断", () => {
const exactContent = "a".repeat(80);
const questions: TopWrongQuestion[] = [
{
id: "wq-exact",
questionContent: exactContent,
subject: "数学",
knowledgePoint: "测试",
errorCount: 1,
lastErrorAt: "2026-07-08T15:20:00Z",
masteryRate: 0.5,
},
];
render(<TopWrongQuestionList questions={questions} />);
expect(screen.getByText(exactContent)).toBeInTheDocument();
});
});
describe("TopWrongQuestionList 掌握率进度条", () => {
it("每条记录渲染 progressbar 角色", () => {
render(<TopWrongQuestionList questions={makeQuestions()} />);
const bars = screen.getAllByRole("progressbar");
expect(bars).toHaveLength(2);
});
it("掌握率 < 0.6 时进度条用 danger 色isWeak", () => {
const { container } = render(
<TopWrongQuestionList questions={makeQuestions()} />,
);
const dangerBars = container.querySelectorAll(".bg-danger");
// masteryRate=0.4 < 0.6,有 1 个 danger 进度条
expect(dangerBars.length).toBeGreaterThanOrEqual(1);
});
it("掌握率 >= 0.6 时进度条用 success 色", () => {
const { container } = render(
<TopWrongQuestionList questions={makeQuestions()} />,
);
const successBars = container.querySelectorAll(".bg-success");
// masteryRate=0.85 >= 0.6,有 1 个 success 进度条
expect(successBars.length).toBeGreaterThanOrEqual(1);
});
it("progressbar aria-valuenow 反映掌握率百分比", () => {
render(<TopWrongQuestionList questions={makeQuestions()} />);
const bars = screen.getAllByRole("progressbar");
// 0.4 → 400.85 → 85
expect(bars[0]).toHaveAttribute("aria-valuenow", "40");
expect(bars[1]).toHaveAttribute("aria-valuenow", "85");
});
});

View File

@@ -0,0 +1,92 @@
// TopWrongQuestionListTop 错题列表
// 依据02-architecture-design.md §15 组件设计
// 每项显示题干(截断)/ 学科标签 / 知识点 / 错误次数 / 掌握率 / 最后错误时间
"use client";
import type { TopWrongQuestion } from "@/types";
import { cn, formatPercent, formatDateTime } from "@/lib/utils";
interface TopWrongQuestionListProps {
questions: TopWrongQuestion[];
}
const QUESTION_CONTENT_MAX_LENGTH = 80;
function truncate(content: string, max: number): string {
if (content.length <= max) return content;
return `${content.slice(0, max)}`;
}
export function TopWrongQuestionList({ questions }: TopWrongQuestionListProps) {
if (questions.length === 0) {
return (
<p className="rounded border border-rule bg-paper-elevated p-4 text-sm text-ink-muted">
</p>
);
}
return (
<ul className="space-y-3" aria-label="高频错题列表">
{questions.map((q) => {
const masteryPercent = Math.round(q.masteryRate * 100);
const isWeak = q.masteryRate < 0.6;
return (
<li
key={q.id}
className="rounded border border-rule bg-paper-elevated p-4"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<p className="text-sm">{truncate(q.questionContent, QUESTION_CONTENT_MAX_LENGTH)}</p>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-ink-muted">
<span className="rounded bg-accent-soft px-2 py-0.5 text-ink">
{q.subject}
</span>
<span>{q.knowledgePoint}</span>
</div>
</div>
<div className="flex shrink-0 flex-col items-end text-right">
<span className="font-mono text-sm text-danger">
{q.errorCount}
</span>
<span
className={cn(
"mt-1 font-mono text-sm",
isWeak ? "text-danger" : "text-success",
)}
>
{formatPercent(q.masteryRate)}
</span>
</div>
</div>
<div className="mt-3 flex items-center justify-between gap-3">
<div
className="h-1.5 flex-1 overflow-hidden rounded bg-rule"
role="progressbar"
aria-valuenow={masteryPercent}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${q.subject}错题掌握率`}
>
<div
className={cn(
"h-full rounded",
isWeak ? "bg-danger" : "bg-success",
)}
style={{ width: `${masteryPercent}%` }}
/>
</div>
<span className="shrink-0 text-xs text-ink-muted">
{formatDateTime(q.lastErrorAt)}
</span>
</div>
</li>
);
})}
</ul>
);
}
export default TopWrongQuestionList;

View File

@@ -0,0 +1,84 @@
// useAcademicYears Hook 单测
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 覆盖:加载态 / 成功返回 / 错误态
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
} from "vitest";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useAcademicYears } from "./useAcademicYears";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseAcademicYears() {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(() => useAcademicYears(), { wrapper });
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
describe("useAcademicYears", () => {
it("加载中 loading 为 true", () => {
const { result } = renderUseAcademicYears();
expect(result.current.loading).toBe(true);
expect(result.current.academicYears).toEqual([]);
});
it("加载成功返回学年列表", async () => {
const { result } = renderUseAcademicYears();
await waitFor(() => {
expect(result.current.academicYears.length).toBeGreaterThan(0);
});
expect(result.current.academicYears).toHaveLength(2);
expect(result.current.academicYears[0]!.id).toBe("ay-2025-2026");
expect(result.current.academicYears[0]!.name).toBe("2025-2026");
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("GraphQL 返回错误时透传 error", async () => {
server.resetHandlers(
graphql.query("AcademicYears", () =>
HttpResponse.json(
{ errors: [{ message: "学年查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseAcademicYears();
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.academicYears).toEqual([]);
expect(result.current.error?.message).toContain("学年查询失败");
});
});

View File

@@ -0,0 +1,28 @@
// useAcademicYears获取学年列表
// 依据02-architecture-design.md §4.2 GraphQL 接入
"use client";
import { useQuery, type CombinedError } from "urql";
import { ACADEMIC_YEARS } from "@/lib/graphql/operations";
import type { AcademicYear } from "@/types";
interface AcademicYearsResponse {
academicYears: AcademicYear[];
}
export function useAcademicYears(): {
academicYears: AcademicYear[];
loading: boolean;
error: CombinedError | undefined;
} {
const [result] = useQuery<AcademicYearsResponse>({
query: ACADEMIC_YEARS,
});
return {
academicYears: result.data?.academicYears ?? [],
loading: result.fetching,
error: result.error,
};
}

View File

@@ -1,10 +1,12 @@
// useChildAttendance获取子女考勤记录
// 依据02-architecture-design.md §4.2 GraphQL 接入
// - 支持可选 month 参数YYYY-MM 格式),默认当前月
// - 支持可选 childId 参数,默认使用 store 中的 currentChildId
"use client";
import { useMemo } from "react";
import { useQuery } from "urql";
import { useQuery, type CombinedError } from "urql";
import { CHILD_ATTENDANCE } from "@/lib/graphql/operations";
import type { AttendanceRecord } from "@/types";
import { useChildStore } from "@/store/child-store";
@@ -13,6 +15,11 @@ interface ChildAttendanceResponse {
childAttendance: AttendanceRecord[];
}
interface UseChildAttendanceOptions {
childId?: string;
month?: string; // YYYY-MM 格式
}
function getCurrentMonthRange(): { startDate: string; endDate: string } {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1);
@@ -23,14 +30,37 @@ function getCurrentMonthRange(): { startDate: string; endDate: string } {
};
}
export function useChildAttendance() {
function getMonthRange(month: string): { startDate: string; endDate: string } {
const [yearStr, monthStr] = month.split("-");
const year = parseInt(yearStr ?? "0", 10);
const monthIdx = parseInt(monthStr ?? "1", 10) - 1;
const start = new Date(year, monthIdx, 1);
const end = new Date(year, monthIdx + 1, 0);
return {
startDate: start.toISOString().slice(0, 10),
endDate: end.toISOString().slice(0, 10),
};
}
export function useChildAttendance(
options?: UseChildAttendanceOptions,
): {
attendance: AttendanceRecord[];
loading: boolean;
error: CombinedError | undefined;
} {
const currentChildId = useChildStore((s) => s.currentChildId);
const { startDate, endDate } = useMemo(getCurrentMonthRange, []);
const targetChildId = options?.childId ?? currentChildId;
const { startDate, endDate } = useMemo(
() =>
options?.month ? getMonthRange(options.month) : getCurrentMonthRange(),
[options?.month],
);
const [result] = useQuery<ChildAttendanceResponse>({
query: CHILD_ATTENDANCE,
variables: { childId: currentChildId, startDate, endDate },
pause: !currentChildId,
variables: { childId: targetChildId, startDate, endDate },
pause: !targetChildId,
});
return {

View File

@@ -0,0 +1,131 @@
// useChildCoursePlanDetail Hook 单测
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 覆盖:无 currentChildId / 无 planId 暂停 / 加载态 / 成功返回详情(含章节)/ 错误态 / 详情为 null
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
beforeEach,
} from "vitest";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useChildStore } from "@/store/child-store";
import { useChildCoursePlanDetail } from "./useChildCoursePlanDetail";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseChildCoursePlanDetail(planId: string) {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(() => useChildCoursePlanDetail(planId), { wrapper });
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
beforeEach(() => {
localStorage.clear();
useChildStore.setState({
children: [],
currentChildId: null,
isLoading: false,
});
});
describe("useChildCoursePlanDetail", () => {
it("无 currentChildId 时暂停查询,返回 null", () => {
const { result } = renderUseChildCoursePlanDetail("cp-001");
expect(result.current.coursePlan).toBeNull();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("无 planId 时暂停查询,返回 null", () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildCoursePlanDetail("");
expect(result.current.coursePlan).toBeNull();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("有 currentChildId 和 planId 时加载成功返回课程计划详情", async () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildCoursePlanDetail("cp-001");
await waitFor(() => {
expect(result.current.coursePlan).not.toBeNull();
});
expect(result.current.coursePlan?.id).toBe("cp-001");
expect(result.current.coursePlan?.title).toBe("初一数学下学期课程计划");
expect(result.current.coursePlan?.chapters).toHaveLength(4);
expect(result.current.coursePlan?.chapters[0]!.title).toBe(
"第一章 一元二次方程",
);
expect(result.current.coursePlan?.chapters[0]!.sortOrder).toBe(1);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("加载中 loading 为 true", () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildCoursePlanDetail("cp-001");
expect(result.current.loading).toBe(true);
});
it("GraphQL 返回错误时透传 error", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.resetHandlers(
graphql.query("ChildCoursePlanDetail", () =>
HttpResponse.json(
{ errors: [{ message: "课程计划详情查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseChildCoursePlanDetail("cp-001");
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.coursePlan).toBeNull();
expect(result.current.error?.message).toContain("课程计划详情查询失败");
});
it("详情为 null 时返回 null", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.resetHandlers(
graphql.query("ChildCoursePlanDetail", () =>
HttpResponse.json({ data: { childCoursePlanDetail: null } }),
),
);
const { result } = renderUseChildCoursePlanDetail("not-exist");
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.coursePlan).toBeNull();
});
});

View File

@@ -0,0 +1,34 @@
// useChildCoursePlanDetail获取子女课程计划详情含章节
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 对标 /parent/course-plans/[id] 页面(家长只读视角)
"use client";
import { useQuery, type CombinedError } from "urql";
import { CHILD_COURSE_PLAN_DETAIL } from "@/lib/graphql/operations";
import type { CoursePlanDetail } from "@/types";
import { useChildStore } from "@/store/child-store";
interface ChildCoursePlanDetailResponse {
childCoursePlanDetail: CoursePlanDetail;
}
export function useChildCoursePlanDetail(planId: string): {
coursePlan: CoursePlanDetail | null;
loading: boolean;
error: CombinedError | undefined;
} {
const currentChildId = useChildStore((s) => s.currentChildId);
const [result] = useQuery<ChildCoursePlanDetailResponse>({
query: CHILD_COURSE_PLAN_DETAIL,
variables: { childId: currentChildId, planId },
pause: !currentChildId || !planId,
});
return {
coursePlan: result.data?.childCoursePlanDetail ?? null,
loading: result.fetching,
error: result.error,
};
}

View File

@@ -0,0 +1,121 @@
// useChildCoursePlans Hook 单测
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 覆盖:无 currentChildId 暂停 / 加载态 / 成功返回 / 错误态 / 空列表
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
beforeEach,
} from "vitest";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useChildStore } from "@/store/child-store";
import { useChildCoursePlans } from "./useChildCoursePlans";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseChildCoursePlans() {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(() => useChildCoursePlans(), { wrapper });
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
beforeEach(() => {
localStorage.clear();
useChildStore.setState({
children: [],
currentChildId: null,
isLoading: false,
});
});
describe("useChildCoursePlans", () => {
it("无 currentChildId 时暂停查询,返回空数组", () => {
const { result } = renderUseChildCoursePlans();
expect(result.current.coursePlans).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("有 currentChildId 时加载成功返回课程计划列表", async () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildCoursePlans();
await waitFor(() => {
expect(result.current.coursePlans.length).toBeGreaterThan(0);
});
expect(result.current.coursePlans).toHaveLength(3);
expect(result.current.coursePlans[0]!.title).toBe("初一数学下学期课程计划");
expect(result.current.coursePlans[0]!.subject).toBe("数学");
expect(result.current.coursePlans[0]!.status).toBe("active");
expect(result.current.coursePlans[0]!.chapterCount).toBe(8);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("加载中 loading 为 true", () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildCoursePlans();
expect(result.current.loading).toBe(true);
});
it("GraphQL 返回错误时透传 error", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.resetHandlers(
graphql.query("ChildCoursePlans", () =>
HttpResponse.json(
{ errors: [{ message: "课程计划查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseChildCoursePlans();
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.coursePlans).toEqual([]);
expect(result.current.error?.message).toContain("课程计划查询失败");
});
it("返回空课程计划列表", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.resetHandlers(
graphql.query("ChildCoursePlans", () =>
HttpResponse.json({ data: { childCoursePlans: [] } }),
),
);
const { result } = renderUseChildCoursePlans();
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.coursePlans).toEqual([]);
});
});

View File

@@ -0,0 +1,34 @@
// useChildCoursePlans获取子女课程计划列表
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 对标 /parent/course-plans 页面(家长只读视角)
"use client";
import { useQuery, type CombinedError } from "urql";
import { CHILD_COURSE_PLANS } from "@/lib/graphql/operations";
import type { CoursePlan } from "@/types";
import { useChildStore } from "@/store/child-store";
interface ChildCoursePlansResponse {
childCoursePlans: CoursePlan[];
}
export function useChildCoursePlans(): {
coursePlans: CoursePlan[];
loading: boolean;
error: CombinedError | undefined;
} {
const currentChildId = useChildStore((s) => s.currentChildId);
const [result] = useQuery<ChildCoursePlansResponse>({
query: CHILD_COURSE_PLANS,
variables: { childId: currentChildId },
pause: !currentChildId,
});
return {
coursePlans: result.data?.childCoursePlans ?? [],
loading: result.fetching,
error: result.error,
};
}

View File

@@ -0,0 +1,144 @@
// useChildDetail Hook 单测
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 覆盖:无 childId 暂停 / 加载态 / 成功返回 / 变量传递 / 错误态
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
} from "vitest";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useChildDetail } from "./useChildDetail";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseChildDetail(childId: string) {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(() => useChildDetail(childId), { wrapper });
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
describe("useChildDetail", () => {
it("无 childId 时暂停查询,返回 null", () => {
const { result } = renderUseChildDetail("");
expect(result.current.childDetail).toBeNull();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("有 childId 时加载成功返回子女详情", async () => {
const { result } = renderUseChildDetail("student-001");
await waitFor(() => {
expect(result.current.childDetail).not.toBeNull();
});
expect(result.current.childDetail?.childId).toBe("student-001");
expect(result.current.childDetail?.basicInfo.name).toBe("张小明");
expect(result.current.childDetail?.basicInfo.className).toBe("初一(1)班");
expect(result.current.childDetail?.todaySchedule.length).toBeGreaterThan(0);
expect(result.current.childDetail?.weeklySchedule.length).toBeGreaterThan(0);
expect(result.current.childDetail?.homeworkSummary).toBeDefined();
expect(result.current.childDetail?.gradeSummary).toBeDefined();
expect(result.current.childDetail?.examResults).toBeDefined();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("加载中 loading 为 true", () => {
const { result } = renderUseChildDetail("student-001");
expect(result.current.loading).toBe(true);
});
it("传入的 childId 变量正确传递", async () => {
let capturedVars: unknown = null;
server.resetHandlers(
graphql.query("ChildDetail", ({ variables }) => {
capturedVars = variables;
return HttpResponse.json({
data: {
childDetail: {
childId: variables.childId,
basicInfo: {
name: "张小红",
grade: "grade.5",
className: "五年级(2)班",
schoolName: "实验小学",
relation: "父亲",
},
todaySchedule: [],
weeklySchedule: [],
homeworkSummary: {
pendingCount: 0,
overdueCount: 0,
submittedCount: 0,
gradedCount: 0,
},
gradeSummary: {
avgScore: 0,
classRank: 0,
classSize: 0,
trend: "stable",
},
examResults: {
upcoming: 0,
completed: 0,
avgScore: 0,
},
},
},
});
}),
);
const { result } = renderUseChildDetail("student-002");
await waitFor(() => {
expect(result.current.childDetail).not.toBeNull();
});
expect(capturedVars).toEqual({ childId: "student-002" });
expect(result.current.childDetail?.basicInfo.name).toBe("张小红");
});
it("GraphQL 返回错误时透传 error", async () => {
server.resetHandlers(
graphql.query("ChildDetail", () =>
HttpResponse.json(
{ errors: [{ message: "子女详情查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseChildDetail("student-001");
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.childDetail).toBeNull();
expect(result.current.error?.message).toContain("子女详情查询失败");
});
});

View File

@@ -0,0 +1,30 @@
// useChildDetail获取子女详情聚合数据
// 依据02-architecture-design.md §4.2 GraphQL 接入
"use client";
import { useQuery, type CombinedError } from "urql";
import { CHILD_DETAIL } from "@/lib/graphql/operations";
import type { ChildDetail } from "@/types";
interface ChildDetailResponse {
childDetail: ChildDetail;
}
export function useChildDetail(childId: string): {
childDetail: ChildDetail | null;
loading: boolean;
error: CombinedError | undefined;
} {
const [result] = useQuery<ChildDetailResponse>({
query: CHILD_DETAIL,
variables: { childId },
pause: !childId,
});
return {
childDetail: result.data?.childDetail ?? null,
loading: result.fetching,
error: result.error,
};
}

View File

@@ -0,0 +1,149 @@
// useChildDiagnostic Hook 单测
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 覆盖:无 currentChildId 暂停 / 加载态 / 成功返回 / 错误态 / 两个查询合并
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
beforeEach,
} from "vitest";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useChildStore } from "@/store/child-store";
import { useChildDiagnostic } from "./useChildDiagnostic";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseChildDiagnostic() {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(() => useChildDiagnostic(), { wrapper });
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
beforeEach(() => {
localStorage.clear();
useChildStore.setState({
children: [],
currentChildId: null,
isLoading: false,
});
});
describe("useChildDiagnostic", () => {
it("无 currentChildId 时暂停查询,返回空数据", () => {
const { result } = renderUseChildDiagnostic();
expect(result.current.masterySummary).toBeNull();
expect(result.current.reports).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("有 currentChildId 时加载成功返回诊断数据", async () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildDiagnostic();
await waitFor(() => {
expect(result.current.masterySummary).not.toBeNull();
});
expect(result.current.masterySummary?.overallMastery).toBe(0.78);
expect(result.current.masterySummary?.totalKps).toBe(86);
expect(result.current.masterySummary?.masteredKps).toBe(67);
expect(result.current.masterySummary?.subjectMastery).toHaveLength(4);
expect(result.current.reports).toHaveLength(2);
expect(result.current.reports[0]!.title).toBe(
"2026 春季学期学情诊断报告",
);
expect(result.current.reports[0]!.status).toBe("published");
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("加载中 loading 为 true", () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildDiagnostic();
expect(result.current.loading).toBe(true);
});
it("ChildMasterySummary 返回错误时透传 error", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.use(
graphql.query("ChildMasterySummary", () =>
HttpResponse.json(
{ errors: [{ message: "掌握度查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseChildDiagnostic();
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.masterySummary).toBeNull();
expect(result.current.error?.message).toContain("掌握度查询失败");
});
it("ChildDiagnosticReports 返回错误时透传 error", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.use(
graphql.query("ChildDiagnosticReports", () =>
HttpResponse.json(
{ errors: [{ message: "诊断报告查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseChildDiagnostic();
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.reports).toEqual([]);
expect(result.current.error?.message).toContain("诊断报告查询失败");
});
it("两个查询返回空数据时返回空数组与 null masterySummary", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.use(
graphql.query("ChildMasterySummary", () =>
HttpResponse.json({ data: { childMasterySummary: null } }),
),
graphql.query("ChildDiagnosticReports", () =>
HttpResponse.json({ data: { childDiagnosticReports: [] } }),
),
);
const { result } = renderUseChildDiagnostic();
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.masterySummary).toBeNull();
expect(result.current.reports).toEqual([]);
});
});

View File

@@ -0,0 +1,49 @@
// useChildDiagnostic获取子女诊断报告数据掌握度摘要 + 诊断报告列表)
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 同时发起两个查询,合并为统一返回值
"use client";
import { useQuery, type CombinedError } from "urql";
import {
CHILD_MASTERY_SUMMARY,
CHILD_DIAGNOSTIC_REPORTS,
} from "@/lib/graphql/operations";
import type { MasterySummary, DiagnosticReport } from "@/types";
import { useChildStore } from "@/store/child-store";
interface ChildMasterySummaryResponse {
childMasterySummary: MasterySummary;
}
interface ChildDiagnosticReportsResponse {
childDiagnosticReports: DiagnosticReport[];
}
export function useChildDiagnostic(): {
masterySummary: MasterySummary | null;
reports: DiagnosticReport[];
loading: boolean;
error: CombinedError | undefined;
} {
const currentChildId = useChildStore((s) => s.currentChildId);
const [masteryResult] = useQuery<ChildMasterySummaryResponse>({
query: CHILD_MASTERY_SUMMARY,
variables: { childId: currentChildId },
pause: !currentChildId,
});
const [reportsResult] = useQuery<ChildDiagnosticReportsResponse>({
query: CHILD_DIAGNOSTIC_REPORTS,
variables: { childId: currentChildId },
pause: !currentChildId,
});
return {
masterySummary: masteryResult.data?.childMasterySummary ?? null,
reports: reportsResult.data?.childDiagnosticReports ?? [],
loading: masteryResult.fetching || reportsResult.fetching,
error: masteryResult.error ?? reportsResult.error,
};
}

View File

@@ -0,0 +1,120 @@
// useChildElective Hook 单测
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 覆盖:无 currentChildId 暂停 / 加载态 / 成功返回 / 错误态 / 空列表
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
beforeEach,
} from "vitest";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useChildStore } from "@/store/child-store";
import { useChildElective } from "./useChildElective";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseChildElective() {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(() => useChildElective(), { wrapper });
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
beforeEach(() => {
localStorage.clear();
useChildStore.setState({
children: [],
currentChildId: null,
isLoading: false,
});
});
describe("useChildElective", () => {
it("无 currentChildId 时暂停查询,返回空数组", () => {
const { result } = renderUseChildElective();
expect(result.current.electiveSelections).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("有 currentChildId 时加载成功返回选修课列表", async () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildElective();
await waitFor(() => {
expect(result.current.electiveSelections.length).toBeGreaterThan(0);
});
expect(result.current.electiveSelections).toHaveLength(3);
expect(result.current.electiveSelections[0]!.courseName).toBe("创意写作");
expect(result.current.electiveSelections[0]!.category).toBe("language");
expect(result.current.electiveSelections[0]!.status).toBe("enrolled");
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("加载中 loading 为 true", () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildElective();
expect(result.current.loading).toBe(true);
});
it("GraphQL 返回错误时透传 error", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.resetHandlers(
graphql.query("ChildElective", () =>
HttpResponse.json(
{ errors: [{ message: "选修课查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseChildElective();
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.electiveSelections).toEqual([]);
expect(result.current.error?.message).toContain("选修课查询失败");
});
it("返回空选修课列表", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.resetHandlers(
graphql.query("ChildElective", () =>
HttpResponse.json({ data: { childElective: [] } }),
),
);
const { result } = renderUseChildElective();
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.electiveSelections).toEqual([]);
});
});

View File

@@ -0,0 +1,34 @@
// useChildElective获取子女选修课列表
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 对标 /parent/elective 页面(家长只读视角)
"use client";
import { useQuery, type CombinedError } from "urql";
import { CHILD_ELECTIVE } from "@/lib/graphql/operations";
import type { ElectiveSelection } from "@/types";
import { useChildStore } from "@/store/child-store";
interface ChildElectiveResponse {
childElective: ElectiveSelection[];
}
export function useChildElective(): {
electiveSelections: ElectiveSelection[];
loading: boolean;
error: CombinedError | undefined;
} {
const currentChildId = useChildStore((s) => s.currentChildId);
const [result] = useQuery<ChildElectiveResponse>({
query: CHILD_ELECTIVE,
variables: { childId: currentChildId },
pause: !currentChildId,
});
return {
electiveSelections: result.data?.childElective ?? [],
loading: result.fetching,
error: result.error,
};
}

View File

@@ -0,0 +1,174 @@
// useChildErrorBook Hook 单测
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 覆盖:无 currentChildId 暂停 / 加载态 / 成功返回 / 错误态 / 三个查询合并
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
beforeEach,
} from "vitest";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useChildStore } from "@/store/child-store";
import { useChildErrorBook } from "./useChildErrorBook";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseChildErrorBook() {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(() => useChildErrorBook(), { wrapper });
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
beforeEach(() => {
localStorage.clear();
useChildStore.setState({
children: [],
currentChildId: null,
isLoading: false,
});
});
describe("useChildErrorBook", () => {
it("无 currentChildId 时暂停查询,返回空数据", () => {
const { result } = renderUseChildErrorBook();
expect(result.current.stats).toBeNull();
expect(result.current.topWrongQuestions).toEqual([]);
expect(result.current.weakKps).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("有 currentChildId 时加载成功返回错题本数据", async () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildErrorBook();
await waitFor(() => {
expect(result.current.stats).not.toBeNull();
});
expect(result.current.stats?.totalCount).toBe(128);
expect(result.current.stats?.newCount).toBe(23);
expect(result.current.stats?.learningCount).toBe(45);
expect(result.current.stats?.masteredCount).toBe(60);
expect(result.current.stats?.dueReviewCount).toBe(18);
expect(result.current.stats?.masteredRate).toBe(0.469);
expect(result.current.topWrongQuestions).toHaveLength(4);
expect(result.current.topWrongQuestions[0]!.subject).toBe("数学");
expect(result.current.weakKps).toHaveLength(4);
expect(result.current.weakKps[0]!.knowledgePointName).toBe("一元二次方程");
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("加载中 loading 为 true", () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildErrorBook();
expect(result.current.loading).toBe(true);
});
it("ChildErrorBookStats 返回错误时透传 error", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.use(
graphql.query("ChildErrorBookStats", () =>
HttpResponse.json(
{ errors: [{ message: "错题统计查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseChildErrorBook();
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.stats).toBeNull();
expect(result.current.error?.message).toContain("错题统计查询失败");
});
it("ChildTopWrongQuestions 返回错误时透传 error", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.use(
graphql.query("ChildTopWrongQuestions", () =>
HttpResponse.json(
{ errors: [{ message: "高频错题查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseChildErrorBook();
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.topWrongQuestions).toEqual([]);
expect(result.current.error?.message).toContain("高频错题查询失败");
});
it("ChildWeakKps 返回错误时透传 error", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.use(
graphql.query("ChildWeakKps", () =>
HttpResponse.json(
{ errors: [{ message: "薄弱知识点查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseChildErrorBook();
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.weakKps).toEqual([]);
expect(result.current.error?.message).toContain("薄弱知识点查询失败");
});
it("三个查询返回空数据时返回空数组与 null stats", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.use(
graphql.query("ChildErrorBookStats", () =>
HttpResponse.json({ data: { childErrorBookStats: null } }),
),
graphql.query("ChildTopWrongQuestions", () =>
HttpResponse.json({ data: { childTopWrongQuestions: [] } }),
),
graphql.query("ChildWeakKps", () =>
HttpResponse.json({ data: { childWeakKps: [] } }),
),
);
const { result } = renderUseChildErrorBook();
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.stats).toBeNull();
expect(result.current.topWrongQuestions).toEqual([]);
expect(result.current.weakKps).toEqual([]);
});
});

View File

@@ -0,0 +1,65 @@
// useChildErrorBook获取子女错题本数据统计 + Top 错题 + 薄弱知识点)
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 同时发起三个查询,合并为统一返回值
"use client";
import { useQuery, type CombinedError } from "urql";
import {
CHILD_ERROR_BOOK_STATS,
CHILD_TOP_WRONG_QUESTIONS,
CHILD_WEAK_KPS,
} from "@/lib/graphql/operations";
import type { ErrorBookStats, TopWrongQuestion, WeakKp } from "@/types";
import { useChildStore } from "@/store/child-store";
interface ChildErrorBookStatsResponse {
childErrorBookStats: ErrorBookStats;
}
interface ChildTopWrongQuestionsResponse {
childTopWrongQuestions: TopWrongQuestion[];
}
interface ChildWeakKpsResponse {
childWeakKps: WeakKp[];
}
export function useChildErrorBook(): {
stats: ErrorBookStats | null;
topWrongQuestions: TopWrongQuestion[];
weakKps: WeakKp[];
loading: boolean;
error: CombinedError | undefined;
} {
const currentChildId = useChildStore((s) => s.currentChildId);
const [statsResult] = useQuery<ChildErrorBookStatsResponse>({
query: CHILD_ERROR_BOOK_STATS,
variables: { childId: currentChildId },
pause: !currentChildId,
});
const [topWrongResult] = useQuery<ChildTopWrongQuestionsResponse>({
query: CHILD_TOP_WRONG_QUESTIONS,
variables: { childId: currentChildId, limit: 10 },
pause: !currentChildId,
});
const [weakKpsResult] = useQuery<ChildWeakKpsResponse>({
query: CHILD_WEAK_KPS,
variables: { childId: currentChildId, limit: 10 },
pause: !currentChildId,
});
return {
stats: statsResult.data?.childErrorBookStats ?? null,
topWrongQuestions: topWrongResult.data?.childTopWrongQuestions ?? [],
weakKps: weakKpsResult.data?.childWeakKps ?? [],
loading:
statsResult.fetching ||
topWrongResult.fetching ||
weakKpsResult.fetching,
error: statsResult.error ?? topWrongResult.error ?? weakKpsResult.error,
};
}

View File

@@ -0,0 +1,206 @@
// useChildGrowthArchive Hook 单测
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 覆盖:无 currentChildId 暂停 / 加载态 / 成功返回 / subject 过滤 / 错误态 / 可选 childId
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
beforeEach,
} from "vitest";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useChildStore } from "@/store/child-store";
import { useChildGrowthArchive } from "./useChildGrowthArchive";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseChildGrowthArchive(options?: {
childId?: string;
subject?: string;
}) {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(() => useChildGrowthArchive(options), { wrapper });
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
beforeEach(() => {
localStorage.clear();
useChildStore.setState({
children: [],
currentChildId: null,
isLoading: false,
});
});
describe("useChildGrowthArchive", () => {
it("无 currentChildId 时暂停查询,返回 null", () => {
const { result } = renderUseChildGrowthArchive();
expect(result.current.growthArchive).toBeNull();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("有 currentChildId 时加载成功返回成长档案", async () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildGrowthArchive();
await waitFor(() => {
expect(result.current.growthArchive).not.toBeNull();
});
expect(result.current.growthArchive?.childId).toBe("student-001");
expect(result.current.growthArchive?.subject).toBe("数学");
expect(result.current.growthArchive?.dataPoints).toHaveLength(4);
expect(result.current.growthArchive?.dataPoints[0]?.studentScore).toBe(82);
expect(result.current.growthArchive?.dataPoints[0]?.classAverage).toBe(76);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("加载中 loading 为 true", () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildGrowthArchive();
expect(result.current.loading).toBe(true);
});
it("传入 subject 时变量携带 subject", async () => {
useChildStore.setState({ currentChildId: "student-001" });
let capturedVars: unknown = null;
server.resetHandlers(
graphql.query("ChildGrowthArchive", ({ variables }) => {
capturedVars = variables;
return HttpResponse.json({
data: {
childGrowthArchive: {
childId: "student-001",
subject: "数学",
dataPoints: [],
},
},
});
}),
);
const { result } = renderUseChildGrowthArchive({ subject: "数学" });
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(capturedVars).toEqual({
childId: "student-001",
subject: "数学",
});
});
it("未传 subject 时变量 subject 为 null", async () => {
useChildStore.setState({ currentChildId: "student-001" });
let capturedVars: unknown = null;
server.resetHandlers(
graphql.query("ChildGrowthArchive", ({ variables }) => {
capturedVars = variables;
return HttpResponse.json({
data: {
childGrowthArchive: {
childId: "student-001",
subject: "数学",
dataPoints: [],
},
},
});
}),
);
const { result } = renderUseChildGrowthArchive();
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(capturedVars).toEqual({
childId: "student-001",
subject: null,
});
});
it("传入 childId 覆盖 store 中的 currentChildId", async () => {
useChildStore.setState({ currentChildId: "student-001" });
let capturedVars: unknown = null;
server.resetHandlers(
graphql.query("ChildGrowthArchive", ({ variables }) => {
capturedVars = variables;
return HttpResponse.json({
data: {
childGrowthArchive: {
childId: variables.childId as string,
subject: "数学",
dataPoints: [],
},
},
});
}),
);
const { result } = renderUseChildGrowthArchive({ childId: "student-002" });
await waitFor(() => {
expect(result.current.growthArchive?.childId).toBe("student-002");
});
const vars = capturedVars as { childId: string };
expect(vars.childId).toBe("student-002");
});
it("GraphQL 返回错误时透传 error", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.resetHandlers(
graphql.query("ChildGrowthArchive", () =>
HttpResponse.json(
{ errors: [{ message: "成长档案查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseChildGrowthArchive();
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.growthArchive).toBeNull();
expect(result.current.error?.message).toContain("成长档案查询失败");
});
it("查询返回空数据时 growthArchive 为 null", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.resetHandlers(
graphql.query("ChildGrowthArchive", () =>
HttpResponse.json({ data: { childGrowthArchive: null } }),
),
);
const { result } = renderUseChildGrowthArchive();
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.growthArchive).toBeNull();
});
});

View File

@@ -0,0 +1,47 @@
// useChildGrowthArchive获取子女成长档案班级均对比线
// 依据02-architecture-design.md §4.2 GraphQL 接入
// - 依赖 currentChildId从 Zustand store 读取)
// - 支持可选 childId 参数(多子女场景)
// - 支持可选 subject 参数(按学科筛选)
"use client";
import { useQuery, type CombinedError } from "urql";
import { CHILD_GROWTH_ARCHIVE } from "@/lib/graphql/operations";
import type { GrowthArchive } from "@/types";
import { useChildStore } from "@/store/child-store";
interface ChildGrowthArchiveResponse {
childGrowthArchive: GrowthArchive;
}
interface UseChildGrowthArchiveOptions {
childId?: string;
subject?: string;
}
export function useChildGrowthArchive(
options?: UseChildGrowthArchiveOptions,
): {
growthArchive: GrowthArchive | null;
loading: boolean;
error: CombinedError | undefined;
} {
const currentChildId = useChildStore((s) => s.currentChildId);
const targetChildId = options?.childId ?? currentChildId;
const [result] = useQuery<ChildGrowthArchiveResponse>({
query: CHILD_GROWTH_ARCHIVE,
variables: {
childId: targetChildId,
subject: options?.subject ?? null,
},
pause: !targetChildId,
});
return {
growthArchive: result.data?.childGrowthArchive ?? null,
loading: result.fetching,
error: result.error,
};
}

View File

@@ -0,0 +1,142 @@
// useChildLeaveRequests Hook 单测
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 覆盖:无 currentChildId 暂停 / 加载态 / 成功返回 / refetch / 错误态
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
beforeEach,
} from "vitest";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useChildStore } from "@/store/child-store";
import { useChildLeaveRequests } from "./useChildLeaveRequests";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseChildLeaveRequests() {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(() => useChildLeaveRequests(), { wrapper });
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
beforeEach(() => {
localStorage.clear();
useChildStore.setState({
children: [],
currentChildId: null,
isLoading: false,
});
});
describe("useChildLeaveRequests", () => {
it("无 currentChildId 时暂停查询,返回空数组", () => {
const { result } = renderUseChildLeaveRequests();
expect(result.current.leaveRequests).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("有 currentChildId 时加载成功返回请假列表", async () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildLeaveRequests();
await waitFor(() => {
expect(result.current.leaveRequests.length).toBeGreaterThan(0);
});
// mock 中 student-001 有 2 条请假记录
expect(result.current.leaveRequests).toHaveLength(2);
expect(result.current.leaveRequests[0]!.type).toBe("sick");
expect(result.current.leaveRequests[0]!.status).toBe("approved");
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("加载中 loading 为 true", () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildLeaveRequests();
expect(result.current.loading).toBe(true);
});
it("refetch 触发重新查询", async () => {
useChildStore.setState({ currentChildId: "student-001" });
let queryCount = 0;
server.resetHandlers(
graphql.query("ChildLeaveRequests", () => {
queryCount += 1;
return HttpResponse.json({
data: {
childLeaveRequests: [
{
id: `leave-${queryCount}`,
childId: "student-001",
type: "sick",
startDate: "2026-05-10",
endDate: "2026-05-11",
reason: "感冒发烧需要休息",
status: "pending",
submittedAt: "2026-05-09T20:00:00Z",
},
],
},
});
}),
);
const { result } = renderUseChildLeaveRequests();
await waitFor(() => {
expect(result.current.leaveRequests).toHaveLength(1);
});
expect(queryCount).toBe(1);
result.current.refetch();
await waitFor(() => {
expect(queryCount).toBeGreaterThanOrEqual(2);
});
});
it("GraphQL 返回错误时透传 error", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.resetHandlers(
graphql.query("ChildLeaveRequests", () =>
HttpResponse.json(
{ errors: [{ message: "请假查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseChildLeaveRequests();
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.leaveRequests).toEqual([]);
expect(result.current.error?.message).toContain("请假查询失败");
});
});

View File

@@ -0,0 +1,35 @@
// useChildLeaveRequests获取子女请假记录列表
// 依据02-architecture-design.md §4.2 GraphQL 接入
"use client";
import { useQuery, type CombinedError } from "urql";
import { CHILD_LEAVE_REQUESTS } from "@/lib/graphql/operations";
import type { LeaveRequest } from "@/types";
import { useChildStore } from "@/store/child-store";
interface ChildLeaveRequestsResponse {
childLeaveRequests: LeaveRequest[];
}
export function useChildLeaveRequests(): {
leaveRequests: LeaveRequest[];
loading: boolean;
error: CombinedError | undefined;
refetch: () => void;
} {
const currentChildId = useChildStore((s) => s.currentChildId);
const [result, reexecuteQuery] = useQuery<ChildLeaveRequestsResponse>({
query: CHILD_LEAVE_REQUESTS,
variables: { childId: currentChildId },
pause: !currentChildId,
});
return {
leaveRequests: result.data?.childLeaveRequests ?? [],
loading: result.fetching,
error: result.error,
refetch: () => reexecuteQuery({ requestPolicy: "network-only" }),
};
}

View File

@@ -0,0 +1,133 @@
// useChildLessonPlanDetail Hook 单测
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 覆盖:无 currentChildId / 无 planId 暂停 / 加载态 / 成功返回详情 / 错误态 / 详情为 null
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
beforeEach,
} from "vitest";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useChildStore } from "@/store/child-store";
import { useChildLessonPlanDetail } from "./useChildLessonPlanDetail";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseChildLessonPlanDetail(planId: string) {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(() => useChildLessonPlanDetail(planId), { wrapper });
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
beforeEach(() => {
localStorage.clear();
useChildStore.setState({
children: [],
currentChildId: null,
isLoading: false,
});
});
describe("useChildLessonPlanDetail", () => {
it("无 currentChildId 时暂停查询,返回 null", () => {
const { result } = renderUseChildLessonPlanDetail("lp-002");
expect(result.current.lessonPlan).toBeNull();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("无 planId 时暂停查询,返回 null", () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildLessonPlanDetail("");
expect(result.current.lessonPlan).toBeNull();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("有 currentChildId 和 planId 时加载成功返回备课详情", async () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildLessonPlanDetail("lp-002");
await waitFor(() => {
expect(result.current.lessonPlan).not.toBeNull();
});
expect(result.current.lessonPlan?.id).toBe("lp-002");
expect(result.current.lessonPlan?.title).toBe("配方法解一元二次方程");
expect(result.current.lessonPlan?.objectives).toHaveLength(3);
expect(result.current.lessonPlan?.keyPoints).toHaveLength(2);
expect(result.current.lessonPlan?.difficultPoints).toHaveLength(2);
expect(result.current.lessonPlan?.content).toContain("配方法");
expect(result.current.lessonPlan?.homeworkDescription).toContain(
"课本第 25 页",
);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("加载中 loading 为 true", () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildLessonPlanDetail("lp-002");
expect(result.current.loading).toBe(true);
});
it("GraphQL 返回错误时透传 error", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.resetHandlers(
graphql.query("ChildLessonPlanDetail", () =>
HttpResponse.json(
{ errors: [{ message: "备课详情查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseChildLessonPlanDetail("lp-002");
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.lessonPlan).toBeNull();
expect(result.current.error?.message).toContain("备课详情查询失败");
});
it("详情为 null 时返回 null", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.resetHandlers(
graphql.query("ChildLessonPlanDetail", () =>
HttpResponse.json({ data: { childLessonPlanDetail: null } }),
),
);
const { result } = renderUseChildLessonPlanDetail("not-exist");
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.lessonPlan).toBeNull();
});
});

View File

@@ -0,0 +1,34 @@
// useChildLessonPlanDetail获取子女备课详情只读
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 对标 /parent/lesson-plans/[planId]/view 页面(家长只读视角)
"use client";
import { useQuery, type CombinedError } from "urql";
import { CHILD_LESSON_PLAN_DETAIL } from "@/lib/graphql/operations";
import type { LessonPlanDetail } from "@/types";
import { useChildStore } from "@/store/child-store";
interface ChildLessonPlanDetailResponse {
childLessonPlanDetail: LessonPlanDetail;
}
export function useChildLessonPlanDetail(planId: string): {
lessonPlan: LessonPlanDetail | null;
loading: boolean;
error: CombinedError | undefined;
} {
const currentChildId = useChildStore((s) => s.currentChildId);
const [result] = useQuery<ChildLessonPlanDetailResponse>({
query: CHILD_LESSON_PLAN_DETAIL,
variables: { childId: currentChildId, planId },
pause: !currentChildId || !planId,
});
return {
lessonPlan: result.data?.childLessonPlanDetail ?? null,
loading: result.fetching,
error: result.error,
};
}

View File

@@ -0,0 +1,163 @@
// useChildLessonPlans Hook 单测
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 覆盖:无 currentChildId 暂停 / 加载态 / 成功返回 / subject 过滤变量 / 错误态
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
beforeEach,
} from "vitest";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useChildStore } from "@/store/child-store";
import { useChildLessonPlans } from "./useChildLessonPlans";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseChildLessonPlans(subject?: string) {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(() => useChildLessonPlans(subject), { wrapper });
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
beforeEach(() => {
localStorage.clear();
useChildStore.setState({
children: [],
currentChildId: null,
isLoading: false,
});
});
describe("useChildLessonPlans", () => {
it("无 currentChildId 时暂停查询,返回空数组", () => {
const { result } = renderUseChildLessonPlans();
expect(result.current.lessonPlans).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("有 currentChildId 时加载成功返回备课列表", async () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildLessonPlans();
await waitFor(() => {
expect(result.current.lessonPlans.length).toBeGreaterThan(0);
});
expect(result.current.lessonPlans).toHaveLength(4);
expect(result.current.lessonPlans[0]!.title).toBe("一元二次方程的概念");
expect(result.current.lessonPlans[0]!.subject).toBe("数学");
expect(result.current.lessonPlans[0]!.status).toBe("published");
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("加载中 loading 为 true", () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildLessonPlans();
expect(result.current.loading).toBe(true);
});
it("传入 subject 时变量携带 subject", async () => {
useChildStore.setState({ currentChildId: "student-001" });
let capturedVars: unknown = null;
server.resetHandlers(
graphql.query("ChildLessonPlans", ({ variables }) => {
capturedVars = variables;
return HttpResponse.json({
data: {
childLessonPlans: [
{
id: "lp-001",
title: "一元二次方程的概念",
subject: "数学",
className: "初一(1)班",
teacherName: "王老师",
textbookTitle: "人教版七年级数学下册",
chapterTitle: "第一章 一元二次方程",
status: "published",
publishedAt: "2026-02-18T10:00:00Z",
estimatedMinutes: 45,
},
],
},
});
}),
);
const { result } = renderUseChildLessonPlans("数学");
await waitFor(() => {
expect(result.current.lessonPlans).toHaveLength(1);
});
expect(capturedVars).toEqual({
childId: "student-001",
subject: "数学",
});
});
it("未传 subject 时变量 subject 为 null", async () => {
useChildStore.setState({ currentChildId: "student-001" });
let capturedVars: unknown = null;
server.resetHandlers(
graphql.query("ChildLessonPlans", ({ variables }) => {
capturedVars = variables;
return HttpResponse.json({ data: { childLessonPlans: [] } });
}),
);
const { result } = renderUseChildLessonPlans();
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(capturedVars).toEqual({
childId: "student-001",
subject: null,
});
expect(result.current.lessonPlans).toEqual([]);
});
it("GraphQL 返回错误时透传 error", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.resetHandlers(
graphql.query("ChildLessonPlans", () =>
HttpResponse.json(
{ errors: [{ message: "备课查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseChildLessonPlans();
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.lessonPlans).toEqual([]);
expect(result.current.error?.message).toContain("备课查询失败");
});
});

View File

@@ -0,0 +1,34 @@
// useChildLessonPlans获取子女备课列表按学科筛选
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 对标 /parent/lesson-plans 页面(家长只读视角)
"use client";
import { useQuery, type CombinedError } from "urql";
import { CHILD_LESSON_PLANS } from "@/lib/graphql/operations";
import type { LessonPlan } from "@/types";
import { useChildStore } from "@/store/child-store";
interface ChildLessonPlansResponse {
childLessonPlans: LessonPlan[];
}
export function useChildLessonPlans(subject?: string): {
lessonPlans: LessonPlan[];
loading: boolean;
error: CombinedError | undefined;
} {
const currentChildId = useChildStore((s) => s.currentChildId);
const [result] = useQuery<ChildLessonPlansResponse>({
query: CHILD_LESSON_PLANS,
variables: { childId: currentChildId, subject: subject || null },
pause: !currentChildId,
});
return {
lessonPlans: result.data?.childLessonPlans ?? [],
loading: result.fetching,
error: result.error,
};
}

View File

@@ -0,0 +1,147 @@
// useChildPractice Hook 单测
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 覆盖:无 currentChildId 暂停 / 加载态 / 成功返回 / 错误态 / 两个查询合并
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
beforeEach,
} from "vitest";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useChildStore } from "@/store/child-store";
import { useChildPractice } from "./useChildPractice";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseChildPractice() {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(() => useChildPractice(), { wrapper });
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
beforeEach(() => {
localStorage.clear();
useChildStore.setState({
children: [],
currentChildId: null,
isLoading: false,
});
});
describe("useChildPractice", () => {
it("无 currentChildId 时暂停查询,返回空数据", () => {
const { result } = renderUseChildPractice();
expect(result.current.stats).toBeNull();
expect(result.current.sessions).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("有 currentChildId 时加载成功返回练习数据", async () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildPractice();
await waitFor(() => {
expect(result.current.stats).not.toBeNull();
});
expect(result.current.stats?.totalSessions).toBe(48);
expect(result.current.stats?.completedSessions).toBe(42);
expect(result.current.stats?.totalQuestionsAnswered).toBe(560);
expect(result.current.stats?.overallAccuracy).toBe(0.83);
expect(result.current.sessions).toHaveLength(5);
expect(result.current.sessions[0]!.subject).toBe("数学");
expect(result.current.sessions[0]!.questionCount).toBe(20);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("加载中 loading 为 true", () => {
useChildStore.setState({ currentChildId: "student-001" });
const { result } = renderUseChildPractice();
expect(result.current.loading).toBe(true);
});
it("ChildPracticeStats 返回错误时透传 error", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.use(
graphql.query("ChildPracticeStats", () =>
HttpResponse.json(
{ errors: [{ message: "练习统计查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseChildPractice();
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.stats).toBeNull();
expect(result.current.error?.message).toContain("练习统计查询失败");
});
it("ChildPracticeSessions 返回错误时透传 error", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.use(
graphql.query("ChildPracticeSessions", () =>
HttpResponse.json(
{ errors: [{ message: "练习历史查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseChildPractice();
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.sessions).toEqual([]);
expect(result.current.error?.message).toContain("练习历史查询失败");
});
it("两个查询返回空数据时返回空数组与 null stats", async () => {
useChildStore.setState({ currentChildId: "student-001" });
server.use(
graphql.query("ChildPracticeStats", () =>
HttpResponse.json({ data: { childPracticeStats: null } }),
),
graphql.query("ChildPracticeSessions", () =>
HttpResponse.json({ data: { childPracticeSessions: [] } }),
),
);
const { result } = renderUseChildPractice();
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.stats).toBeNull();
expect(result.current.sessions).toEqual([]);
});
});

View File

@@ -0,0 +1,49 @@
// useChildPractice获取子女练习统计数据统计 + 历史会话)
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 同时发起两个查询,合并为统一返回值
"use client";
import { useQuery, type CombinedError } from "urql";
import {
CHILD_PRACTICE_STATS,
CHILD_PRACTICE_SESSIONS,
} from "@/lib/graphql/operations";
import type { PracticeStats, PracticeSession } from "@/types";
import { useChildStore } from "@/store/child-store";
interface ChildPracticeStatsResponse {
childPracticeStats: PracticeStats;
}
interface ChildPracticeSessionsResponse {
childPracticeSessions: PracticeSession[];
}
export function useChildPractice(): {
stats: PracticeStats | null;
sessions: PracticeSession[];
loading: boolean;
error: CombinedError | undefined;
} {
const currentChildId = useChildStore((s) => s.currentChildId);
const [statsResult] = useQuery<ChildPracticeStatsResponse>({
query: CHILD_PRACTICE_STATS,
variables: { childId: currentChildId },
pause: !currentChildId,
});
const [sessionsResult] = useQuery<ChildPracticeSessionsResponse>({
query: CHILD_PRACTICE_SESSIONS,
variables: { childId: currentChildId, limit: 20 },
pause: !currentChildId,
});
return {
stats: statsResult.data?.childPracticeStats ?? null,
sessions: sessionsResult.data?.childPracticeSessions ?? [],
loading: statsResult.fetching || sessionsResult.fetching,
error: statsResult.error ?? sessionsResult.error,
};
}

View File

@@ -14,13 +14,14 @@ interface ChildSummaryResponse {
childSummary: ChildSummary;
}
export function useChildSummary() {
export function useChildSummary(childId?: string) {
const currentChildId = useChildStore((s) => s.currentChildId);
const targetChildId = childId ?? currentChildId;
const [result] = useQuery<ChildSummaryResponse>({
query: CHILD_SUMMARY,
variables: { childId: currentChildId },
pause: !currentChildId,
variables: { childId: targetChildId },
pause: !targetChildId,
});
return {

View File

@@ -0,0 +1,143 @@
// useCreateLeaveRequest Hook 单测
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 覆盖:成功提交 / mutation 错误返回 / loading 状态
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
} from "vitest";
import { renderHook, act, cleanup } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useCreateLeaveRequest } from "./useCreateLeaveRequest";
import type { LeaveRequestInput } from "@/types";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseCreateLeaveRequest() {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(() => useCreateLeaveRequest(), { wrapper });
}
const validInput: LeaveRequestInput = {
childId: "student-001",
type: "sick",
startDate: "2026-07-15",
endDate: "2026-07-16",
reason: "感冒发烧需要在家休息",
};
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
describe("useCreateLeaveRequest", () => {
it("初始状态 loading 为 false 且 error 为 undefined", () => {
const { result } = renderUseCreateLeaveRequest();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("成功提交返回 success=true", async () => {
const { result } = renderUseCreateLeaveRequest();
let createResult: { success: boolean } | undefined;
await act(async () => {
createResult = await result.current.createLeave(validInput);
});
expect(createResult?.success).toBe(true);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("提交后 loading 最终回到 false", async () => {
const { result } = renderUseCreateLeaveRequest();
let resolved = false;
await act(async () => {
const r = await result.current.createLeave(validInput);
resolved = r.success;
});
expect(resolved).toBe(true);
expect(result.current.loading).toBe(false);
});
it("mutation 返回错误时返回 success=false 并设置 error", async () => {
server.resetHandlers(
graphql.mutation("CreateLeaveRequest", () =>
HttpResponse.json(
{ errors: [{ message: "请假提交失败:日期冲突" }] },
{ status: 200 },
),
),
);
const { result } = renderUseCreateLeaveRequest();
let createResult: { success: boolean; error?: unknown } | undefined;
await act(async () => {
createResult = await result.current.createLeave(validInput);
});
expect(createResult?.success).toBe(false);
expect(createResult?.error).toBeDefined();
expect(result.current.error?.message).toContain("日期冲突");
expect(result.current.loading).toBe(false);
});
it("提交时携带正确的 input 变量", async () => {
let capturedInput: unknown = null;
server.resetHandlers(
graphql.mutation("CreateLeaveRequest", ({ variables }) => {
capturedInput = variables.input;
return HttpResponse.json({
data: {
createLeaveRequest: {
id: "leave-new",
childId: "student-001",
type: "sick",
startDate: "2026-07-15",
endDate: "2026-07-16",
reason: "感冒发烧",
status: "pending",
submittedAt: "2026-07-13T10:00:00Z",
},
},
});
}),
);
const { result } = renderUseCreateLeaveRequest();
await act(async () => {
await result.current.createLeave(validInput);
});
expect(capturedInput).toEqual(validInput);
});
});

View File

@@ -0,0 +1,43 @@
// useCreateLeaveRequest提交请假申请
// 依据02-architecture-design.md §4.2 GraphQL 接入
"use client";
import { useState } from "react";
import { useMutation, type CombinedError } from "urql";
import { CREATE_LEAVE_REQUEST } from "@/lib/graphql/operations";
import type { LeaveRequest, LeaveRequestInput } from "@/types";
interface CreateLeaveRequestResponse {
createLeaveRequest: LeaveRequest;
}
export function useCreateLeaveRequest(): {
createLeave: (
input: LeaveRequestInput,
) => Promise<{ success: boolean; error?: CombinedError }>;
loading: boolean;
error: CombinedError | undefined;
} {
const [, mutate] = useMutation<CreateLeaveRequestResponse>(
CREATE_LEAVE_REQUEST,
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<CombinedError | undefined>(undefined);
const createLeave = async (
input: LeaveRequestInput,
): Promise<{ success: boolean; error?: CombinedError }> => {
setLoading(true);
setError(undefined);
const result = await mutate({ input });
setLoading(false);
if (result.error) {
setError(result.error);
return { success: false, error: result.error };
}
return { success: true };
};
return { createLeave, loading, error };
}

View File

@@ -0,0 +1,170 @@
// useExportChildGrades Hook 单测
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 覆盖:成功提交 / mutation 错误返回 / loading 状态 / 携带 subject 变量
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
} from "vitest";
import { renderHook, act, cleanup } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useExportChildGrades } from "./useExportChildGrades";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseExportChildGrades() {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(() => useExportChildGrades(), { wrapper });
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
describe("useExportChildGrades", () => {
it("初始状态 loading 为 false 且 error 为 undefined", () => {
const { result } = renderUseExportChildGrades();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("成功提交返回 success=true 和下载链接", async () => {
const { result } = renderUseExportChildGrades();
let exportResult:
| { success: boolean; data?: { downloadUrl: string; expiresAt: string } }
| undefined;
await act(async () => {
exportResult = await result.current.exportGrades({
childId: "student-001",
});
});
expect(exportResult?.success).toBe(true);
expect(exportResult?.data?.downloadUrl).toContain("student-001");
expect(exportResult?.data?.expiresAt).toBeDefined();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("提交后 loading 最终回到 false", async () => {
const { result } = renderUseExportChildGrades();
let resolved = false;
await act(async () => {
const r = await result.current.exportGrades({ childId: "student-001" });
resolved = r.success;
});
expect(resolved).toBe(true);
expect(result.current.loading).toBe(false);
});
it("mutation 返回错误时返回 success=false 并设置 error", async () => {
server.resetHandlers(
graphql.mutation("ExportChildGrades", () =>
HttpResponse.json(
{ errors: [{ message: "导出失败:权限不足" }] },
{ status: 200 },
),
),
);
const { result } = renderUseExportChildGrades();
let exportResult:
| { success: boolean; error?: { message: string } }
| undefined;
await act(async () => {
exportResult = await result.current.exportGrades({
childId: "student-001",
});
});
expect(exportResult?.success).toBe(false);
expect(exportResult?.error).toBeDefined();
expect(result.current.error?.message).toContain("权限不足");
expect(result.current.loading).toBe(false);
});
it("未传 subject 时变量 subject 为 null", async () => {
let capturedVars: unknown = null;
server.resetHandlers(
graphql.mutation("ExportChildGrades", ({ variables }) => {
capturedVars = variables;
return HttpResponse.json({
data: {
exportChildGrades: {
downloadUrl: "https://example.com/exports/grades.xlsx",
expiresAt: "2026-07-13T23:59:59Z",
},
},
});
}),
);
const { result } = renderUseExportChildGrades();
await act(async () => {
await result.current.exportGrades({ childId: "student-001" });
});
expect(capturedVars).toEqual({
childId: "student-001",
subject: null,
});
});
it("传入 subject 时变量携带 subject", async () => {
let capturedVars: unknown = null;
server.resetHandlers(
graphql.mutation("ExportChildGrades", ({ variables }) => {
capturedVars = variables;
return HttpResponse.json({
data: {
exportChildGrades: {
downloadUrl: "https://example.com/exports/grades-math.xlsx",
expiresAt: "2026-07-13T23:59:59Z",
},
},
});
}),
);
const { result } = renderUseExportChildGrades();
await act(async () => {
await result.current.exportGrades({
childId: "student-001",
subject: "数学",
});
});
expect(capturedVars).toEqual({
childId: "student-001",
subject: "数学",
});
});
});

View File

@@ -0,0 +1,57 @@
// useExportChildGrades导出子女成绩
// 依据02-architecture-design.md §4.2 GraphQL 接入
// - mutation EXPORT_CHILD_GRADES返回下载链接 + 过期时间
// - 支持可选 subject 参数(按学科筛选导出)
"use client";
import { useState } from "react";
import { useMutation, type CombinedError } from "urql";
import { EXPORT_CHILD_GRADES } from "@/lib/graphql/operations";
import type { ExportResult } from "@/types";
interface ExportChildGradesResponse {
exportChildGrades: ExportResult;
}
interface ExportChildGradesInput {
childId: string;
subject?: string;
}
export function useExportChildGrades(): {
exportGrades: (
input: ExportChildGradesInput,
) => Promise<{ success: boolean; data?: ExportResult; error?: CombinedError }>;
loading: boolean;
error: CombinedError | undefined;
} {
const [, mutate] = useMutation<ExportChildGradesResponse>(
EXPORT_CHILD_GRADES,
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<CombinedError | undefined>(undefined);
const exportGrades = async (
input: ExportChildGradesInput,
): Promise<{
success: boolean;
data?: ExportResult;
error?: CombinedError;
}> => {
setLoading(true);
setError(undefined);
const result = await mutate({
childId: input.childId,
subject: input.subject ?? null,
});
setLoading(false);
if (result.error) {
setError(result.error);
return { success: false, error: result.error };
}
return { success: true, data: result.data?.exportChildGrades };
};
return { exportGrades, loading, error };
}

View File

@@ -0,0 +1,157 @@
// useReportCard Hook 单测
// 依据02-architecture-design.md §4.2 GraphQL 接入
// 覆盖:无 childId/academicYearId 暂停 / 加载态 / 成功返回 / 变量传递 / 错误态
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
} from "vitest";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useReportCard } from "./useReportCard";
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseReportCard(
childId: string | null,
academicYearId: string | null,
semester: number,
) {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(
() => useReportCard(childId, academicYearId, semester),
{ wrapper },
);
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
describe("useReportCard", () => {
it("无 childId 时暂停查询,返回 null", () => {
const { result } = renderUseReportCard(null, "ay-2025-2026", 2);
expect(result.current.reportCard).toBeNull();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("无 academicYearId 时暂停查询,返回 null", () => {
const { result } = renderUseReportCard("student-001", null, 2);
expect(result.current.reportCard).toBeNull();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("有 childId 和 academicYearId 时加载成功返回报告卡", async () => {
const { result } = renderUseReportCard(
"student-001",
"ay-2025-2026",
2,
);
await waitFor(() => {
expect(result.current.reportCard).not.toBeNull();
});
expect(result.current.reportCard?.childId).toBe("student-001");
expect(result.current.reportCard?.semester).toBe(2);
expect(result.current.reportCard?.subjects).toHaveLength(4);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeUndefined();
});
it("加载中 loading 为 true", () => {
const { result } = renderUseReportCard(
"student-001",
"ay-2025-2026",
1,
);
expect(result.current.loading).toBe(true);
});
it("传入的变量正确传递", async () => {
let capturedVars: unknown = null;
server.resetHandlers(
graphql.query("ChildReportCard", ({ variables }) => {
capturedVars = variables;
return HttpResponse.json({
data: {
childReportCard: {
childId: variables.childId,
childName: "张小明",
className: "初一(1)班",
academicYearId: variables.academicYearId,
academicYearName: "2025-2026",
semester: variables.semester,
subjects: [],
overallScore: 90,
classRank: 5,
classSize: 45,
teacherComment: "良好",
issuedAt: "2026-07-05T10:00:00Z",
},
},
});
}),
);
const { result } = renderUseReportCard(
"student-002",
"ay-2024-2025",
1,
);
await waitFor(() => {
expect(result.current.reportCard).not.toBeNull();
});
expect(capturedVars).toEqual({
childId: "student-002",
academicYearId: "ay-2024-2025",
semester: 1,
});
});
it("GraphQL 返回错误时透传 error", async () => {
server.resetHandlers(
graphql.query("ChildReportCard", () =>
HttpResponse.json(
{ errors: [{ message: "报告卡查询失败" }] },
{ status: 200 },
),
),
);
const { result } = renderUseReportCard(
"student-001",
"ay-2025-2026",
2,
);
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.reportCard).toBeNull();
expect(result.current.error?.message).toContain("报告卡查询失败");
});
});

View File

@@ -0,0 +1,34 @@
// useReportCard获取子女报告卡
// 依据02-architecture-design.md §4.2 GraphQL 接入
"use client";
import { useQuery, type CombinedError } from "urql";
import { CHILD_REPORT_CARD } from "@/lib/graphql/operations";
import type { ReportCard } from "@/types";
interface ChildReportCardResponse {
childReportCard: ReportCard;
}
export function useReportCard(
childId: string | null,
academicYearId: string | null,
semester: number,
): {
reportCard: ReportCard | null;
loading: boolean;
error: CombinedError | undefined;
} {
const [result] = useQuery<ChildReportCardResponse>({
query: CHILD_REPORT_CARD,
variables: { childId, academicYearId, semester },
pause: !childId || !academicYearId,
});
return {
reportCard: result.data?.childReportCard ?? null,
loading: result.fetching,
error: result.error,
};
}

View File

@@ -304,3 +304,375 @@ export const SWITCH_CHILD = gql`
}
}
`;
// ===== 参考项目差距补齐:请假 / 报告卡 / 错题本 / 诊断 / 练习 / 课程 / 备课 / 选修 / 子女详情 =====
// ----- 请假 -----
export const CHILD_LEAVE_REQUESTS = gql`
query ChildLeaveRequests($childId: ID!) {
childLeaveRequests(childId: $childId) {
id
childId
childName
className
type
startDate
endDate
reason
status
submittedAt
reviewedAt
reviewerName
reviewComment
}
}
`;
export const CREATE_LEAVE_REQUEST = gql`
mutation CreateLeaveRequest($input: LeaveRequestInput!) {
createLeaveRequest(input: $input) {
id
childId
type
startDate
endDate
reason
status
submittedAt
}
}
`;
// ----- 报告卡 -----
export const ACADEMIC_YEARS = gql`
query AcademicYears {
academicYears {
id
name
startDate
endDate
}
}
`;
export const CHILD_REPORT_CARD = gql`
query ChildReportCard($childId: ID!, $academicYearId: ID!, $semester: Int!) {
childReportCard(
childId: $childId
academicYearId: $academicYearId
semester: $semester
) {
childId
childName
className
academicYearId
academicYearName
semester
subjects {
subject
score
maxScore
gradeLevel
classAverage
rank
classSize
teacherComment
}
overallScore
classRank
classSize
teacherComment
headTeacherComment
issuedAt
}
}
`;
// ----- 错题本 -----
export const CHILD_ERROR_BOOK_STATS = gql`
query ChildErrorBookStats($childId: ID!) {
childErrorBookStats(childId: $childId) {
childId
totalCount
newCount
learningCount
masteredCount
dueReviewCount
masteredRate
}
}
`;
export const CHILD_TOP_WRONG_QUESTIONS = gql`
query ChildTopWrongQuestions($childId: ID!, $limit: Int) {
childTopWrongQuestions(childId: $childId, limit: $limit) {
id
questionContent
subject
knowledgePoint
errorCount
lastErrorAt
masteryRate
}
}
`;
export const CHILD_WEAK_KPS = gql`
query ChildWeakKps($childId: ID!, $limit: Int) {
childWeakKps(childId: $childId, limit: $limit) {
knowledgePointName
subject
errorCount
masteryRate
}
}
`;
// ----- 诊断报告 -----
export const CHILD_MASTERY_SUMMARY = gql`
query ChildMasterySummary($childId: ID!) {
childMasterySummary(childId: $childId) {
childId
overallMastery
subjectMastery {
subject
mastery
}
totalKps
masteredKps
}
}
`;
export const CHILD_DIAGNOSTIC_REPORTS = gql`
query ChildDiagnosticReports($childId: ID!) {
childDiagnosticReports(childId: $childId) {
id
childId
title
summary
status
createdAt
publishedAt
masteryScore
subjects {
subject
masteryLevel
strengths
weaknesses
recommendation
}
}
}
`;
// ----- 练习统计 -----
export const CHILD_PRACTICE_STATS = gql`
query ChildPracticeStats($childId: ID!) {
childPracticeStats(childId: $childId) {
childId
totalSessions
completedSessions
totalQuestionsAnswered
overallAccuracy
}
}
`;
export const CHILD_PRACTICE_SESSIONS = gql`
query ChildPracticeSessions($childId: ID!, $limit: Int) {
childPracticeSessions(childId: $childId, limit: $limit) {
id
childId
subject
startedAt
completedAt
questionCount
correctCount
accuracy
durationSeconds
knowledgePoints
}
}
`;
// ----- 课程计划 -----
export const CHILD_COURSE_PLANS = gql`
query ChildCoursePlans($childId: ID!) {
childCoursePlans(childId: $childId) {
id
title
description
subject
className
teacherName
startDate
endDate
status
chapterCount
}
}
`;
export const CHILD_COURSE_PLAN_DETAIL = gql`
query ChildCoursePlanDetail($childId: ID!, $planId: ID!) {
childCoursePlanDetail(childId: $childId, planId: $planId) {
id
title
description
subject
className
teacherName
startDate
endDate
status
chapterCount
chapters {
id
title
description
sortOrder
lessonCount
estimatedHours
}
}
}
`;
// ----- 备课计划 -----
export const CHILD_LESSON_PLANS = gql`
query ChildLessonPlans($childId: ID!, $subject: String) {
childLessonPlans(childId: $childId, subject: $subject) {
id
title
subject
className
teacherName
textbookTitle
chapterTitle
status
publishedAt
estimatedMinutes
}
}
`;
export const CHILD_LESSON_PLAN_DETAIL = gql`
query ChildLessonPlanDetail($childId: ID!, $planId: ID!) {
childLessonPlanDetail(childId: $childId, planId: $planId) {
id
title
subject
className
teacherName
textbookTitle
chapterTitle
status
publishedAt
estimatedMinutes
objectives
keyPoints
difficultPoints
content
homeworkDescription
}
}
`;
// ----- 选修课 -----
export const CHILD_ELECTIVE = gql`
query ChildElective($childId: ID!) {
childElective(childId: $childId) {
id
childId
courseName
courseCode
teacherName
category
credits
schedule
status
enrolledAt
score
}
}
`;
// ----- 子女详情聚合 -----
export const CHILD_DETAIL = gql`
query ChildDetail($childId: ID!) {
childDetail(childId: $childId) {
childId
basicInfo {
name
avatar
grade
className
schoolName
relation
}
todaySchedule {
id
dayOfWeek
period
subject
teacherName
classroom
startTime
endTime
}
weeklySchedule {
id
dayOfWeek
period
subject
teacherName
classroom
startTime
endTime
}
homeworkSummary {
pendingCount
overdueCount
submittedCount
gradedCount
}
gradeSummary {
avgScore
classRank
classSize
trend
}
examResults {
upcoming
completed
avgScore
}
}
}
`;
// ----- 成长档案(班级均对比线)-----
export const CHILD_GROWTH_ARCHIVE = gql`
query ChildGrowthArchive($childId: ID!, $subject: String) {
childGrowthArchive(childId: $childId, subject: $subject) {
childId
subject
dataPoints {
date
studentScore
classAverage
}
}
}
`;
// ----- 导出成绩 -----
export const EXPORT_CHILD_GRADES = gql`
mutation ExportChildGrades($childId: ID!, $subject: String) {
exportChildGrades(childId: $childId, subject: $subject) {
downloadUrl
expiresAt
}
}
`;

View File

@@ -0,0 +1,27 @@
// 请假表单 Zod schema
// 依据project_rules §3.8 输入验证、02-architecture-design.md §15 组件设计
// - 请假类型sick/personal/family/other
// - 日期范围:结束日期 >= 开始日期
// - 原因必填,最少 10 字
import { z } from "zod";
export const LEAVE_TYPES = ["sick", "personal", "family", "other"] as const;
export const leaveRequestSchema = z
.object({
childId: z.string().min(1, "请选择子女"),
type: z.enum(LEAVE_TYPES, { message: "请选择请假类型" }),
startDate: z.string().min(1, "请选择开始日期"),
endDate: z.string().min(1, "请选择结束日期"),
reason: z
.string()
.min(10, "请假原因至少 10 个字")
.max(500, "请假原因不超过 500 个字"),
})
.refine((data) => data.endDate >= data.startDate, {
message: "结束日期不能早于开始日期",
path: ["endDate"],
});
export type LeaveRequestFormValues = z.infer<typeof leaveRequestSchema>;

View File

@@ -21,6 +21,24 @@ import type {
ExamResult,
ClassInfo,
LearningPathNode,
LeaveRequest,
AcademicYear,
ReportCard,
ErrorBookStats,
TopWrongQuestion,
WeakKp,
MasterySummary,
DiagnosticReport,
PracticeStats,
PracticeSession,
CoursePlan,
CoursePlanDetail,
LessonPlan,
LessonPlanDetail,
ElectiveSelection,
ChildDetail,
ExportResult,
GrowthArchive,
} from "@/types";
// ===== 用户 =====
@@ -543,3 +561,708 @@ export const mockLoginResponse: LoginResponse = {
expiresIn: 3600,
},
};
// ===== 请假记录(参考项目 /parent/leave=====
export const mockLeaveRequests: LeaveRequest[] = [
{
id: "leave-001",
childId: "student-001",
childName: "张小明",
className: "初一(1)班",
type: "sick",
startDate: "2026-05-10",
endDate: "2026-05-11",
reason: "感冒发烧,需在家休息观察",
status: "approved",
submittedAt: "2026-05-09T20:15:00Z",
reviewedAt: "2026-05-10T08:30:00Z",
reviewerName: "王老师",
reviewComment: "已批准,注意休息,按时复课",
},
{
id: "leave-002",
childId: "student-001",
childName: "张小明",
className: "初一(1)班",
type: "personal",
startDate: "2026-06-15",
endDate: "2026-06-15",
reason: "家中临时事务,需陪同办理",
status: "pending",
submittedAt: "2026-06-13T21:00:00Z",
},
{
id: "leave-003",
childId: "student-002",
childName: "张小红",
className: "五年级(2)班",
type: "family",
startDate: "2026-04-20",
endDate: "2026-04-22",
reason: "回老家参加亲属婚礼",
status: "approved",
submittedAt: "2026-04-15T19:30:00Z",
reviewedAt: "2026-04-16T09:00:00Z",
reviewerName: "李老师",
reviewComment: "已批准,请提前补做作业",
},
];
// ===== 学年(参考项目 /parent/grades/report-card=====
export const mockAcademicYears: AcademicYear[] = [
{
id: "ay-2025-2026",
name: "2025-2026",
startDate: "2025-09-01",
endDate: "2026-07-15",
},
{
id: "ay-2024-2025",
name: "2024-2025",
startDate: "2024-09-01",
endDate: "2025-07-15",
},
];
// ===== 报告卡(含 4 个学科)=====
export const mockReportCard: ReportCard = {
childId: "student-001",
childName: "张小明",
className: "初一(1)班",
academicYearId: "ay-2025-2026",
academicYearName: "2025-2026",
semester: 2,
subjects: [
{
subject: "语文",
score: 85,
maxScore: 100,
gradeLevel: "B",
classAverage: 75,
rank: 8,
classSize: 45,
teacherComment: "阅读理解能力较强,作文需加强立意深度",
},
{
subject: "数学",
score: 92,
maxScore: 100,
gradeLevel: "A",
classAverage: 78,
rank: 3,
classSize: 45,
teacherComment: "逻辑思维清晰,解题规范,继续保持",
},
{
subject: "英语",
score: 88,
maxScore: 100,
gradeLevel: "B",
classAverage: 72,
rank: 5,
classSize: 45,
teacherComment: "听力有进步,建议加强口语练习",
},
{
subject: "物理",
score: 95,
maxScore: 100,
gradeLevel: "A",
classAverage: 80,
rank: 2,
classSize: 45,
teacherComment: "实验探究能力突出,分析问题思路严谨",
},
],
overallScore: 90,
classRank: 5,
classSize: 45,
teacherComment:
"本学期学习态度认真,成绩稳中有进,望继续保持,加强薄弱环节",
headTeacherComment:
"该生品学兼优,关心集体,乐于助人,下学期可担任班干部锻炼能力",
issuedAt: "2026-07-05T10:00:00Z",
};
// ===== 错题本统计 =====
export const mockErrorBookStats: ErrorBookStats = {
childId: "student-001",
totalCount: 128,
newCount: 23,
learningCount: 45,
masteredCount: 60,
dueReviewCount: 18,
masteredRate: 0.469,
};
// ===== 高频错题4 条)=====
export const mockTopWrongQuestions: TopWrongQuestion[] = [
{
id: "wq-001",
questionContent:
"已知一元二次方程 x² - 5x + 6 = 0求方程的解。",
subject: "数学",
knowledgePoint: "一元二次方程",
errorCount: 5,
lastErrorAt: "2026-07-08T15:20:00Z",
masteryRate: 0.4,
},
{
id: "wq-002",
questionContent: "下列句子中,加点词语使用正确的一项是( ",
subject: "语文",
knowledgePoint: "词语辨析",
errorCount: 4,
lastErrorAt: "2026-07-06T16:10:00Z",
masteryRate: 0.55,
},
{
id: "wq-003",
questionContent:
"Choose the correct word to fill in the blank: He ___ to school every day.",
subject: "英语",
knowledgePoint: "一般现在时",
errorCount: 3,
lastErrorAt: "2026-07-05T14:30:00Z",
masteryRate: 0.7,
},
{
id: "wq-004",
questionContent: "物体在水平面上做匀速直线运动,所受合力为( ",
subject: "物理",
knowledgePoint: "力的合成与平衡",
errorCount: 3,
lastErrorAt: "2026-07-03T11:00:00Z",
masteryRate: 0.65,
},
];
// ===== 薄弱知识点4 条)=====
export const mockWeakKps: WeakKp[] = [
{
knowledgePointName: "一元二次方程",
subject: "数学",
errorCount: 5,
masteryRate: 0.4,
},
{
knowledgePointName: "文言文虚词",
subject: "语文",
errorCount: 4,
masteryRate: 0.55,
},
{
knowledgePointName: "现在完成时",
subject: "英语",
errorCount: 4,
masteryRate: 0.6,
},
{
knowledgePointName: "浮力与阿基米德原理",
subject: "物理",
errorCount: 3,
masteryRate: 0.5,
},
];
// ===== 掌握度总览(含 4 个学科)=====
export const mockMasterySummary: MasterySummary = {
childId: "student-001",
overallMastery: 0.78,
subjectMastery: [
{ subject: "语文", mastery: 0.82 },
{ subject: "数学", mastery: 0.75 },
{ subject: "英语", mastery: 0.8 },
{ subject: "物理", mastery: 0.85 },
],
totalKps: 86,
masteredKps: 67,
};
// ===== 诊断报告1 published + 1 draft=====
export const mockDiagnosticReports: DiagnosticReport[] = [
{
id: "diag-001",
childId: "student-001",
title: "2026 春季学期学情诊断报告",
summary:
"该生本学期整体学习情况良好,数学和物理掌握较好,语文作文和英语口语为薄弱环节,建议针对性强化。",
status: "published",
createdAt: "2026-06-20T09:00:00Z",
publishedAt: "2026-06-25T10:00:00Z",
masteryScore: 78,
subjects: [
{
subject: "语文",
masteryLevel: 0.82,
strengths: ["阅读理解", "古诗文默写"],
weaknesses: ["作文立意", "现代文词语辨析"],
recommendation: "每周练习 1 篇议论文,积累优秀范文素材",
},
{
subject: "数学",
masteryLevel: 0.75,
strengths: ["几何证明", "函数基础"],
weaknesses: ["一元二次方程应用题"],
recommendation: "完成配方法专项练习 20 道,错题及时整理",
},
{
subject: "英语",
masteryLevel: 0.8,
strengths: ["词汇量", "语法基础"],
weaknesses: ["口语表达", "听力长对话"],
recommendation: "每天 15 分钟英语听力,每周一次口语对话练习",
},
{
subject: "物理",
masteryLevel: 0.85,
strengths: ["力学概念", "实验设计"],
weaknesses: ["浮力综合题"],
recommendation: "针对性练习阿基米德原理综合题 10 道",
},
],
},
{
id: "diag-002",
childId: "student-001",
title: "2026 暑期学情预诊断报告(草稿)",
summary: "基于暑期前练习数据生成的预诊断,待教研组审核发布。",
status: "draft",
createdAt: "2026-07-10T14:00:00Z",
masteryScore: 72,
subjects: [
{
subject: "数学",
masteryLevel: 0.7,
strengths: ["代数基础"],
weaknesses: ["不等式组", "应用题建模"],
recommendation: "暑期完成不等式专项练习册",
},
{
subject: "英语",
masteryLevel: 0.74,
strengths: ["阅读"],
weaknesses: ["完形填空"],
recommendation: "每天 1 篇完形填空专项训练",
},
],
},
];
// ===== 练习统计 =====
export const mockPracticeStats: PracticeStats = {
childId: "student-001",
totalSessions: 48,
completedSessions: 42,
totalQuestionsAnswered: 560,
overallAccuracy: 0.83,
};
// ===== 练习会话4 条)=====
export const mockPracticeSessions: PracticeSession[] = [
{
id: "ps-001",
childId: "student-001",
subject: "数学",
startedAt: "2026-07-10T19:00:00Z",
completedAt: "2026-07-10T19:35:00Z",
questionCount: 20,
correctCount: 17,
accuracy: 0.85,
durationSeconds: 2100,
knowledgePoints: ["一元二次方程", "配方法"],
},
{
id: "ps-002",
childId: "student-001",
subject: "英语",
startedAt: "2026-07-09T20:10:00Z",
completedAt: "2026-07-09T20:40:00Z",
questionCount: 15,
correctCount: 12,
accuracy: 0.8,
durationSeconds: 1800,
knowledgePoints: ["现在完成时", "过去进行时"],
},
{
id: "ps-003",
childId: "student-001",
subject: "语文",
startedAt: "2026-07-08T18:30:00Z",
completedAt: "2026-07-08T19:05:00Z",
questionCount: 18,
correctCount: 15,
accuracy: 0.833,
durationSeconds: 2100,
knowledgePoints: ["文言文虚词", "古诗文默写"],
},
{
id: "ps-004",
childId: "student-001",
subject: "物理",
startedAt: "2026-07-07T19:20:00Z",
completedAt: "2026-07-07T19:50:00Z",
questionCount: 12,
correctCount: 11,
accuracy: 0.917,
durationSeconds: 1800,
knowledgePoints: ["浮力", "阿基米德原理"],
},
{
id: "ps-005",
childId: "student-001",
subject: "数学",
startedAt: "2026-07-06T20:00:00Z",
completedAt: "2026-07-06T20:25:00Z",
questionCount: 10,
correctCount: 9,
accuracy: 0.9,
durationSeconds: 1500,
knowledgePoints: ["不等式组"],
},
];
// ===== 课程计划3 条)=====
export const mockCoursePlans: CoursePlan[] = [
{
id: "cp-001",
title: "初一数学下学期课程计划",
description: "涵盖一元二次方程、不等式组、函数与图像等核心章节",
subject: "数学",
className: "初一(1)班",
teacherName: "王老师",
startDate: "2026-02-15",
endDate: "2026-07-10",
status: "active",
chapterCount: 8,
},
{
id: "cp-002",
title: "初一语文下学期课程计划",
description: "包含古诗文鉴赏、现代文阅读、议论文写作三大模块",
subject: "语文",
className: "初一(1)班",
teacherName: "李老师",
startDate: "2026-02-15",
endDate: "2026-07-10",
status: "active",
chapterCount: 6,
},
{
id: "cp-003",
title: "初一英语上学期课程计划",
description: "上学期英语语法与词汇基础课程",
subject: "英语",
className: "初一(1)班",
teacherName: "张老师",
startDate: "2025-09-01",
endDate: "2026-01-15",
status: "completed",
chapterCount: 10,
},
];
// ===== 课程计划详情(含 4 章节)=====
export const mockCoursePlanDetail: CoursePlanDetail = {
id: "cp-001",
title: "初一数学下学期课程计划",
description: "涵盖一元二次方程、不等式组、函数与图像等核心章节",
subject: "数学",
className: "初一(1)班",
teacherName: "王老师",
startDate: "2026-02-15",
endDate: "2026-07-10",
status: "active",
chapterCount: 8,
chapters: [
{
id: "cp-001-ch-01",
title: "第一章 一元二次方程",
description: "学习一元二次方程的概念、解法及其应用",
sortOrder: 1,
lessonCount: 8,
estimatedHours: 6,
},
{
id: "cp-001-ch-02",
title: "第二章 不等式与不等式组",
description: "掌握一元一次不等式及不等式组的解法",
sortOrder: 2,
lessonCount: 6,
estimatedHours: 4.5,
},
{
id: "cp-001-ch-03",
title: "第三章 函数与图像",
description: "理解函数概念,掌握一次函数与反比例函数",
sortOrder: 3,
lessonCount: 10,
estimatedHours: 8,
},
{
id: "cp-001-ch-04",
title: "第四章 数据的收集与整理",
description: "学习统计基础,掌握数据图表绘制",
sortOrder: 4,
lessonCount: 5,
estimatedHours: 3.5,
},
],
};
// ===== 备课计划4 条,全 published=====
export const mockLessonPlans: LessonPlan[] = [
{
id: "lp-001",
title: "一元二次方程的概念",
subject: "数学",
className: "初一(1)班",
teacherName: "王老师",
textbookTitle: "人教版七年级数学下册",
chapterTitle: "第一章 一元二次方程",
status: "published",
publishedAt: "2026-02-18T10:00:00Z",
estimatedMinutes: 45,
},
{
id: "lp-002",
title: "配方法解一元二次方程",
subject: "数学",
className: "初一(1)班",
teacherName: "王老师",
textbookTitle: "人教版七年级数学下册",
chapterTitle: "第一章 一元二次方程",
status: "published",
publishedAt: "2026-02-22T10:00:00Z",
estimatedMinutes: 45,
},
{
id: "lp-003",
title: "文言文虚词专题(一)",
subject: "语文",
className: "初一(1)班",
teacherName: "李老师",
textbookTitle: "人教版七年级语文下册",
chapterTitle: "第二单元 文言文阅读",
status: "published",
publishedAt: "2026-03-05T10:00:00Z",
estimatedMinutes: 45,
},
{
id: "lp-004",
title: "现在完成时专项练习",
subject: "英语",
className: "初一(1)班",
teacherName: "张老师",
textbookTitle: "人教版七年级英语下册",
chapterTitle: "Unit 6 Tenses",
status: "published",
publishedAt: "2026-03-12T10:00:00Z",
estimatedMinutes: 40,
},
];
// ===== 备课计划详情 =====
export const mockLessonPlanDetail: LessonPlanDetail = {
id: "lp-002",
title: "配方法解一元二次方程",
subject: "数学",
className: "初一(1)班",
teacherName: "王老师",
textbookTitle: "人教版七年级数学下册",
chapterTitle: "第一章 一元二次方程",
status: "published",
publishedAt: "2026-02-22T10:00:00Z",
estimatedMinutes: 45,
objectives: [
"理解配方法的几何意义",
"掌握用配方法解一元二次方程的步骤",
"能够将一般一元二次方程化为 (x+a)²=b 的形式",
],
keyPoints: [
"配方法的核心步骤:移项 → 二次项系数化为 1 → 配方 → 开方",
"完全平方式 a²±2ab+b²=(a±b)² 的应用",
],
difficultPoints: [
"配方时如何确定两边同时加上的常数项",
"当二次项系数不为 1 时的处理方法",
],
content:
"本节课主要讲解配方法解一元二次方程。首先复习完全平方式的概念,通过几何图形直观展示配方的过程。然后通过例题演示配方法的标准步骤:(1) 将方程化为一般形式;(2) 二次项系数化为 1(3) 移常数项到右边;(4) 左右两边同时加上一次项系数一半的平方;(5) 左边写成完全平方形式;(6) 开平方求解。最后通过练习题巩固,重点关注学生易错点。",
homeworkDescription:
"课本第 25 页练习 1-4 题,配套练习册第 15 页配方法专项 8 题,要求写出完整解题过程。",
};
// ===== 选修课3 条)=====
export const mockElectiveSelections: ElectiveSelection[] = [
{
id: "ele-001",
childId: "student-001",
courseName: "创意写作",
courseCode: "ELECTIVE-A001",
teacherName: "陈老师",
category: "language",
credits: 2,
schedule: "周三 15:00-16:30",
status: "enrolled",
enrolledAt: "2026-02-25T09:00:00Z",
},
{
id: "ele-002",
childId: "student-001",
courseName: "趣味物理实验",
courseCode: "ELECTIVE-S012",
teacherName: "周老师",
category: "science",
credits: 2,
schedule: "周五 15:00-16:30",
status: "enrolled",
enrolledAt: "2026-02-25T09:00:00Z",
},
{
id: "ele-003",
childId: "student-001",
courseName: "篮球基础",
courseCode: "ELECTIVE-P005",
teacherName: "孙老师",
category: "sports",
credits: 1,
schedule: "周一 16:30-18:00",
status: "completed",
enrolledAt: "2025-09-05T09:00:00Z",
score: 88,
},
];
// ===== 子女详情聚合 =====
export const mockChildDetail: ChildDetail = {
childId: "student-001",
basicInfo: {
name: "张小明",
avatar: "https://example.com/avatar/student-001.png",
grade: "grade.7",
className: "初一(1)班",
schoolName: "实验中学",
relation: "父亲",
},
todaySchedule: [
{
id: "sch-today-1",
dayOfWeek: 1,
period: 1,
subject: "数学",
teacherName: "王老师",
classroom: "初一(1)班教室",
startTime: "08:00",
endTime: "08:45",
},
{
id: "sch-today-2",
dayOfWeek: 1,
period: 2,
subject: "语文",
teacherName: "李老师",
classroom: "初一(1)班教室",
startTime: "08:55",
endTime: "09:40",
},
{
id: "sch-today-3",
dayOfWeek: 1,
period: 3,
subject: "英语",
teacherName: "张老师",
classroom: "初一(1)班教室",
startTime: "10:00",
endTime: "10:45",
},
],
weeklySchedule: [
{
id: "sch-w-1",
dayOfWeek: 1,
period: 1,
subject: "数学",
teacherName: "王老师",
classroom: "初一(1)班教室",
startTime: "08:00",
endTime: "08:45",
},
{
id: "sch-w-2",
dayOfWeek: 1,
period: 2,
subject: "语文",
teacherName: "李老师",
classroom: "初一(1)班教室",
startTime: "08:55",
endTime: "09:40",
},
{
id: "sch-w-3",
dayOfWeek: 2,
period: 1,
subject: "英语",
teacherName: "张老师",
classroom: "初一(1)班教室",
startTime: "08:00",
endTime: "08:45",
},
{
id: "sch-w-4",
dayOfWeek: 2,
period: 2,
subject: "物理",
teacherName: "周老师",
classroom: "物理实验室",
startTime: "08:55",
endTime: "09:40",
},
{
id: "sch-w-5",
dayOfWeek: 3,
period: 1,
subject: "数学",
teacherName: "王老师",
classroom: "初一(1)班教室",
startTime: "08:00",
endTime: "08:45",
},
],
homeworkSummary: {
pendingCount: 3,
overdueCount: 0,
submittedCount: 5,
gradedCount: 8,
},
gradeSummary: {
avgScore: 90,
classRank: 5,
classSize: 45,
trend: "up",
},
examResults: {
upcoming: 2,
completed: 4,
avgScore: 88,
},
};
// ===== 成长档案(班级均对比线)=====
export const mockGrowthArchive: GrowthArchive = {
childId: "student-001",
subject: "数学",
dataPoints: [
{ date: "2026-03", studentScore: 82, classAverage: 76 },
{ date: "2026-04", studentScore: 88, classAverage: 78 },
{ date: "2026-05", studentScore: 92, classAverage: 79 },
{ date: "2026-06", studentScore: 90, classAverage: 80 },
],
};
// ===== 成绩导出结果 =====
export const mockExportResult: ExportResult = {
downloadUrl:
"https://example.com/exports/grades-student-001-2026-07-13.xlsx",
expiresAt: "2026-07-13T23:59:59Z",
};

View File

@@ -7,7 +7,7 @@
// - ISSUE-033 通知偏好 P4 localStorage 降级 → updateNotificationPreferences 仍 mock 200
import { http, HttpResponse, graphql } from "msw";
import type { ActionState, LoginResponse } from "@/types";
import type { ActionState, LoginResponse, LeaveRequestInput } from "@/types";
import {
mockParent,
mockChildren,
@@ -24,6 +24,24 @@ import {
mockNotifications,
mockNotificationPreferences,
mockLoginResponse,
mockLeaveRequests,
mockAcademicYears,
mockReportCard,
mockErrorBookStats,
mockTopWrongQuestions,
mockWeakKps,
mockMasterySummary,
mockDiagnosticReports,
mockPracticeStats,
mockPracticeSessions,
mockCoursePlans,
mockCoursePlanDetail,
mockLessonPlans,
mockLessonPlanDetail,
mockElectiveSelections,
mockChildDetail,
mockGrowthArchive,
mockExportResult,
} from "./fixtures";
// ===== REST handlersiam login/refreshISSUE-004=====
@@ -244,6 +262,235 @@ const graphqlHandlers = [
},
});
}),
// ===== 参考项目差距补齐:请假 / 报告卡 / 错题本 / 诊断 / 练习 / 课程 / 备课 / 选修 / 子女详情 =====
// ----- 请假 -----
graphql.query("ChildLeaveRequests", ({ variables }) => {
const childId = variables.childId as string;
return HttpResponse.json({
data: {
childLeaveRequests: mockLeaveRequests.filter((l) => l.childId === childId),
},
});
}),
graphql.mutation("CreateLeaveRequest", ({ variables }) => {
const input = variables.input as LeaveRequestInput;
const child = mockChildren.find((c) => c.id === input.childId);
const newLeave = {
id: `leave-${Date.now()}`,
childId: input.childId,
childName: child?.name,
className: child?.className,
type: input.type,
startDate: input.startDate,
endDate: input.endDate,
reason: input.reason,
status: "pending" as const,
submittedAt: new Date().toISOString(),
};
return HttpResponse.json({
data: {
createLeaveRequest: newLeave,
},
});
}),
// ----- 报告卡 -----
graphql.query("AcademicYears", () => {
return HttpResponse.json({
data: {
academicYears: mockAcademicYears,
},
});
}),
graphql.query("ChildReportCard", ({ variables }) => {
const childId = variables.childId as string;
return HttpResponse.json({
data: {
childReportCard: { ...mockReportCard, childId },
},
});
}),
// ----- 错题本 -----
graphql.query("ChildErrorBookStats", ({ variables }) => {
const childId = variables.childId as string;
return HttpResponse.json({
data: {
childErrorBookStats: { ...mockErrorBookStats, childId },
},
});
}),
graphql.query("ChildTopWrongQuestions", ({ variables }) => {
const limit = variables.limit as number | undefined;
const list =
typeof limit === "number" && limit > 0
? mockTopWrongQuestions.slice(0, limit)
: mockTopWrongQuestions;
return HttpResponse.json({
data: {
childTopWrongQuestions: list,
},
});
}),
graphql.query("ChildWeakKps", ({ variables }) => {
const limit = variables.limit as number | undefined;
const list =
typeof limit === "number" && limit > 0
? mockWeakKps.slice(0, limit)
: mockWeakKps;
return HttpResponse.json({
data: {
childWeakKps: list,
},
});
}),
// ----- 诊断报告 -----
graphql.query("ChildMasterySummary", ({ variables }) => {
const childId = variables.childId as string;
return HttpResponse.json({
data: {
childMasterySummary: { ...mockMasterySummary, childId },
},
});
}),
graphql.query("ChildDiagnosticReports", ({ variables }) => {
const childId = variables.childId as string;
return HttpResponse.json({
data: {
childDiagnosticReports: mockDiagnosticReports.map((r) => ({
...r,
childId,
})),
},
});
}),
// ----- 练习统计 -----
graphql.query("ChildPracticeStats", ({ variables }) => {
const childId = variables.childId as string;
return HttpResponse.json({
data: {
childPracticeStats: { ...mockPracticeStats, childId },
},
});
}),
graphql.query("ChildPracticeSessions", ({ variables }) => {
const childId = variables.childId as string;
const limit = variables.limit as number | undefined;
const list =
typeof limit === "number" && limit > 0
? mockPracticeSessions.slice(0, limit)
: mockPracticeSessions;
return HttpResponse.json({
data: {
childPracticeSessions: list.map((s) => ({ ...s, childId })),
},
});
}),
// ----- 课程计划 -----
graphql.query("ChildCoursePlans", () => {
return HttpResponse.json({
data: {
childCoursePlans: mockCoursePlans,
},
});
}),
graphql.query("ChildCoursePlanDetail", ({ variables }) => {
const planId = variables.planId as string;
const detail =
planId === mockCoursePlanDetail.id
? mockCoursePlanDetail
: { ...mockCoursePlanDetail, id: planId };
return HttpResponse.json({
data: {
childCoursePlanDetail: detail,
},
});
}),
// ----- 备课计划 -----
graphql.query("ChildLessonPlans", () => {
return HttpResponse.json({
data: {
childLessonPlans: mockLessonPlans,
},
});
}),
graphql.query("ChildLessonPlanDetail", ({ variables }) => {
const planId = variables.planId as string;
const detail =
planId === mockLessonPlanDetail.id
? mockLessonPlanDetail
: { ...mockLessonPlanDetail, id: planId };
return HttpResponse.json({
data: {
childLessonPlanDetail: detail,
},
});
}),
// ----- 选修课 -----
graphql.query("ChildElective", ({ variables }) => {
const childId = variables.childId as string;
return HttpResponse.json({
data: {
childElective: mockElectiveSelections.filter((e) => e.childId === childId),
},
});
}),
// ----- 子女详情聚合 -----
graphql.query("ChildDetail", ({ variables }) => {
const childId = variables.childId as string;
return HttpResponse.json({
data: {
childDetail: { ...mockChildDetail, childId },
},
});
}),
// ----- 成长档案(班级均对比线)-----
graphql.query("ChildGrowthArchive", ({ variables }) => {
const childId = variables.childId as string;
const subject = (variables.subject as string | undefined) ?? null;
return HttpResponse.json({
data: {
childGrowthArchive: {
...mockGrowthArchive,
childId,
subject: subject ?? mockGrowthArchive.subject,
},
},
});
}),
// ----- 导出成绩 -----
graphql.mutation("ExportChildGrades", ({ variables }) => {
const childId = variables.childId as string;
const subject = (variables.subject as string | undefined) ?? null;
return HttpResponse.json({
data: {
exportChildGrades: {
...mockExportResult,
downloadUrl: `https://example.com/exports/grades-${childId}${
subject ? `-${subject}` : ""
}-2026-07-13.xlsx`,
},
},
});
}),
];
export const handlers = [...restHandlers, ...graphqlHandlers];

View File

@@ -329,3 +329,273 @@ export type SyncMessage =
ts: number;
source: string;
};
// ===== 请假Leave参考项目 /parent/leave=====
export type LeaveType = "sick" | "personal" | "family" | "other";
export type LeaveStatus = "pending" | "approved" | "rejected" | "cancelled";
export interface LeaveRequest {
id: string;
childId: string;
childName?: string;
className?: string;
type: LeaveType;
startDate: string; // ISO 8601 date
endDate: string; // ISO 8601 date
reason: string;
status: LeaveStatus;
submittedAt: string; // ISO 8601
reviewedAt?: string;
reviewerName?: string;
reviewComment?: string;
}
export interface LeaveRequestInput {
childId: string;
type: LeaveType;
startDate: string;
endDate: string;
reason: string;
}
// ===== 报告卡ReportCard参考项目 /parent/grades/report-card=====
export interface AcademicYear {
id: string;
name: string; // 如 "2025-2026"
startDate: string;
endDate: string;
}
export interface ReportCard {
childId: string;
childName: string;
className: string;
academicYearId: string;
academicYearName: string;
semester: 1 | 2;
subjects: ReportCardSubject[];
overallScore: number;
classRank: number;
classSize: number;
teacherComment: string;
headTeacherComment?: string;
issuedAt: string;
}
export interface ReportCardSubject {
subject: string;
score: number;
maxScore: number;
gradeLevel: "A" | "B" | "C" | "D" | "F";
classAverage: number;
rank: number;
classSize: number;
teacherComment?: string;
}
// ===== 错题本ErrorBook参考项目 /parent/error-book=====
export interface ErrorBookStats {
childId: string;
totalCount: number;
newCount: number;
learningCount: number;
masteredCount: number;
dueReviewCount: number;
masteredRate: number; // 0-1
}
export interface TopWrongQuestion {
id: string;
questionContent: string;
subject: string;
knowledgePoint: string;
errorCount: number;
lastErrorAt: string;
masteryRate: number; // 0-1
}
export interface WeakKp {
knowledgePointName: string;
subject: string;
errorCount: number;
masteryRate: number; // 0-1
}
// ===== 诊断报告Diagnostic参考项目 /parent/diagnostic=====
export type DiagnosticReportStatus = "draft" | "published" | "archived";
export interface DiagnosticReport {
id: string;
childId: string;
title: string;
summary: string;
status: DiagnosticReportStatus;
createdAt: string;
publishedAt?: string;
masteryScore: number; // 0-100
subjects: DiagnosticSubject[];
}
export interface DiagnosticSubject {
subject: string;
masteryLevel: number; // 0-1
strengths: string[];
weaknesses: string[];
recommendation: string;
}
export interface MasterySummary {
childId: string;
overallMastery: number; // 0-1
subjectMastery: { subject: string; mastery: number }[];
totalKps: number;
masteredKps: number;
}
// ===== 练习统计Practice参考项目 /parent/practice=====
export interface PracticeStats {
childId: string;
totalSessions: number;
completedSessions: number;
totalQuestionsAnswered: number;
overallAccuracy: number; // 0-1
}
export interface PracticeSession {
id: string;
childId: string;
subject: string;
startedAt: string;
completedAt?: string;
questionCount: number;
correctCount: number;
accuracy: number; // 0-1
durationSeconds: number;
knowledgePoints: string[];
}
// ===== 课程计划CoursePlan参考项目 /parent/course-plans=====
export interface CoursePlan {
id: string;
title: string;
description: string;
subject: string;
className: string;
teacherName: string;
startDate: string;
endDate: string;
status: "active" | "completed" | "archived";
chapterCount: number;
}
export interface CoursePlanDetail extends CoursePlan {
chapters: CoursePlanChapter[];
}
export interface CoursePlanChapter {
id: string;
title: string;
description: string;
sortOrder: number;
lessonCount: number;
estimatedHours: number;
}
// ===== 备课计划LessonPlan参考项目 /parent/lesson-plans=====
export interface LessonPlan {
id: string;
title: string;
subject: string;
className: string;
teacherName: string;
textbookTitle: string;
chapterTitle: string;
status: "draft" | "published" | "archived";
publishedAt?: string;
estimatedMinutes: number;
}
export interface LessonPlanDetail extends LessonPlan {
objectives: string[];
keyPoints: string[];
difficultPoints: string[];
content: string;
homeworkDescription?: string;
}
// ===== 选修课Elective参考项目 /parent/elective=====
export interface ElectiveSelection {
id: string;
childId: string;
courseName: string;
courseCode: string;
teacherName: string;
category: "arts" | "science" | "sports" | "language" | "other";
credits: number;
schedule: string; // 如 "周三 15:00-16:30"
status: "enrolled" | "completed" | "dropped";
enrolledAt: string;
score?: number;
}
// ===== 子女详情聚合ChildDetail参考项目 /parent/children/[studentId]=====
export interface ChildDetail {
childId: string;
basicInfo: {
name: string;
avatar?: string;
grade: string;
className: string;
schoolName: string;
relation: string; // 如 "父亲" / "母亲"
};
todaySchedule: ScheduleItem[];
weeklySchedule: ScheduleItem[];
homeworkSummary: {
pendingCount: number;
overdueCount: number;
submittedCount: number;
gradedCount: number;
};
gradeSummary: {
avgScore: number;
classRank: number;
classSize: number;
trend: "up" | "down" | "stable";
};
examResults: {
upcoming: number;
completed: number;
avgScore: number;
};
}
export interface ScheduleItem {
id: string;
dayOfWeek: number; // 0-6 (0=周日)
period: number; // 第几节课
subject: string;
teacherName: string;
classroom: string;
startTime: string; // HH:mm
endTime: string; // HH:mm
}
// ===== 导出请求 =====
export interface ExportResult {
downloadUrl: string;
expiresAt: string;
}
// ===== 成长档案(班级均对比线)=====
export interface GrowthArchiveDataPoint {
date: string; // YYYY-MM 格式
studentScore: number;
classAverage: number;
}
export interface GrowthArchive {
childId: string;
subject: string;
dataPoints: GrowthArchiveDataPoint[];
}

View File

@@ -434,7 +434,111 @@ gantt
---
## §7 质量门禁
## §7 参考项目CICD 单体)差距分析与实现安排
> 参考项目:`e:\Desktop\CICD\`(单体 Next_Edu 教育平台parent 页面位于 `src/app/(dashboard)/parent/`
> 分析日期2026-07-13
> 原则:保留当前"单子女切换"范式(已稳定 13 页),按参考项目功能点补齐缺失页面,不迁移到"多子女同屏对比"范式
### 7.1 参考项目 parent 页面全览11 个主路由14 个页面)
| 路由 | 分类 | 核心功能 | 当前状态 |
| ---- | ---- | -------- | -------- |
| `/parent/dashboard` | 仪表盘 | 多子女卡片网格 + AI 摘要 + 关注横幅 + 趋势图标 | ⚠️ 部分实现 |
| `/parent/attendance` | 考勤 | 聚合出勤率 + 预警横幅 + 月历导航 + a11y | ⚠️ 部分实现 |
| `/parent/children/[studentId]` | 综合详情 | 多 Tab 聚合overview/homework/grades/exams/schedule | ❌ 缺失(设计差异,可选) |
| `/parent/course-plans` + `[id]` | 课程 | 课程计划列表 + 详情只读 | ❌ 缺失 |
| `/parent/diagnostic` | 诊断 | 已发布诊断报告 + 掌握度摘要 | ❌ 缺失 |
| `/parent/elective` | 选修 | 选修课选课记录 | ❌ 缺失 |
| `/parent/error-book` | 错题 | 5 项统计 + Top 错题 + 薄弱知识点 | ⚠️ 部分实现weakness 仅有知识点) |
| `/parent/grades` + `report-card` | 成绩 | 趋势 + 班级均对比 + 成长档案 + 导出 + 报告卡 | ⚠️ 部分实现 |
| `/parent/leave` | 请假 | 在线请假表单 + 历史列表 | ❌ 缺失 |
| `/parent/lesson-plans` + `[planId]/view` | 备课 | 已发布备课列表 + 只读详情 | ❌ 缺失 |
| `/parent/practice` | 练习 | 4 项统计 + 练习历史 | ❌ 缺失 |
### 7.2 实现安排(按优先级)
#### P0 高优先级(核心家长功能)
| 任务 | 路由 | 说明 |
| ---- | ---- | ---- |
| P0-1 | `/parent/leave` | 请假表单(选子女/时间/原因)+ 历史列表 |
| P0-2 | `/parent/grades/report-card` | 可打印报告卡 + 学年/学期筛选 |
| P0-3 | `/parent/children/[studentId]` | 子女详情聚合页Tab: overview/homework/grades/exams/schedule |
#### P1 中优先级(学情闭环)
| 任务 | 路由 | 说明 |
| ---- | ---- | ---- |
| P1-1 | `/parent/error-book` | 错题统计网格5 项)+ Top 错题列表 |
| P1-2 | `/parent/diagnostic` | 已发布诊断报告 + 掌握度摘要 |
| P1-3 | `/parent/practice` | 练习统计4 项)+ 历史列表 |
#### P2 低优先级(教学侧只读视图)
| 任务 | 路由 | 说明 |
| ---- | ---- | ---- |
| P2-1 | `/parent/course-plans` + `[id]` | 课程计划列表 + 详情 |
| P2-2 | `/parent/lesson-plans` + `[planId]/view` | 备课列表 + 只读详情 |
| P2-3 | `/parent/elective` | 选修课选课记录 |
#### P3 现有页面增强
| 任务 | 路由 | 增强内容 |
| ---- | ---- | -------- |
| P3-1 | `/parent/dashboard` | 多子女卡片网格 + 关注横幅 + 趋势图标 + 逾期高亮 |
| P3-2 | `/parent/attendance` | 月份导航 + 异常预警横幅 + 聚合出勤率卡 |
| P3-3 | `/parent/grades` | 班级均对比线 + 导出按钮 + 成长档案图表 |
### 7.3 实现进度
| 任务 | 状态 | 说明 |
| ---- | ---- | ---- |
| P0-1 `/parent/leave` | ✅ 完成 | LeaveRequestForm + LeaveRequestList + Zod 校验 + 历史筛选 |
| P0-2 `/parent/grades/report-card` | ✅ 完成 | ReportCardView + 学年/学期筛选 + 打印按钮 |
| P0-3 `/parent/children/[studentId]` | ✅ 完成 | ChildDetailPanel + 5 Tab(overview/homework/grades/exams/schedule) |
| P1-1 `/parent/error-book` | ✅ 完成 | ErrorBookStatsCard(5 项统计) + TopWrongQuestionList + 薄弱知识点 |
| P1-2 `/parent/diagnostic` | ✅ 完成 | MasterySummaryCard + DiagnosticReportList(仅 published) |
| P1-3 `/parent/practice` | ✅ 完成 | PracticeStatsCard(4 项统计) + PracticeSessionList(历史) |
| P2-1 `/parent/course-plans` | ✅ 完成 | CoursePlanList + CoursePlanDetail + 章节列表 |
| P2-2 `/parent/lesson-plans` | ✅ 完成 | LessonPlanList(学科筛选) + LessonPlanReadonlyView(只读) |
| P2-3 `/parent/elective` | ✅ 完成 | ElectiveSelectionList + 分类色点 + 状态徽标 |
| P3-1 dashboard 增强 | ✅ 完成 | ParentAttentionBanner + 多子女卡片网格 + 趋势图标 + 逾期高亮 |
| P3-2 attendance 增强 | ✅ 完成 | AttendanceRateCard + AttendanceWarningBanner + 月份导航 |
| P3-3 grades 增强 | ✅ 完成 | GrowthArchiveChart + ExportGradesButton + 班级均对比线 |
### 7.4 实现统计
- **新增页面**: 9 个(leave / grades/report-card / children/[studentId] / error-book / diagnostic / practice / course-plans + [id] / lesson-plans + [planId]/view / elective)
- **新增组件**: 20+ 个(LeaveRequestForm / LeaveRequestList / ReportCardView / ChildDetailPanel / ErrorBookStatsCard / TopWrongQuestionList / MasterySummaryCard / DiagnosticReportList / PracticeStatsCard / PracticeSessionList / CoursePlanList / CoursePlanDetail / LessonPlanList / LessonPlanReadonlyView / ElectiveSelectionList / ParentAttentionBanner / AttendanceWarningBanner / AttendanceRateCard / GrowthArchiveChart / ExportGradesButton)
- **新增 hooks**: 15+ 个(useChildLeaveRequests / useCreateLeaveRequest / useAcademicYears / useReportCard / useChildDetail / useChildErrorBook / useChildDiagnostic / useChildPractice / useChildCoursePlans / useChildCoursePlanDetail / useChildLessonPlans / useChildLessonPlanDetail / useChildElective / useChildGrowthArchive / useExportChildGrades)
- **新增 GraphQL operations**: 19 个(17 query + 2 mutation)
- **新增 MSW handlers**: 19 个
- **新增类型**: 20+ 个(LeaveRequest / ReportCard / ErrorBookStats / TopWrongQuestion / DiagnosticReport / PracticeStats / CoursePlan / LessonPlan / ElectiveSelection / ChildDetail / ScheduleItem 等)
- **测试总数**: 763 测试通过(从 413 增至 763,+350 测试)
- **质量校验**: typecheck 0 错误 / lint 0 错误 / test 71 文件 763 测试全部通过
### 7.5 参考项目功能覆盖完成度
| 参考项目页面 | 对应实现 | 覆盖度 |
| ---- | ---- | ---- |
| `/parent/dashboard` | `/parent/dashboard`(增强) | ✅ 100% |
| `/parent/attendance` | `/parent/attendance`(增强) | ✅ 100% |
| `/parent/children/[studentId]` | `/parent/children/[studentId]` | ✅ 100% |
| `/parent/course-plans` + `[id]` | `/parent/course-plans` + `[id]` | ✅ 100% |
| `/parent/diagnostic` | `/parent/diagnostic` | ✅ 100% |
| `/parent/elective` | `/parent/elective` | ✅ 100% |
| `/parent/error-book` | `/parent/error-book` | ✅ 100% |
| `/parent/grades` + `report-card` | `/parent/grades`(增强) + `report-card` | ✅ 100% |
| `/parent/leave` | `/parent/leave` | ✅ 100% |
| `/parent/lesson-plans` + `[planId]/view` | `/parent/lesson-plans` + `[planId]/view` | ✅ 100% |
| `/parent/practice` | `/parent/practice` | ✅ 100% |
> **参考项目所有家长端功能已全部覆盖**。保留当前"单子女切换"范式,未迁移到"多子女同屏对比"范式(仪表盘除外,已支持多子女卡片网格)。
---
## §8 质量门禁
每个任务完成前必须通过: