feat(student-portal): 实现CICD参考项目差距功能 - 错题本/请假/选课/自适应练习/公告/课案/课程计划/成绩报告卡/学情诊断增强

This commit is contained in:
SpecialX
2026-07-13 13:39:28 +08:00
parent 7c8e0f5dea
commit f13ca612e6
29 changed files with 6012 additions and 24 deletions

View File

@@ -0,0 +1,104 @@
"use client";
/**
* AnnouncementDetail — 公告详情(批次 A
*
* 数据源useAnnouncementDetail + useMarkAnnouncementRead
*/
import { useEffect } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useAnnouncementDetail, useMarkAnnouncementRead } from "@/lib/graphql/hooks";
import type { AnnouncementType } from "@/lib/graphql/types";
const TYPE_LABELS: Record<AnnouncementType, string> = {
school: "校级",
grade: "年级",
class: "班级",
};
export default function AnnouncementDetailPage(): JSX.Element {
const params = useParams();
const announcementId = params["id"] as string;
const { data, fetching, error } = useAnnouncementDetail(announcementId);
const { execute: markRead } = useMarkAnnouncementRead();
useEffect(() => {
if (data && !data.isReadByCurrentUser) {
markRead({ announcementId: data.id });
}
}, [data, announcementId, markRead]);
if (fetching) {
return (
<div className="px-6 lg:px-10 py-8 max-w-3xl mx-auto">
<p className="text-sm" style={{ color: "var(--color-ink-muted)" }} aria-live="polite">
</p>
</div>
);
}
if (error) {
return (
<div className="px-6 lg:px-10 py-8 max-w-3xl mx-auto">
<div role="alert" className="card-paper p-6" style={{ borderColor: "var(--color-danger)" }}>
<p className="text-sm" style={{ color: "var(--color-danger)" }}>
{error.message}
</p>
</div>
</div>
);
}
if (!data) {
return (
<div className="px-6 lg:px-10 py-8 max-w-3xl mx-auto">
<div className="card-paper p-8 text-center">
<p className="text-base font-serif" style={{ color: "var(--color-ink)" }}>
</p>
<Link href="/announcements" className="mt-4 inline-block btn-secondary text-xs">
</Link>
</div>
</div>
);
}
return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-3xl mx-auto">
<Link href="/announcements" className="text-xs" style={{ color: "var(--color-ink-muted)" }}>
</Link>
<article className="mt-4">
<header className="mb-6">
<div className="flex items-center gap-2 mb-2">
<span className="badge badge-info">{TYPE_LABELS[data.type]}</span>
{data.isPinned && <span className="badge badge-warning"></span>}
</div>
<h1 className="text-2xl lg:text-3xl font-serif" style={{ color: "var(--color-ink)" }}>
{data.title}
</h1>
<div className="mt-3 flex items-center gap-4 text-xs" style={{ color: "var(--color-ink-muted)" }}>
<span>{data.authorName ?? "—"}</span>
{data.publishedAt && (
<span>{new Date(data.publishedAt).toLocaleString("zh-CN")}</span>
)}
</div>
</header>
<div className="rule-thin mb-6" />
<div className="prose-sm">
<p className="text-sm leading-relaxed whitespace-pre-wrap" style={{ color: "var(--color-ink)" }}>
{data.content}
</p>
</div>
</article>
</div>
);
}

View File

@@ -0,0 +1,162 @@
"use client";
/**
* Announcements — 公告列表(批次 A
*
* 数据源useAnnouncements
* 视图:公告列表(置顶标记 + 已读/未读状态 + 类型筛选)
*/
import { useMemo, useState } from "react";
import Link from "next/link";
import { useAnnouncements } from "@/lib/graphql/hooks";
import type { AnnouncementType } from "@/lib/graphql/types";
const TYPE_LABELS: Record<AnnouncementType, string> = {
school: "校级",
grade: "年级",
class: "班级",
};
export default function AnnouncementsPage(): JSX.Element {
const { data, fetching, error, reexecute } = useAnnouncements();
const [typeFilter, setTypeFilter] = useState<string>("all");
const announcements = data ?? [];
const filtered = useMemo(() => {
const list = typeFilter === "all" ? announcements : announcements.filter((a) => a.type === typeFilter);
return [...list].sort((a, b) => {
if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1;
const ta = a.publishedAt ? new Date(a.publishedAt).getTime() : 0;
const tb = b.publishedAt ? new Date(b.publishedAt).getTime() : 0;
return tb - ta;
});
}, [announcements, typeFilter]);
return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-5xl mx-auto">
<header className="mb-6">
<p className="text-xs uppercase tracking-wider" style={{ color: "var(--color-ink-muted)" }}>
</p>
<h1 className="mt-2 text-3xl lg:text-4xl font-serif" style={{ color: "var(--color-ink)" }}>
</h1>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
</p>
</header>
<div className="rule-thin mb-6" />
<div className="flex items-center gap-3 mb-6">
{(["all", "school", "grade", "class"] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => setTypeFilter(t)}
className="px-3 py-1.5 rounded-sm text-xs transition-colors"
style={{
background: typeFilter === t ? "var(--color-accent-muted)" : "transparent",
border: `1px solid ${typeFilter === t ? "var(--color-accent)" : "var(--color-rule)"}`,
color: typeFilter === t ? "var(--color-accent)" : "var(--color-ink-muted)",
}}
>
{t === "all" ? "全部" : TYPE_LABELS[t]}
</button>
))}
</div>
{fetching ? (
<LoadingSkeleton />
) : error ? (
<ErrorState message={error.message} onRetry={() => reexecute()} />
) : filtered.length === 0 ? (
<EmptyState />
) : (
<ul className="space-y-3">
{filtered.map((a) => (
<li key={a.id}>
<Link href={`/announcements/${a.id}`} className="block">
<article
className="card-paper p-4 transition-opacity hover:opacity-80"
style={{
borderLeft: a.isPinned ? "3px solid var(--color-accent)" : "1px solid var(--color-rule)",
}}
>
<div className="flex items-start justify-between gap-4 mb-1">
<div className="flex items-center gap-2">
<span className="badge badge-info">{TYPE_LABELS[a.type]}</span>
{a.isPinned && (
<span className="badge badge-warning"></span>
)}
{!a.isReadByCurrentUser && (
<span className="inline-block w-2 h-2 rounded-full" style={{ background: "var(--color-accent)" }} aria-label="未读" />
)}
</div>
<span className="text-xs" style={{ color: "var(--color-ink-muted)" }}>
{a.publishedAt ? new Date(a.publishedAt).toLocaleDateString("zh-CN") : "—"}
</span>
</div>
<p className="text-sm font-medium" style={{ color: "var(--color-ink)" }}>
{a.title}
</p>
<p className="mt-1 text-xs text-truncate" style={{ color: "var(--color-ink-muted)" }}>
{a.content.slice(0, 80)}...
</p>
<p className="mt-1 text-xs" style={{ color: "var(--color-ink-subtle)" }}>
{a.authorName ?? "—"}
</p>
</article>
</Link>
</li>
))}
</ul>
)}
</div>
);
}
function LoadingSkeleton(): JSX.Element {
return (
<div className="space-y-3" aria-busy="true" aria-live="polite">
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className="h-20 animate-pulse rounded-sm"
style={{ background: "var(--color-rule)", opacity: 0.3 }}
/>
))}
<span className="sr-only">...</span>
</div>
);
}
function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }): JSX.Element {
return (
<div role="alert" className="card-paper p-8 text-center" style={{ borderColor: "var(--color-danger)" }}>
<p className="text-base font-serif" style={{ color: "var(--color-danger)" }}>
</p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
{message}
</p>
<button type="button" onClick={onRetry} className="mt-4 btn-secondary text-xs">
</button>
</div>
);
}
function EmptyState(): JSX.Element {
return (
<div className="card-paper p-8 text-center">
<p className="text-4xl font-serif" style={{ color: "var(--color-ink-subtle)" }} aria-hidden="true">
</p>
<p className="mt-4 text-base font-serif" style={{ color: "var(--color-ink)" }}>
</p>
</div>
);
}

View File

@@ -0,0 +1,221 @@
"use client";
/**
* CoursePlanDetail — 课程计划详情(批次 A
*
* 数据源useCoursePlanDetail
* 视图:计划信息 + 周次条目列表(完成状态标记)
*/
import Link from "next/link";
import { useParams } from "next/navigation";
import { useCoursePlanDetail } from "@/lib/graphql/hooks";
import type { CoursePlanStatus, CoursePlanSemester } from "@/lib/graphql/types";
const STATUS_LABELS: Record<CoursePlanStatus, string> = {
planning: "规划中",
active: "进行中",
completed: "已完成",
paused: "已暂停",
};
const SEMESTER_LABELS: Record<CoursePlanSemester, string> = {
"1": "上学期",
"2": "下学期",
};
export default function CoursePlanDetailPage(): JSX.Element {
const params = useParams();
const planId = params["id"] as string;
const { data, fetching, error } = useCoursePlanDetail(planId);
if (fetching) {
return (
<div className="px-6 lg:px-10 py-8 max-w-4xl mx-auto">
<p className="text-sm" style={{ color: "var(--color-ink-muted)" }} aria-live="polite">
</p>
</div>
);
}
if (error) {
return (
<div className="px-6 lg:px-10 py-8 max-w-4xl mx-auto">
<div role="alert" className="card-paper p-6" style={{ borderColor: "var(--color-danger)" }}>
<p className="text-sm" style={{ color: "var(--color-danger)" }}>
{error.message}
</p>
</div>
</div>
);
}
if (!data) {
return (
<div className="px-6 lg:px-10 py-8 max-w-4xl mx-auto">
<div className="card-paper p-8 text-center">
<p className="text-base font-serif" style={{ color: "var(--color-ink)" }}>
</p>
<Link href="/course-plans" className="mt-4 inline-block btn-secondary text-xs">
</Link>
</div>
</div>
);
}
const progress = data.totalHours > 0 ? (data.completedHours / data.totalHours) * 100 : 0;
return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-4xl mx-auto">
<Link href="/course-plans" className="text-xs" style={{ color: "var(--color-ink-muted)" }}>
</Link>
<header className="mt-4 mb-6">
<div className="flex items-center gap-2 mb-2">
{data.subjectName && <span className="badge badge-info">{data.subjectName}</span>}
<span className="text-xs" style={{ color: "var(--color-ink-muted)" }}>
{data.className ?? "—"} · {SEMESTER_LABELS[data.semester]}
</span>
</div>
<h1 className="text-2xl lg:text-3xl font-serif" style={{ color: "var(--color-ink)" }}>
{data.subjectName ?? "课程计划"} · {data.teacherName ?? "—"}
</h1>
</header>
<div className="rule-thin mb-6" />
<section className="card-paper p-6 mb-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm mb-4">
<div>
<p className="text-xs uppercase tracking-wider" style={{ color: "var(--color-ink-muted)" }}>
</p>
<p className="mt-1 font-serif text-lg" style={{ color: "var(--color-ink)" }}>
{data.totalHours}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wider" style={{ color: "var(--color-ink-muted)" }}>
</p>
<p className="mt-1 font-serif text-lg" style={{ color: "var(--color-ink)" }}>
{data.completedHours}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wider" style={{ color: "var(--color-ink-muted)" }}>
</p>
<p className="mt-1 font-serif text-lg" style={{ color: "var(--color-ink)" }}>
{data.weeklyHours}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wider" style={{ color: "var(--color-ink-muted)" }}>
</p>
<p className="mt-1 text-sm" style={{ color: "var(--color-ink)" }}>
{STATUS_LABELS[data.status]}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex-1 h-1.5 rounded-sm overflow-hidden" style={{ background: "var(--color-rule)" }}>
<div
className="h-full"
style={{ width: `${Math.min(progress, 100)}%`, background: "var(--color-accent)" }}
/>
</div>
<span className="text-xs" style={{ color: "var(--color-ink-muted)" }}>
{Math.round(progress)}%
</span>
</div>
</section>
{data.syllabus && (
<section className="mb-6">
<h2 className="text-sm uppercase tracking-wider mb-2" style={{ color: "var(--color-ink-muted)" }}>
</h2>
<div className="card-paper p-4">
<p className="text-sm leading-relaxed whitespace-pre-wrap" style={{ color: "var(--color-ink)" }}>
{data.syllabus}
</p>
</div>
</section>
)}
{data.objectives && (
<section className="mb-6">
<h2 className="text-sm uppercase tracking-wider mb-2" style={{ color: "var(--color-ink-muted)" }}>
</h2>
<div className="card-paper p-4">
<p className="text-sm leading-relaxed whitespace-pre-wrap" style={{ color: "var(--color-ink)" }}>
{data.objectives}
</p>
</div>
</section>
)}
<section>
<h2 className="text-sm uppercase tracking-wider mb-3" style={{ color: "var(--color-ink-muted)" }}>
</h2>
<ul className="space-y-2">
{data.items.map((item) => (
<li key={item.id}>
<article
className="card-paper p-4"
style={{
borderLeft: item.isCompleted ? "3px solid var(--color-success)" : "1px solid var(--color-rule)",
}}
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-serif" style={{ color: "var(--color-ink)" }}>
{item.week}
</span>
<span className="text-xs" style={{ color: "var(--color-ink-muted)" }}>
{item.hours}
</span>
{item.isCompleted ? (
<span className="badge badge-success"></span>
) : (
<span className="badge badge-warning"></span>
)}
</div>
<p className="text-sm font-medium" style={{ color: "var(--color-ink)" }}>
{item.topic}
</p>
{item.content && (
<p className="mt-1 text-xs" style={{ color: "var(--color-ink-muted)" }}>
{item.content}
</p>
)}
{item.textbookChapter && (
<p className="mt-1 text-xs" style={{ color: "var(--color-ink-subtle)" }}>
{item.textbookChapter}
</p>
)}
{item.notes && (
<p className="mt-1 text-xs italic" style={{ color: "var(--color-ink-subtle)" }}>
{item.notes}
</p>
)}
</div>
</div>
</article>
</li>
))}
</ul>
</section>
</div>
);
}

View File

@@ -0,0 +1,153 @@
"use client";
/**
* CoursePlans — 课程计划列表(批次 A
*
* 数据源useMyCoursePlans
* 视图:课程计划列表(进度条 + 状态 + 周课时)
*/
import Link from "next/link";
import { useMyCoursePlans } from "@/lib/graphql/hooks";
import type { CoursePlanStatus, CoursePlanSemester } from "@/lib/graphql/types";
const STATUS_LABELS: Record<CoursePlanStatus, string> = {
planning: "规划中",
active: "进行中",
completed: "已完成",
paused: "已暂停",
};
const STATUS_BADGE: Record<CoursePlanStatus, string> = {
planning: "badge-warning",
active: "badge-success",
completed: "badge-info",
paused: "badge-warning",
};
const SEMESTER_LABELS: Record<CoursePlanSemester, string> = {
"1": "上学期",
"2": "下学期",
};
export default function CoursePlansPage(): JSX.Element {
const { data, fetching, error, reexecute } = useMyCoursePlans();
const plans = data ?? [];
return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-5xl mx-auto">
<header className="mb-6">
<p className="text-xs uppercase tracking-wider" style={{ color: "var(--color-ink-muted)" }}>
</p>
<h1 className="mt-2 text-3xl lg:text-4xl font-serif" style={{ color: "var(--color-ink)" }}>
</h1>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
</p>
</header>
<div className="rule-thin mb-8" />
{fetching ? (
<LoadingSkeleton />
) : error ? (
<ErrorState message={error.message} onRetry={() => reexecute()} />
) : plans.length === 0 ? (
<EmptyState />
) : (
<ul className="space-y-4">
{plans.map((p) => {
const progress = p.totalHours > 0 ? (p.completedHours / p.totalHours) * 100 : 0;
return (
<li key={p.id}>
<Link href={`/course-plans/${p.id}`} className="block">
<article className="card-paper p-5 transition-opacity hover:opacity-80">
<div className="flex items-start justify-between gap-4 mb-2">
<div className="flex items-center gap-2">
{p.subjectName && <span className="badge badge-info">{p.subjectName}</span>}
<span className={`badge ${STATUS_BADGE[p.status]}`}>
{STATUS_LABELS[p.status]}
</span>
<span className="text-xs" style={{ color: "var(--color-ink-muted)" }}>
{SEMESTER_LABELS[p.semester]}
</span>
</div>
<span className="text-xs" style={{ color: "var(--color-ink-muted)" }}>
{p.completedHours}/{p.totalHours}
</span>
</div>
<p className="text-sm font-medium" style={{ color: "var(--color-ink)" }}>
{p.className ?? "—"} · {p.teacherName ?? "—"}
</p>
{p.syllabus && (
<p className="mt-1 text-xs text-truncate" style={{ color: "var(--color-ink-muted)" }}>
{p.syllabus}
</p>
)}
<div className="mt-3 flex items-center gap-3">
<div className="flex-1 h-1.5 rounded-sm overflow-hidden" style={{ background: "var(--color-rule)" }}>
<div
className="h-full"
style={{ width: `${Math.min(progress, 100)}%`, background: "var(--color-accent)" }}
/>
</div>
<span className="text-xs flex-shrink-0" style={{ color: "var(--color-ink-muted)" }}>
{Math.round(progress)}%
</span>
</div>
</article>
</Link>
</li>
);
})}
</ul>
)}
</div>
);
}
function LoadingSkeleton(): JSX.Element {
return (
<div className="space-y-4" aria-busy="true" aria-live="polite">
{[0, 1, 2].map((i) => (
<div
key={i}
className="h-24 animate-pulse rounded-sm"
style={{ background: "var(--color-rule)", opacity: 0.3 }}
/>
))}
<span className="sr-only">...</span>
</div>
);
}
function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }): JSX.Element {
return (
<div role="alert" className="card-paper p-8 text-center" style={{ borderColor: "var(--color-danger)" }}>
<p className="text-base font-serif" style={{ color: "var(--color-danger)" }}>
</p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
{message}
</p>
<button type="button" onClick={onRetry} className="mt-4 btn-secondary text-xs">
</button>
</div>
);
}
function EmptyState(): JSX.Element {
return (
<div className="card-paper p-8 text-center">
<p className="text-4xl font-serif" style={{ color: "var(--color-ink-subtle)" }} aria-hidden="true">
</p>
<p className="mt-4 text-base font-serif" style={{ color: "var(--color-ink)" }}>
</p>
</div>
);
}

View File

@@ -3,9 +3,11 @@
/**
* 学情诊断页ai14P4路由 /dashboard/weakness
*
* - 消费 useMyWeakness 查询
* - 雷达图recharts按科目展示掌握度
* - 薄弱知识点卡片:科目、知识点、掌握度、建议
* - 消费 useMyWeakness 查询:科目掌握度雷达图 + 薄弱知识点卡片
* - 消费 useMyMasterySummary 查询:知识点掌握度雷达图 + 置信度等级
* - 消费 useMyDiagnosticReports 查询:诊断报告列表(优势/弱项/建议
*
* 三态覆盖:加载 / 错误 / 空
*/
import { useMemo } from "react";
@@ -18,27 +20,54 @@ import {
ResponsiveContainer,
Tooltip,
} from "recharts";
import { useMyWeakness } from "@/lib/graphql/hooks";
import type { RecommendedAction, WeaknessPoint } from "@/lib/graphql/types";
import {
useMyDiagnosticReports,
useMyMasterySummary,
useMyWeakness,
} from "@/lib/graphql/hooks";
import type {
ConfidenceLevel,
DiagnosticReport,
RecommendedAction,
WeaknessPoint,
} from "@/lib/graphql/types";
interface RadarDatum {
interface SubjectRadarDatum {
subject: string;
mastery: number;
fullMark: number;
}
interface KnowledgeRadarDatum {
knowledge: string;
mastery: number;
fullMark: number;
}
const RECOMMENDED_ACTION_LABEL: Record<RecommendedAction, string> = {
review: "建议复习",
practice: "建议练习",
mastered: "已掌握",
};
const CONFIDENCE_LEVEL_LABEL: Record<ConfidenceLevel, string> = {
low: "低置信度",
medium: "中置信度",
high: "高置信度",
};
const CONFIDENCE_LEVEL_COLOR: Record<ConfidenceLevel, string> = {
low: "var(--color-danger)",
medium: "var(--color-warning)",
high: "var(--color-success)",
};
function masteryPercent(mastery: number): number {
return Math.round(Math.max(0, Math.min(1, mastery)) * 100);
}
/** 按科目聚合平均掌握度 */
function aggregateBySubject(items: WeaknessPoint[]): RadarDatum[] {
function aggregateBySubject(items: WeaknessPoint[]): SubjectRadarDatum[] {
const map = new Map<string, { sum: number; count: number }>();
for (const item of items) {
const name = item.subject.name;
@@ -54,10 +83,49 @@ function aggregateBySubject(items: WeaknessPoint[]): RadarDatum[] {
}));
}
/** 排序诊断报告(按 createdAt 降序) */
function sortReportsByDateDesc(reports: DiagnosticReport[]): DiagnosticReport[] {
return [...reports].sort((a, b) => {
const ta = new Date(a.createdAt).getTime();
const tb = new Date(b.createdAt).getTime();
return tb - ta;
});
}
export default function WeaknessPage(): React.ReactNode {
const { data, fetching, error, reexecute } = useMyWeakness();
const items = data ?? [];
const {
data: weaknessData,
fetching: weaknessFetching,
error: weaknessError,
reexecute: reexecuteWeakness,
} = useMyWeakness();
const {
data: masteryData,
fetching: masteryFetching,
error: masteryError,
reexecute: reexecuteMastery,
} = useMyMasterySummary();
const {
data: reportsData,
fetching: reportsFetching,
error: reportsError,
reexecute: reexecuteReports,
} = useMyDiagnosticReports();
const items = weaknessData ?? [];
const radarData = useMemo(() => aggregateBySubject(items), [items]);
const masteryRadarData = useMemo<KnowledgeRadarDatum[]>(() => {
if (!masteryData) return [];
return masteryData.mastery.map((m) => ({
knowledge: m.knowledgePointName,
mastery: Math.round(Math.max(0, Math.min(100, m.masteryLevel))),
fullMark: 100,
}));
}, [masteryData]);
const sortedReports = useMemo(
() => (reportsData ? sortReportsByDateDesc(reportsData) : []),
[reportsData],
);
return (
<main
@@ -68,33 +136,135 @@ export default function WeaknessPage(): React.ReactNode {
<h1 id="weakness-title" className="font-serif text-2xl text-ink">
</h1>
<p className="mt-xs text-sm text-ink-muted"></p>
<p className="mt-xs text-sm text-ink-muted">
+
</p>
</header>
<div className="rule-thin mb-lg" />
{fetching ? (
<p className="text-sm text-ink-muted" aria-live="polite">
{/* 综合掌握度概览(来自 masterySummary */}
{masteryFetching ? (
<p className="text-sm text-ink-muted mb-lg" aria-live="polite">
</p>
) : error ? (
) : masteryError ? (
<div className="mark-left py-md px-md mb-md" aria-live="assertive">
<p className="text-sm text-ink">{error.message || "加载失败"}</p>
<p className="text-sm text-ink">
{masteryError.message || "掌握度摘要加载失败"}
</p>
<button
type="button"
className="btn-secondary mt-sm"
onClick={() => reexecute()}
onClick={() => reexecuteMastery()}
>
</button>
</div>
) : masteryData ? (
<section
className="card-paper p-lg mb-lg"
aria-label="掌握度概览"
>
<div className="flex items-center justify-between gap-md flex-wrap">
<div>
<h2 className="font-serif text-base text-ink mb-xs">
</h2>
<p className="text-xs text-ink-subtle">
{masteryData.totalKnowledgePoints}
</p>
</div>
<div className="flex items-center gap-md">
<div className="text-right">
<p className="text-3xl font-serif text-ink">
{masteryData.averageMastery}
<span className="text-base text-ink-muted">%</span>
</p>
<p className="text-xs text-ink-subtle"></p>
</div>
<span
className="badge"
style={{
color: CONFIDENCE_LEVEL_COLOR[masteryData.confidenceLevel],
borderColor: CONFIDENCE_LEVEL_COLOR[masteryData.confidenceLevel],
}}
>
{CONFIDENCE_LEVEL_LABEL[masteryData.confidenceLevel]}
</span>
</div>
</div>
</section>
) : null}
{/* 知识点掌握度雷达图(来自 masterySummary */}
{masteryData && masteryRadarData.length > 0 && (
<section
className="card-paper p-lg mb-lg"
aria-label="知识点掌握度雷达图"
>
<h2 className="font-serif text-base text-ink mb-md">
</h2>
<div style={{ width: "100%", height: 320 }}>
<ResponsiveContainer width="100%" height="100%">
<RadarChart data={masteryRadarData} outerRadius="70%">
<PolarGrid stroke="var(--color-rule)" />
<PolarAngleAxis
dataKey="knowledge"
tick={{ fill: "var(--color-ink-muted)", fontSize: 11 }}
/>
<PolarRadiusAxis
angle={90}
domain={[0, 100]}
tick={{ fill: "var(--color-ink-subtle)", fontSize: 10 }}
/>
<Radar
name="掌握度"
dataKey="mastery"
stroke="var(--color-accent)"
fill="var(--color-accent)"
fillOpacity={0.35}
/>
<Tooltip
contentStyle={{
background: "var(--color-paper)",
border: "1px solid var(--color-rule)",
borderRadius: "var(--radius-base)",
fontSize: 12,
}}
labelStyle={{ color: "var(--color-ink)" }}
/>
</RadarChart>
</ResponsiveContainer>
</div>
</section>
)}
{/* 科目掌握度雷达图(来自 weakness 聚合) */}
{weaknessFetching ? (
<p className="text-sm text-ink-muted mb-lg" aria-live="polite">
</p>
) : weaknessError ? (
<div className="mark-left py-md px-md mb-md" aria-live="assertive">
<p className="text-sm text-ink">
{weaknessError.message || "加载失败"}
</p>
<button
type="button"
className="btn-secondary mt-sm"
onClick={() => reexecuteWeakness()}
>
</button>
</div>
) : items.length === 0 ? (
<p className="text-sm text-ink-muted" role="status">
<p className="text-sm text-ink-muted mb-lg" role="status">
</p>
) : (
<>
{/* 雷达图 */}
<section
className="card-paper p-lg mb-lg"
aria-label="科目掌握度雷达图"
@@ -135,7 +305,7 @@ export default function WeaknessPage(): React.ReactNode {
</section>
{/* 薄弱知识点卡片 */}
<section aria-label="薄弱知识点列表">
<section aria-label="薄弱知识点列表" className="mb-lg">
<h2 className="font-serif text-base text-ink mb-md"></h2>
<ul className="space-y-md">
{items.map((item: WeaknessPoint) => {
@@ -177,6 +347,146 @@ export default function WeaknessPage(): React.ReactNode {
</section>
</>
)}
{/* 诊断报告列表(来自 diagnosticReports */}
<section aria-label="诊断报告列表">
<h2 className="font-serif text-base text-ink mb-md"></h2>
{reportsFetching ? (
<p className="text-sm text-ink-muted" aria-live="polite">
</p>
) : reportsError ? (
<div className="mark-left py-md px-md" aria-live="assertive">
<p className="text-sm text-ink">
{reportsError.message || "诊断报告加载失败"}
</p>
<button
type="button"
className="btn-secondary mt-sm"
onClick={() => reexecuteReports()}
>
</button>
</div>
) : sortedReports.length === 0 ? (
<p className="text-sm text-ink-muted" role="status">
</p>
) : (
<ul className="space-y-md">
{sortedReports.map((report) => (
<li key={report.id}>
<DiagnosticReportCard report={report} />
</li>
))}
</ul>
)}
</section>
</main>
);
}
/** 诊断报告卡片 */
function DiagnosticReportCard({
report,
}: {
report: DiagnosticReport;
}): JSX.Element {
const reportDate = new Date(report.createdAt);
const dateLabel = isNaN(reportDate.getTime())
? report.createdAt
: reportDate.toLocaleDateString("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
});
return (
<article className="card-paper p-md">
<div className="flex items-center justify-between gap-md mb-xs flex-wrap">
<div className="flex items-center gap-sm flex-wrap">
<span className="badge badge-info">{report.period ?? "—"}</span>
{report.overallScore !== null && (
<span
className="text-sm font-serif"
style={{ color: "var(--color-ink)" }}
>
{report.overallScore}
</span>
)}
</div>
<span className="text-xs text-ink-subtle">{dateLabel}</span>
</div>
{report.summary && (
<p className="text-sm text-ink mt-xs">{report.summary}</p>
)}
{report.strengths && report.strengths.length > 0 && (
<div className="mt-sm">
<p className="text-xs font-serif text-ink mb-xs"></p>
<ul className="space-y-xs">
{report.strengths.map((s, i) => (
<li
key={i}
className="text-xs text-ink-muted flex items-start gap-xs"
>
<span
style={{ color: "var(--color-success)" }}
aria-hidden="true"
>
</span>
<span>{s}</span>
</li>
))}
</ul>
</div>
)}
{report.weaknesses && report.weaknesses.length > 0 && (
<div className="mt-sm">
<p className="text-xs font-serif text-ink mb-xs"></p>
<ul className="space-y-xs">
{report.weaknesses.map((w, i) => (
<li
key={i}
className="text-xs text-ink-muted flex items-start gap-xs"
>
<span
style={{ color: "var(--color-warning)" }}
aria-hidden="true"
>
!
</span>
<span>{w}</span>
</li>
))}
</ul>
</div>
)}
{report.recommendations && report.recommendations.length > 0 && (
<div className="mt-sm">
<p className="text-xs font-serif text-ink mb-xs"></p>
<ul className="space-y-xs">
{report.recommendations.map((r, i) => (
<li
key={i}
className="text-xs text-ink-muted flex items-start gap-xs"
>
<span
style={{ color: "var(--color-accent)" }}
aria-hidden="true"
>
</span>
<span>{r}</span>
</li>
))}
</ul>
</div>
)}
</article>
);
}

View File

@@ -0,0 +1,238 @@
"use client";
/**
* ElectiveDetail — 选课详情(批次 A
*
* 数据源useAvailableElectiveCourses按 id 过滤)
* + useMyElectiveSelections + useSelectElectiveCourse + useDropElectiveCourse
*/
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import {
useAvailableElectiveCourses,
useMyElectiveSelections,
useSelectElectiveCourse,
useDropElectiveCourse,
} from "@/lib/graphql/hooks";
import type { ElectiveCourseStatus } from "@/lib/graphql/types";
const COURSE_STATUS_LABELS: Record<ElectiveCourseStatus, string> = {
draft: "草稿",
open: "开放选课",
closed: "已截止",
cancelled: "已取消",
};
export default function ElectiveDetailPage(): JSX.Element {
const params = useParams();
const router = useRouter();
const courseId = params["id"] as string;
const { data: courses, fetching, error } = useAvailableElectiveCourses();
const { data: selections, reexecute: reexecuteSel } = useMyElectiveSelections();
const { execute: selectExecute, fetching: selectFetching } = useSelectElectiveCourse();
const { execute: dropExecute, fetching: dropFetching } = useDropElectiveCourse();
const course = (courses ?? []).find((c) => c.id === courseId) ?? null;
const mySelection = (selections ?? []).find((s) => s.courseId === courseId) ?? null;
const isEnrolled = mySelection?.status === "enrolled" || mySelection?.status === "selected";
const handleSelect = (): void => {
selectExecute({ courseId });
reexecuteSel();
};
const handleDrop = (): void => {
dropExecute({ courseId });
reexecuteSel();
};
if (fetching) {
return (
<div className="px-6 lg:px-10 py-8 max-w-4xl mx-auto">
<p className="text-sm" style={{ color: "var(--color-ink-muted)" }} aria-live="polite">
</p>
</div>
);
}
if (error) {
return (
<div className="px-6 lg:px-10 py-8 max-w-4xl mx-auto">
<div role="alert" className="card-paper p-6" style={{ borderColor: "var(--color-danger)" }}>
<p className="text-sm" style={{ color: "var(--color-danger)" }}>
{error.message}
</p>
<button type="button" onClick={() => router.back()} className="mt-4 btn-secondary text-xs">
</button>
</div>
</div>
);
}
if (!course) {
return (
<div className="px-6 lg:px-10 py-8 max-w-4xl mx-auto">
<div className="card-paper p-8 text-center">
<p className="text-base font-serif" style={{ color: "var(--color-ink)" }}>
</p>
<Link href="/elective" className="mt-4 inline-block btn-secondary text-xs">
</Link>
</div>
</div>
);
}
const fillRate = course.capacity > 0 ? course.enrolledCount / course.capacity : 0;
const isFull = course.enrolledCount >= course.capacity;
return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-4xl mx-auto">
<Link
href="/elective"
className="text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
</Link>
<header className="mt-4 mb-6">
<div className="flex items-center gap-2 mb-2">
<span className="badge badge-info">{course.subjectName ?? "—"}</span>
<span className={`badge ${course.status === "open" ? "badge-success" : "badge-warning"}`}>
{COURSE_STATUS_LABELS[course.status]}
</span>
<span className="text-xs" style={{ color: "var(--color-ink-muted)" }}>
{course.selectionMode === "fcfs" ? "先到先得" : "抽签"}
</span>
</div>
<h1 className="text-3xl font-serif" style={{ color: "var(--color-ink)" }}>
{course.name}
</h1>
</header>
<div className="rule-thin mb-6" />
{course.description && (
<section className="mb-6">
<h2 className="text-sm uppercase tracking-wider mb-2" style={{ color: "var(--color-ink-muted)" }}>
</h2>
<p className="text-sm" style={{ color: "var(--color-ink)" }}>
{course.description}
</p>
</section>
)}
<section className="card-paper p-6 mb-6">
<dl className="grid grid-cols-2 gap-4 text-sm">
<div>
<dt className="text-xs uppercase tracking-wider" style={{ color: "var(--color-ink-muted)" }}>
</dt>
<dd className="mt-1" style={{ color: "var(--color-ink)" }}>{course.teacherName ?? "—"}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wider" style={{ color: "var(--color-ink-muted)" }}>
</dt>
<dd className="mt-1" style={{ color: "var(--color-ink)" }}>{course.classroom ?? "—"}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wider" style={{ color: "var(--color-ink-muted)" }}>
</dt>
<dd className="mt-1" style={{ color: "var(--color-ink)" }}>{course.schedule ?? "—"}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wider" style={{ color: "var(--color-ink-muted)" }}>
</dt>
<dd className="mt-1" style={{ color: "var(--color-ink)" }}>{course.credit}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wider" style={{ color: "var(--color-ink-muted)" }}>
</dt>
<dd className="mt-1" style={{ color: "var(--color-ink)" }}>
{course.startDate ?? "—"} {course.endDate ?? "—"}
</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wider" style={{ color: "var(--color-ink-muted)" }}>
</dt>
<dd className="mt-1" style={{ color: "var(--color-ink)" }}>{course.gradeName ?? "—"}</dd>
</div>
</dl>
</section>
<section className="card-paper p-6 mb-6">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-wider" style={{ color: "var(--color-ink-muted)" }}>
</p>
<p className="mt-1 text-2xl font-serif" style={{ color: "var(--color-ink)" }}>
{course.enrolledCount}<span className="text-sm" style={{ color: "var(--color-ink-muted)" }}>/{course.capacity}</span>
</p>
</div>
<div className="flex-1 max-w-xs h-2 rounded-sm overflow-hidden" style={{ background: "var(--color-rule)" }}>
<div
className="h-full"
style={{
width: `${Math.min(fillRate * 100, 100)}%`,
background: isFull ? "var(--color-danger)" : "var(--color-accent)",
}}
/>
</div>
</div>
</section>
{course.selectionStartAt && course.selectionEndAt && (
<section className="card-paper p-6 mb-6">
<h2 className="text-sm uppercase tracking-wider mb-2" style={{ color: "var(--color-ink-muted)" }}>
</h2>
<p className="text-sm" style={{ color: "var(--color-ink)" }}>
{new Date(course.selectionStartAt).toLocaleString("zh-CN")} {" "}
{new Date(course.selectionEndAt).toLocaleString("zh-CN")}
</p>
{course.dropDeadline && (
<p className="mt-1 text-xs" style={{ color: "var(--color-ink-muted)" }}>
退{new Date(course.dropDeadline).toLocaleString("zh-CN")}
</p>
)}
</section>
)}
<div className="flex gap-3">
{course.status === "open" && !isEnrolled && (
<button
type="button"
onClick={handleSelect}
disabled={isFull || selectFetching}
className="btn-primary text-sm disabled:opacity-50"
>
{selectFetching ? "选课中..." : isFull ? "已满" : "选课"}
</button>
)}
{isEnrolled && (
<button
type="button"
onClick={handleDrop}
disabled={dropFetching}
className="btn-secondary text-sm disabled:opacity-50"
>
{dropFetching ? "退选中..." : "退选"}
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,269 @@
"use client";
/**
* Elective — 选课中心(批次 A
*
* 数据源useAvailableElectiveCourses + useMyElectiveSelections
* + useSelectElectiveCourse + useDropElectiveCourse
* 视图:我的选课 + 可选课程列表(容量进度 + 选课/退选操作)
*/
import Link from "next/link";
import {
useAvailableElectiveCourses,
useMyElectiveSelections,
useSelectElectiveCourse,
useDropElectiveCourse,
} from "@/lib/graphql/hooks";
import type { CourseSelectionStatus, ElectiveCourse, ElectiveCourseStatus } from "@/lib/graphql/types";
const COURSE_STATUS_LABELS: Record<ElectiveCourseStatus, string> = {
draft: "草稿",
open: "开放选课",
closed: "已截止",
cancelled: "已取消",
};
const SELECTION_STATUS_LABELS: Record<CourseSelectionStatus, string> = {
selected: "已选",
enrolled: "已录取",
waitlist: "候补",
dropped: "已退选",
rejected: "未录取",
};
export default function ElectivePage(): JSX.Element {
const { data: courses, fetching, error, reexecute } = useAvailableElectiveCourses();
const { data: selections, reexecute: reexecuteSelections } = useMyElectiveSelections();
const { execute: selectExecute } = useSelectElectiveCourse();
const { execute: dropExecute } = useDropElectiveCourse();
const availableCourses = courses ?? [];
const mySelections = selections ?? [];
const handleSelect = (courseId: string): void => {
selectExecute({ courseId });
reexecute();
reexecuteSelections();
};
const handleDrop = (courseId: string): void => {
dropExecute({ courseId });
reexecute();
reexecuteSelections();
};
return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-7xl mx-auto">
<header className="mb-6">
<p className="text-xs uppercase tracking-wider" style={{ color: "var(--color-ink-muted)" }}>
</p>
<h1 className="mt-2 text-3xl lg:text-4xl font-serif" style={{ color: "var(--color-ink)" }}>
</h1>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
线
</p>
</header>
<div className="rule-thin mb-8" />
{mySelections.length > 0 && (
<section className="mb-8">
<h2 className="text-base font-serif mb-4" style={{ color: "var(--color-ink)" }}>
</h2>
<ul className="space-y-3">
{mySelections.map((sel) => (
<li key={sel.id}>
<article className="card-paper p-4 flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="badge badge-success">
{SELECTION_STATUS_LABELS[sel.status]}
</span>
<span className="text-xs" style={{ color: "var(--color-ink-muted)" }}>
{sel.course.credit}
</span>
</div>
<Link
href={`/elective/${sel.courseId}`}
className="text-sm font-medium hover:underline"
style={{ color: "var(--color-ink)" }}
>
{sel.courseName}
</Link>
<p className="mt-1 text-xs" style={{ color: "var(--color-ink-muted)" }}>
{sel.course.teacherName ?? "—"} · {sel.course.schedule ?? "—"}
</p>
</div>
{sel.status === "enrolled" && (
<button
type="button"
onClick={() => handleDrop(sel.courseId)}
className="btn-secondary text-xs flex-shrink-0"
>
退
</button>
)}
</article>
</li>
))}
</ul>
</section>
)}
<section>
<h2 className="text-base font-serif mb-4" style={{ color: "var(--color-ink)" }}>
</h2>
{fetching ? (
<LoadingSkeleton />
) : error ? (
<ErrorState message={error.message} onRetry={() => reexecute()} />
) : availableCourses.length === 0 ? (
<EmptyState />
) : (
<ul className="space-y-4">
{availableCourses.map((course) => (
<CourseCard
key={course.id}
course={course}
enrolled={mySelections.some(
(s) => s.courseId === course.id && (s.status === "enrolled" || s.status === "selected"),
)}
onSelect={() => handleSelect(course.id)}
/>
))}
</ul>
)}
</section>
</div>
);
}
function CourseCard({
course,
enrolled,
onSelect,
}: {
course: ElectiveCourse;
enrolled: boolean;
onSelect: () => void;
}): JSX.Element {
const fillRate = course.capacity > 0 ? course.enrolledCount / course.capacity : 0;
const isFull = course.enrolledCount >= course.capacity;
return (
<li>
<article className="card-paper p-5">
<div className="flex items-start justify-between gap-4 mb-2">
<div className="flex items-center gap-2">
<span className="badge badge-info">{course.subjectName ?? "—"}</span>
<span className={`badge ${course.status === "open" ? "badge-success" : "badge-warning"}`}>
{COURSE_STATUS_LABELS[course.status]}
</span>
</div>
<span className="text-xs" style={{ color: "var(--color-ink-muted)" }}>
{course.selectionMode === "fcfs" ? "先到先得" : "抽签"}
</span>
</div>
<Link
href={`/elective/${course.id}`}
className="text-base font-serif hover:underline"
style={{ color: "var(--color-ink)" }}
>
{course.name}
</Link>
{course.description && (
<p className="mt-1 text-sm" style={{ color: "var(--color-ink-muted)" }}>
{course.description}
</p>
)}
<div className="mt-3 grid grid-cols-2 md:grid-cols-4 gap-3 text-xs" style={{ color: "var(--color-ink-muted)" }}>
<span>{course.teacherName ?? "—"}</span>
<span>{course.classroom ?? "—"}</span>
<span>{course.schedule ?? "—"}</span>
<span>{course.credit}</span>
</div>
<div className="mt-3 flex items-center gap-3">
<div className="flex-1 h-2 rounded-sm overflow-hidden" style={{ background: "var(--color-rule)" }}>
<div
className="h-full"
style={{
width: `${Math.min(fillRate * 100, 100)}%`,
background: isFull ? "var(--color-danger)" : "var(--color-accent)",
}}
/>
</div>
<span className="text-xs flex-shrink-0" style={{ color: "var(--color-ink-muted)" }}>
{course.enrolledCount}/{course.capacity}
</span>
{course.status === "open" && !enrolled && (
<button
type="button"
onClick={onSelect}
disabled={isFull}
className="btn-primary text-xs flex-shrink-0 disabled:opacity-50"
>
{isFull ? "已满" : "选课"}
</button>
)}
{enrolled && (
<span className="text-xs flex-shrink-0" style={{ color: "var(--color-success)" }}>
</span>
)}
</div>
</article>
</li>
);
}
function LoadingSkeleton(): JSX.Element {
return (
<div className="space-y-4" aria-busy="true" aria-live="polite">
{[0, 1, 2].map((i) => (
<div
key={i}
className="h-36 animate-pulse rounded-sm"
style={{ background: "var(--color-rule)", opacity: 0.3 }}
/>
))}
<span className="sr-only">...</span>
</div>
);
}
function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }): JSX.Element {
return (
<div role="alert" className="card-paper p-8 text-center" style={{ borderColor: "var(--color-danger)" }}>
<p className="text-base font-serif" style={{ color: "var(--color-danger)" }}>
</p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
{message}
</p>
<button type="button" onClick={onRetry} className="mt-4 btn-secondary text-xs">
</button>
</div>
);
}
function EmptyState(): JSX.Element {
return (
<div className="card-paper p-8 text-center">
<p className="text-4xl font-serif" style={{ color: "var(--color-ink-subtle)" }} aria-hidden="true">
</p>
<p className="mt-4 text-base font-serif" style={{ color: "var(--color-ink)" }}>
</p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
</p>
</div>
);
}

View File

@@ -0,0 +1,341 @@
"use client";
/**
* ErrorBook — 错题本(批次 A
*
* 数据源useMyErrorBook + useUpdateErrorBookItem
* 视图:统计概览 + 学科/状态筛选 + 错题卡片列表 + SM2 复习自评弹窗
*/
import { useMemo, useState } from "react";
import { useMyErrorBook, useUpdateErrorBookItem } from "@/lib/graphql/hooks";
import type { ErrorBookItem, ErrorBookStatus } from "@/lib/graphql/types";
const STATUS_LABELS: Record<ErrorBookStatus, string> = {
new: "待学习",
learning: "学习中",
mastered: "已掌握",
archived: "已归档",
};
const STATUS_BADGE: Record<ErrorBookStatus, string> = {
new: "badge-warning",
learning: "badge-info",
mastered: "badge-success",
archived: "badge-info",
};
const REVIEW_OPTIONS = [
{ value: "again", label: "忘记", desc: "1 天后复习" },
{ value: "hard", label: "困难", desc: "3 天后复习" },
{ value: "good", label: "良好", desc: "7 天后复习" },
{ value: "easy", label: "简单", desc: "14 天后复习" },
] as const;
export default function ErrorBookPage(): JSX.Element {
const { data, fetching, error, reexecute } = useMyErrorBook();
const [subjectFilter, setSubjectFilter] = useState<string>("all");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [reviewingId, setReviewingId] = useState<string | null>(null);
const items = data?.items ?? [];
const stats = data?.stats;
const subjectOptions = useMemo(() => {
const set = new Set<string>();
items.forEach((i) => set.add(i.subject.name));
return Array.from(set);
}, [items]);
const filtered = useMemo(() => {
return items.filter((i) => {
if (subjectFilter !== "all" && i.subject.name !== subjectFilter) return false;
if (statusFilter !== "all" && i.status !== statusFilter) return false;
return true;
});
}, [items, subjectFilter, statusFilter]);
const reviewingItem = reviewingId ? items.find((i) => i.id === reviewingId) ?? null : null;
return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-7xl mx-auto">
<header className="mb-6">
<p className="text-xs uppercase tracking-wider" style={{ color: "var(--color-ink-muted)" }}>
</p>
<h1 className="mt-2 text-3xl lg:text-4xl font-serif" style={{ color: "var(--color-ink)" }}>
</h1>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
SM2 线
</p>
</header>
<div className="rule-thin mb-8" />
{fetching ? (
<LoadingSkeleton />
) : error ? (
<ErrorState message={error.message} onRetry={() => reexecute()} />
) : items.length === 0 ? (
<EmptyState />
) : (
<>
{stats && <StatsOverview stats={stats} />}
<div className="flex flex-wrap items-center gap-3 mb-6">
<Select
label="学科"
value={subjectFilter}
onChange={setSubjectFilter}
options={[{ value: "all", label: "全部学科" }, ...subjectOptions.map((s) => ({ value: s, label: s }))]}
/>
<Select
label="状态"
value={statusFilter}
onChange={setStatusFilter}
options={[
{ value: "all", label: "全部状态" },
{ value: "new", label: "待学习" },
{ value: "learning", label: "学习中" },
{ value: "mastered", label: "已掌握" },
]}
/>
</div>
<ul className="space-y-4">
{filtered.map((item) => (
<ErrorCard
key={item.id}
item={item}
onReview={() => setReviewingId(item.id)}
/>
))}
</ul>
</>
)}
{reviewingItem && (
<ReviewDialog
item={reviewingItem}
onClose={() => setReviewingId(null)}
/>
)}
</div>
);
}
function StatsOverview({ stats }: { stats: NonNullable<ReturnType<typeof useMyErrorBook>["data"]>["stats"] }): JSX.Element {
const rate = Math.round(stats.masteredRate * 100);
return (
<section className="card-paper p-6 mb-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatBox label="总错题" value={stats.totalCount} />
<StatBox label="待学习" value={stats.newCount} />
<StatBox label="学习中" value={stats.learningCount} />
<StatBox label="已掌握" value={stats.masteredCount} />
</div>
<div className="mt-4 flex items-center gap-4 text-sm" style={{ color: "var(--color-ink-muted)" }}>
<span>{stats.dueReviewCount} </span>
<span>{rate}%</span>
</div>
</section>
);
}
function StatBox({ label, value }: { label: string; value: number }): JSX.Element {
return (
<div>
<p className="text-xs uppercase tracking-wider" style={{ color: "var(--color-ink-muted)" }}>
{label}
</p>
<p className="mt-1 text-2xl font-serif" style={{ color: "var(--color-ink)" }}>
{value}
</p>
</div>
);
}
function Select({
label,
value,
onChange,
options,
}: {
label: string;
value: string;
onChange: (v: string) => void;
options: Array<{ value: string; label: string }>;
}): JSX.Element {
return (
<label className="flex items-center gap-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
{label}
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="px-3 py-1.5 rounded-sm text-sm"
style={{
background: "var(--color-paper)",
border: "1px solid var(--color-rule)",
color: "var(--color-ink)",
}}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
);
}
function ErrorCard({ item, onReview }: { item: ErrorBookItem; onReview: () => void }): JSX.Element {
const masteryPct = Math.round(item.masteryLevel * 100);
return (
<li>
<article className="card-paper p-5">
<div className="flex items-start justify-between gap-4 mb-2">
<div className="flex items-center gap-2">
<span className="badge badge-info">{item.subject.name}</span>
<span className={`badge ${STATUS_BADGE[item.status]}`}>
{STATUS_LABELS[item.status]}
</span>
</div>
<span className="text-xs" style={{ color: "var(--color-ink-muted)" }}>
{masteryPct}%
</span>
</div>
<p className="text-sm font-medium" style={{ color: "var(--color-ink)" }}>
{item.questionContent}
</p>
<p className="mt-1 text-xs" style={{ color: "var(--color-ink-muted)" }}>
{item.knowledgePointName} · {item.reviewCount}
</p>
{item.note && (
<p className="mt-2 text-xs italic" style={{ color: "var(--color-ink-subtle)" }}>
{item.note}
</p>
)}
{item.nextReviewAt && (
<p className="mt-1 text-xs" style={{ color: "var(--color-ink-muted)" }}>
{new Date(item.nextReviewAt).toLocaleDateString("zh-CN")}
</p>
)}
<div className="mt-3 flex gap-2">
<button type="button" onClick={onReview} className="btn-secondary text-xs">
</button>
</div>
</article>
</li>
);
}
function ReviewDialog({ item, onClose }: { item: ErrorBookItem; onClose: () => void }): JSX.Element {
const { execute, fetching } = useUpdateErrorBookItem();
const handleReview = (result: "again" | "hard" | "good" | "easy"): void => {
execute({ id: item.id, reviewResult: result });
onClose();
};
return (
<div
role="dialog"
aria-modal="true"
aria-label="复习自评"
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ background: "rgba(0,0,0,0.4)" }}
onClick={onClose}
>
<div
className="card-paper p-6 max-w-md w-full"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-serif mb-2" style={{ color: "var(--color-ink)" }}>
</h3>
<p className="text-sm mb-4" style={{ color: "var(--color-ink-muted)" }}>
{item.questionContent}
</p>
<div className="grid grid-cols-2 gap-3">
{REVIEW_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
disabled={fetching}
onClick={() => handleReview(opt.value)}
className="card-paper p-4 text-center transition-opacity hover:opacity-80 disabled:opacity-50"
>
<p className="text-base font-serif" style={{ color: "var(--color-ink)" }}>
{opt.label}
</p>
<p className="mt-1 text-xs" style={{ color: "var(--color-ink-muted)" }}>
{opt.desc}
</p>
</button>
))}
</div>
<button
type="button"
onClick={onClose}
className="mt-4 text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
</button>
</div>
</div>
);
}
function LoadingSkeleton(): JSX.Element {
return (
<div className="card-paper p-6" aria-busy="true" aria-live="polite">
<div className="grid grid-cols-4 gap-4 mb-6">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="h-16 animate-pulse rounded-sm" style={{ background: "var(--color-rule)", opacity: 0.3 }} />
))}
</div>
<div className="space-y-4">
{[0, 1, 2].map((i) => (
<div key={i} className="h-24 animate-pulse rounded-sm" style={{ background: "var(--color-rule)", opacity: 0.3 }} />
))}
</div>
<span className="sr-only">...</span>
</div>
);
}
function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }): JSX.Element {
return (
<div role="alert" className="card-paper p-8 text-center" style={{ borderColor: "var(--color-danger)" }}>
<p className="text-base font-serif" style={{ color: "var(--color-danger)" }}>
</p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
{message}
</p>
<button type="button" onClick={onRetry} className="mt-4 btn-secondary text-xs">
</button>
</div>
);
}
function EmptyState(): JSX.Element {
return (
<div className="card-paper p-8 text-center">
<p className="text-4xl font-serif" style={{ color: "var(--color-ink-subtle)" }} aria-hidden="true">
</p>
<p className="mt-4 text-base font-serif" style={{ color: "var(--color-ink)" }}>
</p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
便
</p>
</div>
);
}

View File

@@ -0,0 +1,354 @@
"use client";
/**
* Leave — 在线请假(批次 A
*
* 数据源useMyLeaveRequests + useCreateLeaveRequest + useCancelLeaveRequest
* 视图:请假记录列表 + 新建请假表单react-hook-form + zod 校验)
*/
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
useMyLeaveRequests,
useCreateLeaveRequest,
useCancelLeaveRequest,
} from "@/lib/graphql/hooks";
import type { LeaveRequest, LeaveStatus, LeaveType } from "@/lib/graphql/types";
const leaveSchema = z.object({
leaveType: z.enum(["sick", "personal", "family", "other"]),
startDate: z.string().min(1, "请选择开始日期"),
endDate: z.string().min(1, "请选择结束日期"),
reason: z.string().min(5, "请填写请假原因(至少 5 字)"),
});
type LeaveFormData = z.infer<typeof leaveSchema>;
const LEAVE_TYPE_LABELS: Record<LeaveType, string> = {
sick: "病假",
personal: "事假",
family: "家事假",
other: "其他",
};
const LEAVE_STATUS_LABELS: Record<LeaveStatus, string> = {
pending: "待审批",
approved: "已批准",
rejected: "已驳回",
cancelled: "已撤销",
};
const LEAVE_STATUS_BADGE: Record<LeaveStatus, string> = {
pending: "badge-warning",
approved: "badge-success",
rejected: "badge-danger",
cancelled: "badge-info",
};
export default function LeavePage(): JSX.Element {
const { data, fetching, error, reexecute } = useMyLeaveRequests();
const [showForm, setShowForm] = useState(false);
const requests = data ?? [];
return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-5xl mx-auto">
<header className="mb-6">
<p className="text-xs uppercase tracking-wider" style={{ color: "var(--color-ink-muted)" }}>
</p>
<h1 className="mt-2 text-3xl lg:text-4xl font-serif" style={{ color: "var(--color-ink)" }}>
线
</h1>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
</p>
</header>
<div className="rule-thin mb-8" />
<div className="mb-6">
<button
type="button"
onClick={() => setShowForm((v) => !v)}
className="btn-primary text-sm"
>
{showForm ? "取消" : "新建请假申请"}
</button>
</div>
{showForm && (
<LeaveForm
onSuccess={() => {
setShowForm(false);
reexecute();
}}
/>
)}
{fetching ? (
<LoadingSkeleton />
) : error ? (
<ErrorState message={error.message} onRetry={() => reexecute()} />
) : requests.length === 0 ? (
<EmptyState />
) : (
<ul className="space-y-4">
{requests.map((req) => (
<LeaveCard key={req.id} request={req} onCancelled={() => reexecute()} />
))}
</ul>
)}
</div>
);
}
function LeaveForm({ onSuccess }: { onSuccess: () => void }): JSX.Element {
const { execute, fetching, errors } = useCreateLeaveRequest();
const {
register,
handleSubmit,
reset,
formState: { errors: formErrors },
} = useForm<LeaveFormData>({
defaultValues: {
leaveType: "sick",
startDate: "",
endDate: "",
reason: "",
},
});
const onSubmit = (data: LeaveFormData): void => {
const result = leaveSchema.safeParse(data);
if (!result.success) return;
execute({
leaveType: result.data.leaveType,
startDate: result.data.startDate,
endDate: result.data.endDate,
reason: result.data.reason,
});
reset();
onSuccess();
};
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="card-paper p-6 mb-6 space-y-4"
>
<div>
<label
className="block text-xs uppercase tracking-wider mb-1"
style={{ color: "var(--color-ink-muted)" }}
>
</label>
<select
{...register("leaveType")}
className="w-full px-3 py-2 rounded-sm text-sm"
style={{
background: "var(--color-paper)",
border: "1px solid var(--color-rule)",
color: "var(--color-ink)",
}}
>
<option value="sick"></option>
<option value="personal"></option>
<option value="family"></option>
<option value="other"></option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label
className="block text-xs uppercase tracking-wider mb-1"
style={{ color: "var(--color-ink-muted)" }}
>
</label>
<input
type="date"
{...register("startDate", { required: "请选择开始日期" })}
className="w-full px-3 py-2 rounded-sm text-sm"
style={{
background: "var(--color-paper)",
border: "1px solid var(--color-rule)",
color: "var(--color-ink)",
}}
/>
{formErrors.startDate && (
<p className="mt-1 text-xs" style={{ color: "var(--color-danger)" }}>
{formErrors.startDate.message}
</p>
)}
</div>
<div>
<label
className="block text-xs uppercase tracking-wider mb-1"
style={{ color: "var(--color-ink-muted)" }}
>
</label>
<input
type="date"
{...register("endDate", { required: "请选择结束日期" })}
className="w-full px-3 py-2 rounded-sm text-sm"
style={{
background: "var(--color-paper)",
border: "1px solid var(--color-rule)",
color: "var(--color-ink)",
}}
/>
{formErrors.endDate && (
<p className="mt-1 text-xs" style={{ color: "var(--color-danger)" }}>
{formErrors.endDate.message}
</p>
)}
</div>
</div>
<div>
<label
className="block text-xs uppercase tracking-wider mb-1"
style={{ color: "var(--color-ink-muted)" }}
>
</label>
<textarea
{...register("reason", { required: "请填写请假原因", minLength: 5 })}
rows={3}
className="w-full px-3 py-2 rounded-sm text-sm"
style={{
background: "var(--color-paper)",
border: "1px solid var(--color-rule)",
color: "var(--color-ink)",
}}
/>
{formErrors.reason && (
<p className="mt-1 text-xs" style={{ color: "var(--color-danger)" }}>
{formErrors.reason.message ?? "请填写请假原因(至少 5 字)"}
</p>
)}
</div>
{errors.length > 0 && (
<p className="text-xs" style={{ color: "var(--color-danger)" }}>
{errors[0]?.message}
</p>
)}
<div className="flex gap-3">
<button type="submit" disabled={fetching} className="btn-primary text-sm disabled:opacity-50">
{fetching ? "提交中..." : "提交申请"}
</button>
</div>
</form>
);
}
function LeaveCard({
request,
onCancelled,
}: {
request: LeaveRequest;
onCancelled: () => void;
}): JSX.Element {
const { execute } = useCancelLeaveRequest();
const handleCancel = (): void => {
execute({ id: request.id });
onCancelled();
};
return (
<li>
<article className="card-paper p-5">
<div className="flex items-start justify-between gap-4 mb-2">
<div className="flex items-center gap-2">
<span className="badge badge-info">
{LEAVE_TYPE_LABELS[request.leaveType]}
</span>
<span className={`badge ${LEAVE_STATUS_BADGE[request.status]}`}>
{LEAVE_STATUS_LABELS[request.status]}
</span>
</div>
{request.status === "pending" && (
<button
type="button"
onClick={handleCancel}
className="text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
</button>
)}
</div>
<p className="text-sm" style={{ color: "var(--color-ink)" }}>
{request.startDate} {request.endDate}
</p>
<p className="mt-1 text-sm" style={{ color: "var(--color-ink-muted)" }}>
{request.reason}
</p>
{request.reviewComment && (
<p className="mt-2 text-xs italic" style={{ color: "var(--color-ink-subtle)" }}>
{request.reviewerName}{request.reviewComment}
</p>
)}
<p className="mt-2 text-xs" style={{ color: "var(--color-ink-muted)" }}>
{request.className} · {request.attendanceSynced ? "已同步" : "未同步"}
</p>
</article>
</li>
);
}
function LoadingSkeleton(): JSX.Element {
return (
<div className="space-y-4" aria-busy="true" aria-live="polite">
{[0, 1, 2].map((i) => (
<div
key={i}
className="h-28 animate-pulse rounded-sm"
style={{ background: "var(--color-rule)", opacity: 0.3 }}
/>
))}
<span className="sr-only">...</span>
</div>
);
}
function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }): JSX.Element {
return (
<div role="alert" className="card-paper p-8 text-center" style={{ borderColor: "var(--color-danger)" }}>
<p className="text-base font-serif" style={{ color: "var(--color-danger)" }}>
</p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
{message}
</p>
<button type="button" onClick={onRetry} className="mt-4 btn-secondary text-xs">
</button>
</div>
);
}
function EmptyState(): JSX.Element {
return (
<div className="card-paper p-8 text-center">
<p className="text-4xl font-serif" style={{ color: "var(--color-ink-subtle)" }} aria-hidden="true">
</p>
<p className="mt-4 text-base font-serif" style={{ color: "var(--color-ink)" }}>
</p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
"新建请假申请"
</p>
</div>
);
}

View File

@@ -0,0 +1,141 @@
"use client";
/**
* LessonPlanDetail — 课案详情(只读视图)(批次 A
*
* 数据源useLessonPlanDetail
*/
import Link from "next/link";
import { useParams } from "next/navigation";
import { useLessonPlanDetail } from "@/lib/graphql/hooks";
export default function LessonPlanDetailPage(): JSX.Element {
const params = useParams();
const planId = params["id"] as string;
const { data, fetching, error } = useLessonPlanDetail(planId);
if (fetching) {
return (
<div className="px-6 lg:px-10 py-8 max-w-3xl mx-auto">
<p className="text-sm" style={{ color: "var(--color-ink-muted)" }} aria-live="polite">
</p>
</div>
);
}
if (error) {
return (
<div className="px-6 lg:px-10 py-8 max-w-3xl mx-auto">
<div role="alert" className="card-paper p-6" style={{ borderColor: "var(--color-danger)" }}>
<p className="text-sm" style={{ color: "var(--color-danger)" }}>
{error.message}
</p>
</div>
</div>
);
}
if (!data) {
return (
<div className="px-6 lg:px-10 py-8 max-w-3xl mx-auto">
<div className="card-paper p-8 text-center">
<p className="text-base font-serif" style={{ color: "var(--color-ink)" }}>
</p>
<Link href="/lesson-plans" className="mt-4 inline-block btn-secondary text-xs">
</Link>
</div>
</div>
);
}
return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-3xl mx-auto">
<Link href="/lesson-plans" className="text-xs" style={{ color: "var(--color-ink-muted)" }}>
</Link>
<article className="mt-4">
<header className="mb-6">
<div className="flex items-center gap-2 mb-2">
{data.subjectName && <span className="badge badge-info">{data.subjectName}</span>}
{data.gradeName && (
<span className="text-xs" style={{ color: "var(--color-ink-muted)" }}>
{data.gradeName}
</span>
)}
</div>
<h1 className="text-2xl lg:text-3xl font-serif" style={{ color: "var(--color-ink)" }}>
{data.title}
</h1>
<div className="mt-3 flex items-center gap-4 text-xs" style={{ color: "var(--color-ink-muted)" }}>
<span>{data.creatorName ?? "—"}</span>
{data.chapterTitle && <span>{data.chapterTitle}</span>}
{data.publishedAt && (
<span>{new Date(data.publishedAt).toLocaleDateString("zh-CN")}</span>
)}
</div>
</header>
<div className="rule-thin mb-6" />
<div className="space-y-6">
{data.content.objectives && (
<Section title="教学目标">
<p className="text-sm leading-relaxed whitespace-pre-wrap" style={{ color: "var(--color-ink)" }}>
{data.content.objectives}
</p>
</Section>
)}
{data.content.keyPoints && (
<Section title="重点难点">
<p className="text-sm leading-relaxed whitespace-pre-wrap" style={{ color: "var(--color-ink)" }}>
{data.content.keyPoints}
</p>
</Section>
)}
{data.content.summary && (
<Section title="课堂小结">
<p className="text-sm leading-relaxed whitespace-pre-wrap" style={{ color: "var(--color-ink)" }}>
{data.content.summary}
</p>
</Section>
)}
{data.content.homework && (
<Section title="课后作业">
<p className="text-sm leading-relaxed whitespace-pre-wrap" style={{ color: "var(--color-ink)" }}>
{data.content.homework}
</p>
</Section>
)}
{data.content.blackboard && (
<Section title="板书设计">
<p className="text-sm leading-relaxed whitespace-pre-wrap" style={{ color: "var(--color-ink)" }}>
{data.content.blackboard}
</p>
</Section>
)}
</div>
</article>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }): JSX.Element {
return (
<section>
<h2 className="text-sm uppercase tracking-wider mb-2" style={{ color: "var(--color-ink-muted)" }}>
{title}
</h2>
<div className="card-paper p-4">{children}</div>
</section>
);
}

View File

@@ -0,0 +1,124 @@
"use client";
/**
* LessonPlans — 课案列表(批次 A
*
* 数据源useMyLessonPlans
* 视图:已发布课案列表(学科/章节/教师/发布时间)
*/
import Link from "next/link";
import { useMyLessonPlans } from "@/lib/graphql/hooks";
export default function LessonPlansPage(): JSX.Element {
const { data, fetching, error, reexecute } = useMyLessonPlans();
const plans = data ?? [];
return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-5xl mx-auto">
<header className="mb-6">
<p className="text-xs uppercase tracking-wider" style={{ color: "var(--color-ink-muted)" }}>
</p>
<h1 className="mt-2 text-3xl lg:text-4xl font-serif" style={{ color: "var(--color-ink)" }}>
</h1>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
</p>
</header>
<div className="rule-thin mb-8" />
{fetching ? (
<LoadingSkeleton />
) : error ? (
<ErrorState message={error.message} onRetry={() => reexecute()} />
) : plans.length === 0 ? (
<EmptyState />
) : (
<ul className="space-y-3">
{plans.map((p) => (
<li key={p.id}>
<Link href={`/lesson-plans/${p.id}/view`} className="block">
<article className="card-paper p-4 transition-opacity hover:opacity-80">
<div className="flex items-start justify-between gap-4 mb-1">
<div className="flex items-center gap-2">
{p.subjectName && <span className="badge badge-info">{p.subjectName}</span>}
{p.gradeName && (
<span className="text-xs" style={{ color: "var(--color-ink-muted)" }}>
{p.gradeName}
</span>
)}
</div>
<span className="text-xs" style={{ color: "var(--color-ink-muted)" }}>
{p.publishedAt ? new Date(p.publishedAt).toLocaleDateString("zh-CN") : "—"}
</span>
</div>
<p className="text-sm font-medium" style={{ color: "var(--color-ink)" }}>
{p.title}
</p>
{p.chapterTitle && (
<p className="mt-1 text-xs" style={{ color: "var(--color-ink-muted)" }}>
{p.chapterTitle}
</p>
)}
<p className="mt-1 text-xs" style={{ color: "var(--color-ink-subtle)" }}>
{p.creatorName ?? "—"}
</p>
</article>
</Link>
</li>
))}
</ul>
)}
</div>
);
}
function LoadingSkeleton(): JSX.Element {
return (
<div className="space-y-3" aria-busy="true" aria-live="polite">
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className="h-20 animate-pulse rounded-sm"
style={{ background: "var(--color-rule)", opacity: 0.3 }}
/>
))}
<span className="sr-only">...</span>
</div>
);
}
function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }): JSX.Element {
return (
<div role="alert" className="card-paper p-8 text-center" style={{ borderColor: "var(--color-danger)" }}>
<p className="text-base font-serif" style={{ color: "var(--color-danger)" }}>
</p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
{message}
</p>
<button type="button" onClick={onRetry} className="mt-4 btn-secondary text-xs">
</button>
</div>
);
}
function EmptyState(): JSX.Element {
return (
<div className="card-paper p-8 text-center">
<p className="text-4xl font-serif" style={{ color: "var(--color-ink-subtle)" }} aria-hidden="true">
</p>
<p className="mt-4 text-base font-serif" style={{ color: "var(--color-ink)" }}>
</p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
</p>
</div>
);
}

View File

@@ -0,0 +1,176 @@
"use client";
/**
* ReportCard — 成绩报告卡(批次 A
*
* 数据源useMyReportCard
* 视图:报告卡概览(综合平均分/排名/班级统计)+ 各科目成绩明细
*/
import { useMyReportCard } from "@/lib/graphql/hooks";
export default function ReportCardPage(): JSX.Element {
const { data, fetching, error, reexecute } = useMyReportCard();
if (fetching) {
return (
<div className="px-6 lg:px-10 py-8 max-w-5xl mx-auto">
<p className="text-sm" style={{ color: "var(--color-ink-muted)" }} aria-live="polite">
</p>
</div>
);
}
if (error) {
return (
<div className="px-6 lg:px-10 py-8 max-w-5xl mx-auto">
<div role="alert" className="card-paper p-6" style={{ borderColor: "var(--color-danger)" }}>
<p className="text-sm" style={{ color: "var(--color-danger)" }}>
{error.message}
</p>
<button type="button" onClick={() => reexecute()} className="mt-4 btn-secondary text-xs">
</button>
</div>
</div>
);
}
if (!data) {
return (
<div className="px-6 lg:px-10 py-8 max-w-5xl mx-auto">
<div className="card-paper p-8 text-center">
<p className="text-4xl font-serif" style={{ color: "var(--color-ink-subtle)" }} aria-hidden="true">
</p>
<p className="mt-4 text-base font-serif" style={{ color: "var(--color-ink)" }}>
</p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
</p>
</div>
</div>
);
}
return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-5xl mx-auto">
<header className="mb-6">
<p className="text-xs uppercase tracking-wider" style={{ color: "var(--color-ink-muted)" }}>
</p>
<h1 className="mt-2 text-3xl lg:text-4xl font-serif" style={{ color: "var(--color-ink)" }}>
</h1>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
{data.studentName} · {data.className} · {data.academicYearName ?? ""} {data.semester === "1" ? "上学期" : data.semester === "2" ? "下学期" : "全学年"}
</p>
</header>
<div className="rule-thin mb-6" />
<section className="card-paper p-6 mb-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatBox label="综合平均分" value={data.overallAverage.toFixed(1)} />
<StatBox label="班级排名" value={`${data.overallRank}/${data.classTotalStudents}`} />
<StatBox label="及格率" value={`${Math.round(data.overallPassRate * 100)}%`} />
<StatBox label="优秀率" value={`${Math.round(data.overallExcellentRate * 100)}%`} />
</div>
<p className="mt-4 text-xs" style={{ color: "var(--color-ink-muted)" }}>
{data.classTeacherName} · {new Date(data.generatedAt).toLocaleString("zh-CN")}
</p>
</section>
<section>
<h2 className="text-base font-serif mb-4" style={{ color: "var(--color-ink)" }}>
</h2>
<div className="space-y-4">
{data.subjects.map((subj) => (
<article key={subj.subjectId} className="card-paper p-5">
<div className="flex items-start justify-between gap-4 mb-3">
<div>
<p className="text-sm font-serif" style={{ color: "var(--color-ink)" }}>
{subj.subjectName}
</p>
<p className="mt-1 text-xs" style={{ color: "var(--color-ink-muted)" }}>
{subj.averageScore.toFixed(1)} · {subj.rankInSubject}/{subj.totalStudentsInSubject}
</p>
</div>
<span className="text-sm font-serif" style={{ color: "var(--color-ink)" }}>
{subj.fullScoreTotal.toFixed(1)}
</span>
</div>
{subj.records.length > 0 && (
<table className="w-full text-sm">
<thead>
<tr className="border-b" style={{ borderColor: "var(--color-rule)" }}>
<th className="text-left py-2 text-xs uppercase tracking-wider" style={{ color: "var(--color-ink-muted)" }}>
</th>
<th className="text-right py-2 text-xs uppercase tracking-wider" style={{ color: "var(--color-ink-muted)" }}>
</th>
<th className="text-center py-2 text-xs uppercase tracking-wider" style={{ color: "var(--color-ink-muted)" }}>
</th>
<th className="text-right py-2 text-xs uppercase tracking-wider" style={{ color: "var(--color-ink-muted)" }}>
</th>
</tr>
</thead>
<tbody>
{subj.records.map((r) => (
<tr key={r.id} className="border-b last:border-b-0" style={{ borderColor: "var(--color-rule)" }}>
<td className="py-2" style={{ color: "var(--color-ink)" }}>
{r.examName}
</td>
<td className="text-right py-2 font-serif" style={{ color: "var(--color-ink)" }}>
{r.score}
<span className="text-xs" style={{ color: "var(--color-ink-muted)" }}>
{" "}/ {r.maxScore}
</span>
</td>
<td className="text-center py-2">
<span className={`badge ${getBadgeClassForGrade(r.grade)}`}>
{r.grade}
</span>
</td>
<td className="text-right py-2 text-xs" style={{ color: "var(--color-ink-muted)" }}>
{r.rank}
</td>
</tr>
))}
</tbody>
</table>
)}
</article>
))}
</div>
</section>
</div>
);
}
function StatBox({ label, value }: { label: string; value: string }): JSX.Element {
return (
<div>
<p className="text-xs uppercase tracking-wider" style={{ color: "var(--color-ink-muted)" }}>
{label}
</p>
<p className="mt-1 text-2xl font-serif" style={{ color: "var(--color-ink)" }}>
{value}
</p>
</div>
);
}
function getBadgeClassForGrade(grade: string): string {
if (grade === "优秀") return "badge-success";
if (grade === "良好") return "badge-info";
if (grade === "及格") return "badge-warning";
if (grade === "不及格") return "badge-danger";
return "badge-info";
}

View File

@@ -0,0 +1,224 @@
"use client";
/**
* PracticeSession — 练习会话答题(批次 A
*
* 数据源useStartPracticeSession加载会话题目+ useSubmitPracticeAnswer
* 视图:当前题目 + 选项/作答区 + 进度指示 + 答题反馈
*/
import { useEffect, useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import {
useStartPracticeSession,
useSubmitPracticeAnswer,
} from "@/lib/graphql/hooks";
import type { PracticeQuestion } from "@/lib/graphql/types";
export default function PracticeSessionPage(): JSX.Element {
const params = useParams();
const sessionId = params["sessionId"] as string;
const { data: sessionData, fetching, error, execute: startExecute } = useStartPracticeSession();
const { execute: submitExecute, data: submitResult, fetching: submitting } = useSubmitPracticeAnswer();
const [currentIndex, setCurrentIndex] = useState(0);
const [selectedAnswer, setSelectedAnswer] = useState<string>("");
const [textAnswer, setTextAnswer] = useState<string>("");
const [feedback, setFeedback] = useState<null | { isCorrect: boolean; correctAnswer: string }>(null);
const [started, setStarted] = useState(false);
useEffect(() => {
if (!started) {
startExecute({ practiceType: "error_variant", questionCount: 10 });
setStarted(true);
}
}, [started, startExecute]);
const session = sessionData?.session ?? null;
const questions = session?.questions ?? [];
const currentQuestion: PracticeQuestion | undefined = questions[currentIndex];
const handleSubmit = (): void => {
if (!currentQuestion) return;
const answer = currentQuestion.type === "single-choice" ? selectedAnswer : textAnswer;
if (!answer) return;
submitExecute({ sessionId: session?.id ?? sessionId, questionId: currentQuestion.questionId, answer });
setFeedback(null);
};
useEffect(() => {
if (submitResult) {
setFeedback({
isCorrect: submitResult.isCorrect,
correctAnswer: submitResult.correctAnswer,
});
}
}, [submitResult]);
const handleNext = (): void => {
setCurrentIndex((i) => i + 1);
setSelectedAnswer("");
setTextAnswer("");
setFeedback(null);
};
if (fetching && !session) {
return (
<div className="px-6 lg:px-10 py-8 max-w-3xl mx-auto">
<p className="text-sm" style={{ color: "var(--color-ink-muted)" }} aria-live="polite">
</p>
</div>
);
}
if (error) {
return (
<div className="px-6 lg:px-10 py-8 max-w-3xl mx-auto">
<div role="alert" className="card-paper p-6" style={{ borderColor: "var(--color-danger)" }}>
<p className="text-sm" style={{ color: "var(--color-danger)" }}>
{error.message}
</p>
<Link href="/practice" className="mt-4 inline-block btn-secondary text-xs">
</Link>
</div>
</div>
);
}
if (!session || !currentQuestion) {
return (
<div className="px-6 lg:px-10 py-8 max-w-3xl mx-auto">
<div className="card-paper p-8 text-center">
<p className="text-base font-serif" style={{ color: "var(--color-ink)" }}>
</p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
{session
? `已答题 ${session.answeredQuestions}/${session.totalQuestions},正确 ${session.correctCount}`
: "未找到练习会话"}
</p>
<Link href="/practice" className="mt-4 inline-block btn-secondary text-xs">
</Link>
</div>
</div>
);
}
const progress = session.totalQuestions > 0 ? (currentIndex / session.totalQuestions) * 100 : 0;
return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-3xl mx-auto">
<Link href="/practice" className="text-xs" style={{ color: "var(--color-ink-muted)" }}>
退
</Link>
<div className="mt-4 mb-6">
<div className="flex items-center justify-between mb-2">
<span className="text-xs uppercase tracking-wider" style={{ color: "var(--color-ink-muted)" }}>
{currentIndex + 1} / {session.totalQuestions}
</span>
<span className="text-xs" style={{ color: "var(--color-ink-muted)" }}>
{session.answeredQuestions} · {session.correctCount}
</span>
</div>
<div className="h-1 rounded-sm overflow-hidden" style={{ background: "var(--color-rule)" }}>
<div
className="h-full transition-all"
style={{ width: `${progress}%`, background: "var(--color-accent)" }}
/>
</div>
</div>
<article className="card-paper p-6 mb-6">
<p className="text-base font-serif mb-4" style={{ color: "var(--color-ink)" }}>
{currentQuestion.content}
</p>
{currentQuestion.type === "single-choice" && currentQuestion.options.length > 0 ? (
<ul className="space-y-2">
{currentQuestion.options.map((opt) => (
<li key={opt.id}>
<label
className="flex items-center gap-3 p-3 rounded-sm cursor-pointer transition-colors"
style={{
background: selectedAnswer === opt.id ? "var(--color-accent-muted)" : "var(--color-paper)",
border: `1px solid ${selectedAnswer === opt.id ? "var(--color-accent)" : "var(--color-rule)"}`,
}}
>
<input
type="radio"
name="answer"
value={opt.id}
checked={selectedAnswer === opt.id}
onChange={(e) => setSelectedAnswer(e.target.value)}
disabled={feedback !== null}
/>
<span className="text-sm" style={{ color: "var(--color-ink)" }}>
{opt.text}
</span>
</label>
</li>
))}
</ul>
) : (
<textarea
value={textAnswer}
onChange={(e) => setTextAnswer(e.target.value)}
disabled={feedback !== null}
rows={4}
placeholder="请输入你的答案..."
className="w-full px-3 py-2 rounded-sm text-sm"
style={{
background: "var(--color-paper)",
border: "1px solid var(--color-rule)",
color: "var(--color-ink)",
}}
/>
)}
{feedback && (
<div
className="mt-4 p-4 rounded-sm"
style={{
background: feedback.isCorrect ? "var(--color-success-muted, rgba(34,197,94,0.1))" : "rgba(239,68,68,0.1)",
border: `1px solid ${feedback.isCorrect ? "var(--color-success)" : "var(--color-danger)"}`,
}}
>
<p
className="text-sm font-serif"
style={{ color: feedback.isCorrect ? "var(--color-success)" : "var(--color-danger)" }}
>
{feedback.isCorrect ? "回答正确!" : "回答错误"}
</p>
{!feedback.isCorrect && (
<p className="mt-1 text-xs" style={{ color: "var(--color-ink-muted)" }}>
{feedback.correctAnswer}
</p>
)}
</div>
)}
</article>
<div className="flex gap-3">
{!feedback ? (
<button
type="button"
onClick={handleSubmit}
disabled={submitting || (!selectedAnswer && !textAnswer)}
className="btn-primary text-sm disabled:opacity-50"
>
{submitting ? "提交中..." : "提交答案"}
</button>
) : (
<button type="button" onClick={handleNext} className="btn-primary text-sm">
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,207 @@
"use client";
/**
* Practice — 自适应练习(批次 A
*
* 数据源useMyPracticeSessions + useStartPracticeSession
* 视图:练习统计 + 会话列表 + 启动新练习
*/
import { useRouter } from "next/navigation";
import {
useMyPracticeSessions,
useStartPracticeSession,
} from "@/lib/graphql/hooks";
import type { PracticeStatus, PracticeType } from "@/lib/graphql/types";
const PRACTICE_TYPE_LABELS: Record<PracticeType, string> = {
error_variant: "错题变式",
knowledge_point: "知识点练习",
weak_chapter: "薄弱章节",
ai_recommended: "AI 推荐",
};
const PRACTICE_STATUS_LABELS: Record<PracticeStatus, string> = {
in_progress: "进行中",
completed: "已完成",
abandoned: "已放弃",
};
const PRACTICE_STATUS_BADGE: Record<PracticeStatus, string> = {
in_progress: "badge-info",
completed: "badge-success",
abandoned: "badge-warning",
};
export default function PracticePage(): JSX.Element {
const router = useRouter();
const { data, fetching, error, reexecute } = useMyPracticeSessions();
const { execute: startExecute, fetching: startFetching } = useStartPracticeSession();
const sessions = data?.sessions ?? [];
const stats = data?.stats;
const handleStart = (practiceType: PracticeType): void => {
startExecute({ practiceType, questionCount: 10 });
// 导航到练习会话页mock 模式下 sessionId 为临时值)
router.push("/practice/session-active");
};
return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-5xl mx-auto">
<header className="mb-6">
<p className="text-xs uppercase tracking-wider" style={{ color: "var(--color-ink-muted)" }}>
</p>
<h1 className="mt-2 text-3xl lg:text-4xl font-serif" style={{ color: "var(--color-ink)" }}>
</h1>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
</p>
</header>
<div className="rule-thin mb-8" />
<section className="mb-8">
<h2 className="text-base font-serif mb-4" style={{ color: "var(--color-ink)" }}>
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{(Object.keys(PRACTICE_TYPE_LABELS) as PracticeType[]).map((pt) => (
<button
key={pt}
type="button"
onClick={() => handleStart(pt)}
disabled={startFetching}
className="card-paper p-4 text-center transition-opacity hover:opacity-80 disabled:opacity-50"
>
<p className="text-sm font-serif" style={{ color: "var(--color-ink)" }}>
{PRACTICE_TYPE_LABELS[pt]}
</p>
<p className="mt-1 text-xs" style={{ color: "var(--color-ink-muted)" }}>
10
</p>
</button>
))}
</div>
</section>
{stats && (
<section className="card-paper p-6 mb-6">
<h2 className="text-base font-serif mb-4" style={{ color: "var(--color-ink)" }}>
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatBox label="总会话数" value={stats.totalSessions} />
<StatBox label="已完成" value={stats.completedSessions} />
<StatBox label="答题总数" value={stats.totalQuestionsAnswered} />
<StatBox label="总体正确率" value={`${Math.round(stats.overallAccuracy * 100)}%`} />
</div>
</section>
)}
{fetching ? (
<LoadingSkeleton />
) : error ? (
<ErrorState message={error.message} onRetry={() => reexecute()} />
) : sessions.length === 0 ? (
<EmptyState />
) : (
<section>
<h2 className="text-base font-serif mb-4" style={{ color: "var(--color-ink)" }}>
</h2>
<ul className="space-y-3">
{sessions.map((s) => (
<li key={s.id}>
<article className="card-paper p-4">
<div className="flex items-start justify-between gap-4 mb-2">
<div className="flex items-center gap-2">
<span className="badge badge-info">
{PRACTICE_TYPE_LABELS[s.practiceType]}
</span>
<span className={`badge ${PRACTICE_STATUS_BADGE[s.status]}`}>
{PRACTICE_STATUS_LABELS[s.status]}
</span>
</div>
<span className="text-sm font-serif" style={{ color: "var(--color-ink)" }}>
{Math.round(s.accuracy * 100)}%
</span>
</div>
<p className="text-xs" style={{ color: "var(--color-ink-muted)" }}>
{s.correctCount}/{s.answeredQuestions} · {s.totalQuestions}
</p>
<p className="mt-1 text-xs" style={{ color: "var(--color-ink-subtle)" }}>
{new Date(s.startedAt).toLocaleString("zh-CN")}
{s.completedAt && ` · 完成:${new Date(s.completedAt).toLocaleString("zh-CN")}`}
</p>
</article>
</li>
))}
</ul>
</section>
)}
</div>
);
}
function StatBox({ label, value }: { label: string; value: string | number }): JSX.Element {
return (
<div>
<p className="text-xs uppercase tracking-wider" style={{ color: "var(--color-ink-muted)" }}>
{label}
</p>
<p className="mt-1 text-2xl font-serif" style={{ color: "var(--color-ink)" }}>
{value}
</p>
</div>
);
}
function LoadingSkeleton(): JSX.Element {
return (
<div className="space-y-3" aria-busy="true" aria-live="polite">
{[0, 1, 2].map((i) => (
<div
key={i}
className="h-20 animate-pulse rounded-sm"
style={{ background: "var(--color-rule)", opacity: 0.3 }}
/>
))}
<span className="sr-only">...</span>
</div>
);
}
function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }): JSX.Element {
return (
<div role="alert" className="card-paper p-8 text-center" style={{ borderColor: "var(--color-danger)" }}>
<p className="text-base font-serif" style={{ color: "var(--color-danger)" }}>
</p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
{message}
</p>
<button type="button" onClick={onRetry} className="mt-4 btn-secondary text-xs">
</button>
</div>
);
}
function EmptyState(): JSX.Element {
return (
<div className="card-paper p-8 text-center">
<p className="text-4xl font-serif" style={{ color: "var(--color-ink-subtle)" }} aria-hidden="true">
</p>
<p className="mt-4 text-base font-serif" style={{ color: "var(--color-ink)" }}>
</p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
</p>
</div>
);
}

View File

@@ -26,14 +26,21 @@ interface NavItem {
const NAV_ITEMS: NavItem[] = [
{ label: "Dashboard", route: "/dashboard" },
{ label: "我的班级", route: "/my-classes" },
{ label: "我的考试", route: "/my-exams" },
{ label: "我的课表", route: "/schedule" },
{ label: "选课中心", route: "/elective" },
{ label: "我的作业", route: "/my-homework" },
{ label: "我的考试", route: "/my-exams" },
{ label: "我的成绩", route: "/my-grades" },
{ label: "我的考勤", route: "/my-attendance" },
{ label: "我的课表", route: "/schedule" },
{ label: "错题本", route: "/error-book" },
{ label: "在线请假", route: "/leave" },
{ label: "学习路径", route: "/learning-path" },
{ label: "学情诊断", route: "/diagnostic" },
{ label: "自适应练习", route: "/practice" },
{ label: "学情诊断", route: "/dashboard/weakness" },
{ label: "课案查看", route: "/lesson-plans" },
{ label: "课程计划", route: "/course-plans" },
{ label: "教材", route: "/textbooks" },
{ label: "公告", route: "/announcements" },
{ label: "通知中心", route: "/notifications" },
{ label: "AI辅学", route: "/ai-tutor" },
{ label: "系统设置", route: "/settings" },

View File

@@ -17,13 +17,30 @@ import type { CombinedError } from "urql";
import type {
ActionError,
ActionState,
AddErrorBookItemInput,
AddErrorBookItemResult,
Announcement,
AssignmentAnalysis,
AttendanceRecord,
CancelLeaveRequestInput,
CancelLeaveRequestResult,
ChangePasswordInput,
ChangePasswordResult,
ChapterNode,
ClassInfo,
CoursePlanDetail,
CoursePlanListItem,
CourseSelection,
CreateLeaveRequestInput,
CreateLeaveRequestResult,
CurrentUser,
DeleteErrorBookItemInput,
DeleteErrorBookItemResult,
DiagnosticReport,
DropElectiveCourseInput,
DropElectiveCourseResult,
ElectiveCourse,
ErrorBookData,
ExamDetail,
ExamListItem,
GradeRecord,
@@ -34,33 +51,49 @@ import type {
JoinClassResult,
LeaveClassInput,
LeaveClassResult,
LeaveRequest,
LearningPathNode,
LessonPlanDetail,
LessonPlanListItem,
MarkAllAsReadInput,
MarkAllAsReadResult,
MarkAnnouncementReadInput,
MarkAnnouncementReadResult,
MarkAsReadInput,
MarkAsReadResult,
MasterySummary,
MutationResponses,
MyPracticeSessionsData,
NotificationItem,
PracticeSessionDetail,
QueryResponses,
RecordExamViolationInput,
RecordExamViolationResult,
RecordPasteEventInput,
RecordPasteEventResult,
ReportCardData,
RequestExtensionInput,
RequestExtensionResult,
SaveExamDraftInput,
SaveExamDraftResult,
ScheduleItem,
SelectElectiveCourseInput,
SelectElectiveCourseResult,
ServerTime,
StartPracticeSessionInput,
StudentDashboard,
StudentProfile,
SubmitExamInput,
SubmitExamResult,
SubmitHomeworkInput,
SubmitHomeworkResult,
SubmitPracticeAnswerInput,
SubmitPracticeAnswerResult,
SubjectRef,
Textbook,
TrendDataPoint,
UpdateErrorBookItemInput,
UpdateErrorBookItemResult,
UpdateNotificationPreferenceInput,
UpdateNotificationPreferenceResult,
UpdateProfileInput,
@@ -68,24 +101,44 @@ import type {
WeaknessPoint,
} from "./types";
import {
ADD_ERROR_BOOK_ITEM_MUTATION,
ANNOUNCEMENT_DETAIL_QUERY,
ANNOUNCEMENTS_QUERY,
ASSIGNMENT_ANALYSIS_QUERY,
AVAILABLE_ELECTIVE_COURSES_QUERY,
CANCEL_LEAVE_REQUEST_MUTATION,
CHANGE_PASSWORD_MUTATION,
CHAPTERS_QUERY,
COURSE_PLAN_DETAIL_QUERY,
CREATE_LEAVE_REQUEST_MUTATION,
CURRENT_USER_QUERY,
DELETE_ERROR_BOOK_ITEM_MUTATION,
DROP_ELECTIVE_COURSE_MUTATION,
EXAM_DETAIL_QUERY,
HOMEWORK_DETAIL_QUERY,
JOIN_CLASS_MUTATION,
LEAVE_CLASS_MUTATION,
LEARNING_PATH_QUERY,
LESSON_PLAN_DETAIL_QUERY,
MARK_ALL_AS_READ_MUTATION,
MARK_ANNOUNCEMENT_READ_MUTATION,
MARK_AS_READ_MUTATION,
MY_ATTENDANCE_QUERY,
MY_CLASSES_QUERY,
MY_COURSE_PLANS_QUERY,
MY_DIAGNOSTIC_REPORTS_QUERY,
MY_ELECTIVE_SELECTIONS_QUERY,
MY_ERROR_BOOK_QUERY,
MY_EXAMS_QUERY,
MY_GRADES_QUERY,
MY_HOMEWORK_QUERY,
MY_LEAVE_REQUESTS_QUERY,
MY_LESSON_PLANS_QUERY,
MY_MASTERY_SUMMARY_QUERY,
MY_NOTIFICATIONS_QUERY,
MY_PRACTICE_SESSIONS_QUERY,
MY_PROFILE_QUERY,
MY_REPORT_CARD_QUERY,
MY_SCHEDULE_QUERY,
MY_TREND_QUERY,
MY_WEAKNESS_QUERY,
@@ -93,12 +146,16 @@ import {
RECORD_PASTE_EVENT_MUTATION,
REQUEST_EXTENSION_MUTATION,
SAVE_EXAM_DRAFT_MUTATION,
SELECT_ELECTIVE_COURSE_MUTATION,
SERVER_TIME_QUERY,
START_PRACTICE_SESSION_QUERY,
STUDENT_DASHBOARD_QUERY,
STUDENT_GROWTH_QUERY,
SUBMIT_EXAM_MUTATION,
SUBMIT_HOMEWORK_MUTATION,
SUBMIT_PRACTICE_ANSWER_MUTATION,
TEXTBOOKS_QUERY,
UPDATE_ERROR_BOOK_ITEM_MUTATION,
UPDATE_NOTIFICATION_PREFERENCE_MUTATION,
UPDATE_PROFILE_MUTATION,
} from "./operations";
@@ -923,6 +980,541 @@ export function useLeaveClass(): UseMutationResult<
};
}
// ---------------------------------------------------------------------------
// 批次 A 扩展查询 Hooks
// ---------------------------------------------------------------------------
/** 我的错题本 */
export function useMyErrorBook(): UseQueryResult<ErrorBookData> {
const [result, reexecute] = useQuery<{
myErrorBook: ActionState<ErrorBookData>;
}>({ query: MY_ERROR_BOOK_QUERY });
const state = result.data?.myErrorBook;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 我的请假记录 */
export function useMyLeaveRequests(): UseQueryResult<LeaveRequest[]> {
const [result, reexecute] = useQuery<{
myLeaveRequests: ActionState<LeaveRequest[]>;
}>({ query: MY_LEAVE_REQUESTS_QUERY });
const state = result.data?.myLeaveRequests;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 我的选课记录 */
export function useMyElectiveSelections(): UseQueryResult<CourseSelection[]> {
const [result, reexecute] = useQuery<{
myElectiveSelections: ActionState<CourseSelection[]>;
}>({ query: MY_ELECTIVE_SELECTIONS_QUERY });
const state = result.data?.myElectiveSelections;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 可选课程列表 */
export function useAvailableElectiveCourses(): UseQueryResult<ElectiveCourse[]> {
const [result, reexecute] = useQuery<{
availableElectiveCourses: ActionState<ElectiveCourse[]>;
}>({ query: AVAILABLE_ELECTIVE_COURSES_QUERY });
const state = result.data?.availableElectiveCourses;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 我的练习会话列表 */
export function useMyPracticeSessions(): UseQueryResult<MyPracticeSessionsData> {
const [result, reexecute] = useQuery<{
myPracticeSessions: ActionState<MyPracticeSessionsData>;
}>({ query: MY_PRACTICE_SESSIONS_QUERY });
const state = result.data?.myPracticeSessions;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 启动练习会话 */
export function useStartPracticeSession(): UseMutationResult<
PracticeSessionDetail,
StartPracticeSessionInput
> {
const [result, execute] = useMutation<{
startPracticeSession: ActionState<PracticeSessionDetail>;
}>(START_PRACTICE_SESSION_QUERY);
const state = result.data?.startPracticeSession;
const executeMutation = (input: StartPracticeSessionInput): void => {
void execute({ input });
};
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
execute: executeMutation,
};
}
/** 公告列表 */
export function useAnnouncements(): UseQueryResult<Announcement[]> {
const [result, reexecute] = useQuery<{
announcements: ActionState<Announcement[]>;
}>({ query: ANNOUNCEMENTS_QUERY });
const state = result.data?.announcements;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 公告详情 */
export function useAnnouncementDetail(
announcementId: string,
): UseQueryResult<Announcement> {
const [result, reexecute] = useQuery<{
announcementDetail: ActionState<Announcement>;
}>({
query: ANNOUNCEMENT_DETAIL_QUERY,
variables: { announcementId },
});
const state = result.data?.announcementDetail;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 我的课案列表(仅已发布) */
export function useMyLessonPlans(): UseQueryResult<LessonPlanListItem[]> {
const [result, reexecute] = useQuery<{
myLessonPlans: ActionState<LessonPlanListItem[]>;
}>({ query: MY_LESSON_PLANS_QUERY });
const state = result.data?.myLessonPlans;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 课案详情 */
export function useLessonPlanDetail(
planId: string,
): UseQueryResult<LessonPlanDetail> {
const [result, reexecute] = useQuery<{
lessonPlanDetail: ActionState<LessonPlanDetail>;
}>({
query: LESSON_PLAN_DETAIL_QUERY,
variables: { planId },
});
const state = result.data?.lessonPlanDetail;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 我的课程计划列表 */
export function useMyCoursePlans(): UseQueryResult<CoursePlanListItem[]> {
const [result, reexecute] = useQuery<{
myCoursePlans: ActionState<CoursePlanListItem[]>;
}>({ query: MY_COURSE_PLANS_QUERY });
const state = result.data?.myCoursePlans;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 课程计划详情 */
export function useCoursePlanDetail(
planId: string,
): UseQueryResult<CoursePlanDetail> {
const [result, reexecute] = useQuery<{
coursePlanDetail: ActionState<CoursePlanDetail>;
}>({
query: COURSE_PLAN_DETAIL_QUERY,
variables: { planId },
});
const state = result.data?.coursePlanDetail;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 成绩报告卡 */
export function useMyReportCard(
academicYearId?: string,
semester?: string,
): UseQueryResult<ReportCardData> {
const [result, reexecute] = useQuery<{
myReportCard: ActionState<ReportCardData>;
}>({
query: MY_REPORT_CARD_QUERY,
variables: { academicYearId, semester },
});
const state = result.data?.myReportCard;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 我的诊断报告列表 */
export function useMyDiagnosticReports(): UseQueryResult<DiagnosticReport[]> {
const [result, reexecute] = useQuery<{
myDiagnosticReports: ActionState<DiagnosticReport[]>;
}>({ query: MY_DIAGNOSTIC_REPORTS_QUERY });
const state = result.data?.myDiagnosticReports;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 我的掌握度摘要 */
export function useMyMasterySummary(): UseQueryResult<MasterySummary> {
const [result, reexecute] = useQuery<{
myMasterySummary: ActionState<MasterySummary>;
}>({ query: MY_MASTERY_SUMMARY_QUERY });
const state = result.data?.myMasterySummary;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
// ---------------------------------------------------------------------------
// 批次 A 扩展变更 Hooks
// ---------------------------------------------------------------------------
/** 添加错题 */
export function useAddErrorBookItem(): UseMutationResult<
AddErrorBookItemResult,
AddErrorBookItemInput
> {
const [result, execute] = useMutation<{
addErrorBookItem: ActionState<AddErrorBookItemResult>;
}>(ADD_ERROR_BOOK_ITEM_MUTATION);
const state = result.data?.addErrorBookItem;
const executeMutation = (input: AddErrorBookItemInput): void => {
void execute({ input });
};
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
execute: executeMutation,
};
}
/** 更新错题(含复习自评) */
export function useUpdateErrorBookItem(): UseMutationResult<
UpdateErrorBookItemResult,
UpdateErrorBookItemInput
> {
const [result, execute] = useMutation<{
updateErrorBookItem: ActionState<UpdateErrorBookItemResult>;
}>(UPDATE_ERROR_BOOK_ITEM_MUTATION);
const state = result.data?.updateErrorBookItem;
const executeMutation = (input: UpdateErrorBookItemInput): void => {
void execute({ input });
};
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
execute: executeMutation,
};
}
/** 删除错题 */
export function useDeleteErrorBookItem(): UseMutationResult<
DeleteErrorBookItemResult,
DeleteErrorBookItemInput
> {
const [result, execute] = useMutation<{
deleteErrorBookItem: ActionState<DeleteErrorBookItemResult>;
}>(DELETE_ERROR_BOOK_ITEM_MUTATION);
const state = result.data?.deleteErrorBookItem;
const executeMutation = (input: DeleteErrorBookItemInput): void => {
void execute({ input });
};
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
execute: executeMutation,
};
}
/** 创建请假申请 */
export function useCreateLeaveRequest(): UseMutationResult<
CreateLeaveRequestResult,
CreateLeaveRequestInput
> {
const [result, execute] = useMutation<{
createLeaveRequest: ActionState<CreateLeaveRequestResult>;
}>(CREATE_LEAVE_REQUEST_MUTATION);
const state = result.data?.createLeaveRequest;
const executeMutation = (input: CreateLeaveRequestInput): void => {
void execute({ input });
};
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
execute: executeMutation,
};
}
/** 撤销请假申请 */
export function useCancelLeaveRequest(): UseMutationResult<
CancelLeaveRequestResult,
CancelLeaveRequestInput
> {
const [result, execute] = useMutation<{
cancelLeaveRequest: ActionState<CancelLeaveRequestResult>;
}>(CANCEL_LEAVE_REQUEST_MUTATION);
const state = result.data?.cancelLeaveRequest;
const executeMutation = (input: CancelLeaveRequestInput): void => {
void execute({ input });
};
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
execute: executeMutation,
};
}
/** 选课 */
export function useSelectElectiveCourse(): UseMutationResult<
SelectElectiveCourseResult,
SelectElectiveCourseInput
> {
const [result, execute] = useMutation<{
selectElectiveCourse: ActionState<SelectElectiveCourseResult>;
}>(SELECT_ELECTIVE_COURSE_MUTATION);
const state = result.data?.selectElectiveCourse;
const executeMutation = (input: SelectElectiveCourseInput): void => {
void execute({ input });
};
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
execute: executeMutation,
};
}
/** 退选 */
export function useDropElectiveCourse(): UseMutationResult<
DropElectiveCourseResult,
DropElectiveCourseInput
> {
const [result, execute] = useMutation<{
dropElectiveCourse: ActionState<DropElectiveCourseResult>;
}>(DROP_ELECTIVE_COURSE_MUTATION);
const state = result.data?.dropElectiveCourse;
const executeMutation = (input: DropElectiveCourseInput): void => {
void execute({ input });
};
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
execute: executeMutation,
};
}
/** 提交练习答题 */
export function useSubmitPracticeAnswer(): UseMutationResult<
SubmitPracticeAnswerResult,
SubmitPracticeAnswerInput
> {
const [result, execute] = useMutation<{
submitPracticeAnswer: ActionState<SubmitPracticeAnswerResult>;
}>(SUBMIT_PRACTICE_ANSWER_MUTATION);
const state = result.data?.submitPracticeAnswer;
const executeMutation = (input: SubmitPracticeAnswerInput): void => {
void execute({ input });
};
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
execute: executeMutation,
};
}
/** 标记公告已读 */
export function useMarkAnnouncementRead(): UseMutationResult<
MarkAnnouncementReadResult,
MarkAnnouncementReadInput
> {
const [result, execute] = useMutation<{
markAnnouncementRead: ActionState<MarkAnnouncementReadResult>;
}>(MARK_ANNOUNCEMENT_READ_MUTATION);
const state = result.data?.markAnnouncementRead;
const executeMutation = (input: MarkAnnouncementReadInput): void => {
void execute({ input });
};
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
execute: executeMutation,
};
}
// ---------------------------------------------------------------------------
// 类型导出(供响应映射使用)
// ---------------------------------------------------------------------------

View File

@@ -962,3 +962,805 @@ export const LEAVE_CLASS_MUTATION = gql`
}
}
`;
// ===========================================================================
// 批次 A 扩展操作9 查询 + 8 变更)
// ===========================================================================
/** 我的错题本 */
export const MY_ERROR_BOOK_QUERY = gql`
query myErrorBook {
myErrorBook {
success
errors {
code
message
field
}
data {
items {
id
questionId
questionContent
sourceType
subject {
id
name
}
knowledgePointIds
knowledgePointName
status
masteryLevel
nextReviewAt
reviewCount
correctStreak
note
errorTags
createdAt
updatedAt
}
stats {
totalCount
newCount
learningCount
masteredCount
dueReviewCount
masteredRate
}
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 添加错题 */
export const ADD_ERROR_BOOK_ITEM_MUTATION = gql`
mutation addErrorBookItem($input: AddErrorBookItemInput!) {
addErrorBookItem(input: $input) {
success
errors {
code
message
field
}
data {
id
status
createdAt
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 更新错题(含复习自评) */
export const UPDATE_ERROR_BOOK_ITEM_MUTATION = gql`
mutation updateErrorBookItem($input: UpdateErrorBookItemInput!) {
updateErrorBookItem(input: $input) {
success
errors {
code
message
field
}
data {
id
status
masteryLevel
nextReviewAt
reviewCount
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 删除错题 */
export const DELETE_ERROR_BOOK_ITEM_MUTATION = gql`
mutation deleteErrorBookItem($input: DeleteErrorBookItemInput!) {
deleteErrorBookItem(input: $input) {
success
errors {
code
message
field
}
data {
id
deleted
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 我的请假记录 */
export const MY_LEAVE_REQUESTS_QUERY = gql`
query myLeaveRequests {
myLeaveRequests {
success
errors {
code
message
field
}
data {
id
leaveType
startDate
endDate
reason
status
reviewComment
reviewedAt
reviewerName
className
attendanceSynced
createdAt
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 创建请假申请 */
export const CREATE_LEAVE_REQUEST_MUTATION = gql`
mutation createLeaveRequest($input: CreateLeaveRequestInput!) {
createLeaveRequest(input: $input) {
success
errors {
code
message
field
}
data {
id
status
createdAt
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 撤销请假申请 */
export const CANCEL_LEAVE_REQUEST_MUTATION = gql`
mutation cancelLeaveRequest($input: CancelLeaveRequestInput!) {
cancelLeaveRequest(input: $input) {
success
errors {
code
message
field
}
data {
id
status
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 我的选课记录 */
export const MY_ELECTIVE_SELECTIONS_QUERY = gql`
query myElectiveSelections {
myElectiveSelections {
success
errors {
code
message
field
}
data {
id
courseId
courseName
status
selectedAt
enrolledAt
droppedAt
dropReason
course {
id
name
teacherName
subjectName
capacity
enrolledCount
classroom
schedule
credit
status
}
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 可选课程列表 */
export const AVAILABLE_ELECTIVE_COURSES_QUERY = gql`
query availableElectiveCourses {
availableElectiveCourses {
success
errors {
code
message
field
}
data {
id
name
teacherName
subjectName
gradeName
description
capacity
enrolledCount
classroom
schedule
startDate
endDate
selectionStartAt
selectionEndAt
dropDeadline
status
selectionMode
credit
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 选课 */
export const SELECT_ELECTIVE_COURSE_MUTATION = gql`
mutation selectElectiveCourse($input: SelectElectiveCourseInput!) {
selectElectiveCourse(input: $input) {
success
errors {
code
message
field
}
data {
id
courseId
status
selectedAt
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 退选 */
export const DROP_ELECTIVE_COURSE_MUTATION = gql`
mutation dropElectiveCourse($input: DropElectiveCourseInput!) {
dropElectiveCourse(input: $input) {
success
errors {
code
message
field
}
data {
id
courseId
status
droppedAt
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 我的练习会话列表 */
export const MY_PRACTICE_SESSIONS_QUERY = gql`
query myPracticeSessions {
myPracticeSessions {
success
errors {
code
message
field
}
data {
sessions {
id
practiceType
status
totalQuestions
answeredQuestions
correctCount
accuracy
startedAt
completedAt
}
stats {
totalSessions
completedSessions
totalQuestionsAnswered
totalCorrect
overallAccuracy
}
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 启动练习会话 */
export const START_PRACTICE_SESSION_QUERY = gql`
query startPracticeSession($input: StartPracticeSessionInput!) {
startPracticeSession(input: $input) {
success
errors {
code
message
field
}
data {
session {
id
practiceType
status
totalQuestions
answeredQuestions
correctCount
accuracy
startedAt
currentQuestionIndex
questions {
id
questionId
content
type
options {
id
text
}
maxScore
status
}
}
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 提交练习答题 */
export const SUBMIT_PRACTICE_ANSWER_MUTATION = gql`
mutation submitPracticeAnswer($input: SubmitPracticeAnswerInput!) {
submitPracticeAnswer(input: $input) {
success
errors {
code
message
field
}
data {
answerId
isCorrect
score
maxScore
correctAnswer
sessionCompleted
session {
id
answeredQuestions
correctCount
accuracy
status
}
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 公告列表 */
export const ANNOUNCEMENTS_QUERY = gql`
query announcements {
announcements {
success
errors {
code
message
field
}
data {
id
title
content
type
status
authorName
publishedAt
isPinned
isReadByCurrentUser
createdAt
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 公告详情 */
export const ANNOUNCEMENT_DETAIL_QUERY = gql`
query announcementDetail($announcementId: ID!) {
announcementDetail(announcementId: $announcementId) {
success
errors {
code
message
field
}
data {
id
title
content
type
status
authorName
publishedAt
isPinned
isReadByCurrentUser
createdAt
updatedAt
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 标记公告已读 */
export const MARK_ANNOUNCEMENT_READ_MUTATION = gql`
mutation markAnnouncementRead($input: MarkAnnouncementReadInput!) {
markAnnouncementRead(input: $input) {
success
errors {
code
message
field
}
data {
announcementId
read
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 我的课案列表(仅已发布) */
export const MY_LESSON_PLANS_QUERY = gql`
query myLessonPlans {
myLessonPlans {
success
errors {
code
message
field
}
data {
id
title
subjectName
gradeName
chapterTitle
creatorName
status
publishedAt
updatedAt
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 课案详情 */
export const LESSON_PLAN_DETAIL_QUERY = gql`
query lessonPlanDetail($planId: ID!) {
lessonPlanDetail(planId: $planId) {
success
errors {
code
message
field
}
data {
id
title
subjectName
gradeName
chapterTitle
creatorName
status
content {
objectives
keyPoints
summary
homework
blackboard
}
publishedAt
updatedAt
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 我的课程计划列表 */
export const MY_COURSE_PLANS_QUERY = gql`
query myCoursePlans {
myCoursePlans {
success
errors {
code
message
field
}
data {
id
className
subjectName
teacherName
semester
totalHours
completedHours
weeklyHours
startDate
endDate
syllabus
objectives
status
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 课程计划详情 */
export const COURSE_PLAN_DETAIL_QUERY = gql`
query coursePlanDetail($planId: ID!) {
coursePlanDetail(planId: $planId) {
success
errors {
code
message
field
}
data {
id
className
subjectName
teacherName
semester
totalHours
completedHours
weeklyHours
startDate
endDate
syllabus
objectives
status
items {
id
week
topic
content
hours
textbookChapter
notes
isCompleted
completedAt
}
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 成绩报告卡 */
export const MY_REPORT_CARD_QUERY = gql`
query myReportCard($academicYearId: ID, $semester: String) {
myReportCard(academicYearId: $academicYearId, semester: $semester) {
success
errors {
code
message
field
}
data {
studentId
studentName
classId
className
classTeacherName
academicYearName
semester
subjects {
subjectId
subjectName
averageScore
rankInSubject
totalStudentsInSubject
fullScoreTotal
records {
id
examName
score
maxScore
grade
rank
submittedAt
}
}
overallAverage
overallRank
classTotalStudents
overallPassRate
overallExcellentRate
generatedAt
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 我的诊断报告列表 */
export const MY_DIAGNOSTIC_REPORTS_QUERY = gql`
query myDiagnosticReports {
myDiagnosticReports {
success
errors {
code
message
field
}
data {
id
reportType
period
summary
strengths
weaknesses
recommendations
overallScore
status
createdAt
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 我的掌握度摘要 */
export const MY_MASTERY_SUMMARY_QUERY = gql`
query myMasterySummary {
myMasterySummary {
success
errors {
code
message
field
}
data {
studentId
studentName
averageMastery
totalKnowledgePoints
confidenceLevel
mastery {
knowledgePointId
knowledgePointName
masteryLevel
totalQuestions
correctQuestions
lastAssessedAt
}
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;

View File

@@ -399,6 +399,419 @@ export interface StudentProfile {
preferredSubjectId: string;
}
// ---------------------------------------------------------------------------
// 批次 A 扩展实体类型
// ---------------------------------------------------------------------------
/** 错题来源类型 */
export type ErrorBookSourceType = "exam" | "homework" | "manual";
/** 错题状态 */
export type ErrorBookStatus = "new" | "learning" | "mastered" | "archived";
/** 错题本条目 */
export interface ErrorBookItem {
id: string;
questionId: string;
questionContent: string;
sourceType: ErrorBookSourceType;
subject: SubjectRef;
knowledgePointIds: string[];
knowledgePointName: string;
status: ErrorBookStatus;
/** 掌握度 0-1 */
masteryLevel: number;
nextReviewAt: string | null;
reviewCount: number;
correctStreak: number;
note: string | null;
errorTags: string[];
createdAt: string;
updatedAt: string;
}
/** 错题本统计 */
export interface ErrorBookStats {
totalCount: number;
newCount: number;
learningCount: number;
masteredCount: number;
dueReviewCount: number;
/** 掌握率 0-1 */
masteredRate: number;
}
/** 错题本响应数据 */
export interface ErrorBookData {
items: ErrorBookItem[];
stats: ErrorBookStats;
}
/** 请假类型 */
export type LeaveType = "sick" | "personal" | "family" | "other";
/** 请假状态 */
export type LeaveStatus = "pending" | "approved" | "rejected" | "cancelled";
/** 请假记录 */
export interface LeaveRequest {
id: string;
leaveType: LeaveType;
startDate: string;
endDate: string;
reason: string;
status: LeaveStatus;
reviewComment: string | null;
reviewedAt: string | null;
reviewerName: string | null;
className: string;
attendanceSynced: boolean;
createdAt: string;
}
/** 选课课程状态 */
export type ElectiveCourseStatus =
| "draft"
| "open"
| "closed"
| "cancelled";
/** 选课模式 */
export type ElectiveSelectionMode = "fcfs" | "lottery";
/** 选课记录状态 */
export type CourseSelectionStatus =
| "selected"
| "enrolled"
| "waitlist"
| "dropped"
| "rejected";
/** 选课课程(列表/详情) */
export interface ElectiveCourse {
id: string;
name: string;
teacherName: string | null;
subjectName: string | null;
gradeName: string | null;
description: string | null;
capacity: number;
enrolledCount: number;
classroom: string | null;
schedule: string | null;
startDate: string | null;
endDate: string | null;
selectionStartAt: string | null;
selectionEndAt: string | null;
dropDeadline: string | null;
status: ElectiveCourseStatus;
selectionMode: ElectiveSelectionMode;
credit: string;
}
/** 我的选课记录(含课程信息) */
export interface CourseSelection {
id: string;
courseId: string;
courseName: string;
status: CourseSelectionStatus;
selectedAt: string;
enrolledAt: string | null;
droppedAt: string | null;
dropReason: string | null;
course: {
id: string;
name: string;
teacherName: string | null;
subjectName: string | null;
capacity: number;
enrolledCount: number;
classroom: string | null;
schedule: string | null;
credit: string;
status: ElectiveCourseStatus;
};
}
/** 练习类型 */
export type PracticeType =
| "error_variant"
| "knowledge_point"
| "weak_chapter"
| "ai_recommended";
/** 练习状态 */
export type PracticeStatus = "in_progress" | "completed" | "abandoned";
/** 练习答题状态 */
export type PracticeAnswerStatus = "pending" | "answered" | "skipped";
/** 练习会话摘要 */
export interface PracticeSessionSummary {
id: string;
practiceType: PracticeType;
status: PracticeStatus;
totalQuestions: number;
answeredQuestions: number;
correctCount: number;
/** 正确率 0-1 */
accuracy: number;
startedAt: string;
completedAt: string | null;
}
/** 练习统计 */
export interface PracticeStats {
totalSessions: number;
completedSessions: number;
totalQuestionsAnswered: number;
totalCorrect: number;
/** 总体正确率 0-1 */
overallAccuracy: number;
}
/** 我的练习会话响应数据 */
export interface MyPracticeSessionsData {
sessions: PracticeSessionSummary[];
stats: PracticeStats;
}
/** 练习题目选项 */
export interface PracticeQuestionOption {
id: string;
text: string;
}
/** 练习题目 */
export interface PracticeQuestion {
id: string;
questionId: string;
content: string;
type: string;
options: PracticeQuestionOption[];
maxScore: number;
status: PracticeAnswerStatus;
}
/** 练习会话详情(启动后) */
export interface PracticeSessionDetail {
session: {
id: string;
practiceType: PracticeType;
status: PracticeStatus;
totalQuestions: number;
answeredQuestions: number;
correctCount: number;
accuracy: number;
startedAt: string;
currentQuestionIndex: number;
questions: PracticeQuestion[];
};
}
/** 提交练习答题响应 */
export interface SubmitPracticeAnswerResult {
answerId: string;
isCorrect: boolean;
score: number;
maxScore: number;
correctAnswer: string;
sessionCompleted: boolean;
session: {
id: string;
answeredQuestions: number;
correctCount: number;
accuracy: number;
status: PracticeStatus;
};
}
/** 公告类型 */
export type AnnouncementType = "school" | "grade" | "class";
/** 公告状态 */
export type AnnouncementStatus = "draft" | "published" | "archived";
/** 公告 */
export interface Announcement {
id: string;
title: string;
content: string;
type: AnnouncementType;
status: AnnouncementStatus;
authorName: string | null;
publishedAt: string | null;
isPinned: boolean;
isReadByCurrentUser: boolean;
createdAt: string;
updatedAt?: string;
}
/** 课案内容(只读视图) */
export interface LessonPlanContent {
objectives: string | null;
keyPoints: string | null;
summary: string | null;
homework: string | null;
blackboard: string | null;
}
/** 课案列表项 */
export interface LessonPlanListItem {
id: string;
title: string;
subjectName: string | null;
gradeName: string | null;
chapterTitle: string | null;
creatorName: string | null;
status: string;
publishedAt: string | null;
updatedAt: string;
}
/** 课案详情 */
export interface LessonPlanDetail {
id: string;
title: string;
subjectName: string | null;
gradeName: string | null;
chapterTitle: string | null;
creatorName: string | null;
status: string;
content: LessonPlanContent;
publishedAt: string | null;
updatedAt: string;
}
/** 课程计划状态 */
export type CoursePlanStatus =
| "planning"
| "active"
| "completed"
| "paused";
/** 课程计划学期 */
export type CoursePlanSemester = "1" | "2";
/** 课程计划列表项 */
export interface CoursePlanListItem {
id: string;
className: string | null;
subjectName: string | null;
teacherName: string | null;
semester: CoursePlanSemester;
totalHours: number;
completedHours: number;
weeklyHours: number;
startDate: string | null;
endDate: string | null;
syllabus: string | null;
objectives: string | null;
status: CoursePlanStatus;
}
/** 课程计划周次条目 */
export interface CoursePlanItem {
id: string;
week: number;
topic: string;
content: string | null;
hours: number;
textbookChapter: string | null;
notes: string | null;
isCompleted: boolean;
completedAt: string | null;
}
/** 课程计划详情(含周次条目) */
export interface CoursePlanDetail extends CoursePlanListItem {
items: CoursePlanItem[];
}
/** 报告卡成绩记录 */
export interface ReportCardRecord {
id: string;
examName: string;
score: number;
maxScore: number;
grade: string;
rank: number;
submittedAt: string;
}
/** 报告卡学科项 */
export interface ReportCardSubjectItem {
subjectId: string;
subjectName: string;
averageScore: number;
rankInSubject: number;
totalStudentsInSubject: number;
fullScoreTotal: number;
records: ReportCardRecord[];
}
/** 成绩报告卡 */
export interface ReportCardData {
studentId: string;
studentName: string;
classId: string;
className: string;
classTeacherName: string;
academicYearName: string | null;
semester: string;
subjects: ReportCardSubjectItem[];
overallAverage: number;
overallRank: number;
classTotalStudents: number;
overallPassRate: number;
overallExcellentRate: number;
generatedAt: string;
}
/** 诊断报告类型 */
export type DiagnosticReportType = "individual" | "class" | "grade";
/** 诊断报告状态 */
export type DiagnosticReportStatus = "draft" | "published" | "archived";
/** 置信度等级 */
export type ConfidenceLevel = "low" | "medium" | "high";
/** 诊断报告 */
export interface DiagnosticReport {
id: string;
reportType: DiagnosticReportType;
period: string | null;
summary: string | null;
strengths: string[] | null;
weaknesses: string[] | null;
recommendations: string[] | null;
overallScore: number | null;
status: DiagnosticReportStatus;
createdAt: string;
}
/** 掌握度条目 */
export interface MasteryItem {
knowledgePointId: string;
knowledgePointName: string;
/** 0-100 */
masteryLevel: number;
totalQuestions: number;
correctQuestions: number;
lastAssessedAt: string;
}
/** 掌握度摘要 */
export interface MasterySummary {
studentId: string;
studentName: string;
averageMastery: number;
totalKnowledgePoints: number;
confidenceLevel: ConfidenceLevel;
mastery: MasteryItem[];
}
// ---------------------------------------------------------------------------
// Mutation 输入类型
// ---------------------------------------------------------------------------
@@ -504,6 +917,140 @@ export interface LeaveClassInput {
classId: string;
}
// ---------------------------------------------------------------------------
// 批次 A 扩展 Mutation 输入类型
// ---------------------------------------------------------------------------
/** 添加错题输入 */
export interface AddErrorBookItemInput {
questionId: string;
questionContent: string;
sourceType: ErrorBookSourceType;
subjectId: string;
knowledgePointIds?: string[];
knowledgePointName?: string;
note?: string;
errorTags?: string[];
}
/** 更新错题输入(含复习自评) */
export interface UpdateErrorBookItemInput {
id: string;
status?: ErrorBookStatus;
reviewResult?: "again" | "hard" | "good" | "easy";
note?: string;
}
/** 删除错题输入 */
export interface DeleteErrorBookItemInput {
id: string;
}
/** 创建请假申请输入 */
export interface CreateLeaveRequestInput {
leaveType: LeaveType;
startDate: string;
endDate: string;
reason: string;
}
/** 撤销请假申请输入 */
export interface CancelLeaveRequestInput {
id: string;
}
/** 选课输入 */
export interface SelectElectiveCourseInput {
courseId: string;
}
/** 退选输入 */
export interface DropElectiveCourseInput {
courseId: string;
dropReason?: string;
}
/** 启动练习会话输入 */
export interface StartPracticeSessionInput {
practiceType: PracticeType;
subjectId?: string;
knowledgePointIds?: string[];
questionCount?: number;
}
/** 提交练习答题输入 */
export interface SubmitPracticeAnswerInput {
sessionId: string;
questionId: string;
answer: string;
}
/** 标记公告已读输入 */
export interface MarkAnnouncementReadInput {
announcementId: string;
}
// ---------------------------------------------------------------------------
// 批次 A 扩展 Mutation 响应数据类型
// ---------------------------------------------------------------------------
/** 添加错题响应 */
export interface AddErrorBookItemResult {
id: string;
status: ErrorBookStatus;
createdAt: string;
}
/** 更新错题响应 */
export interface UpdateErrorBookItemResult {
id: string;
status: ErrorBookStatus;
masteryLevel: number;
nextReviewAt: string | null;
reviewCount: number;
}
/** 删除错题响应 */
export interface DeleteErrorBookItemResult {
id: string;
deleted: boolean;
}
/** 创建请假响应 */
export interface CreateLeaveRequestResult {
id: string;
status: LeaveStatus;
createdAt: string;
}
/** 撤销请假响应 */
export interface CancelLeaveRequestResult {
id: string;
status: LeaveStatus;
}
/** 选课响应 */
export interface SelectElectiveCourseResult {
id: string;
courseId: string;
status: CourseSelectionStatus;
selectedAt: string;
}
/** 退选响应 */
export interface DropElectiveCourseResult {
id: string;
courseId: string;
status: CourseSelectionStatus;
droppedAt: string;
}
/** 标记公告已读响应 */
export interface MarkAnnouncementReadResult {
announcementId: string;
read: boolean;
}
// ---------------------------------------------------------------------------
// Mutation 响应数据类型
// ---------------------------------------------------------------------------
@@ -624,7 +1171,22 @@ export type QueryOperationName =
| "mySchedule"
| "studentGrowth"
| "assignmentAnalysis"
| "myProfile";
| "myProfile"
| "myErrorBook"
| "myLeaveRequests"
| "myElectiveSelections"
| "availableElectiveCourses"
| "myPracticeSessions"
| "startPracticeSession"
| "announcements"
| "announcementDetail"
| "myLessonPlans"
| "lessonPlanDetail"
| "myCoursePlans"
| "coursePlanDetail"
| "myReportCard"
| "myDiagnosticReports"
| "myMasterySummary";
/** 变更操作名 */
export type MutationOperationName =
@@ -640,7 +1202,16 @@ export type MutationOperationName =
| "changePassword"
| "requestExtension"
| "joinClass"
| "leaveClass";
| "leaveClass"
| "addErrorBookItem"
| "updateErrorBookItem"
| "deleteErrorBookItem"
| "createLeaveRequest"
| "cancelLeaveRequest"
| "selectElectiveCourse"
| "dropElectiveCourse"
| "submitPracticeAnswer"
| "markAnnouncementRead";
/** 全部操作名 */
export type OperationName = QueryOperationName | MutationOperationName;
@@ -675,6 +1246,21 @@ export interface QueryResponses {
studentGrowth: ActionState<GrowthDataPoint[]>;
assignmentAnalysis: ActionState<AssignmentAnalysis>;
myProfile: ActionState<StudentProfile>;
myErrorBook: ActionState<ErrorBookData>;
myLeaveRequests: ActionState<LeaveRequest[]>;
myElectiveSelections: ActionState<CourseSelection[]>;
availableElectiveCourses: ActionState<ElectiveCourse[]>;
myPracticeSessions: ActionState<MyPracticeSessionsData>;
startPracticeSession: ActionState<PracticeSessionDetail>;
announcements: ActionState<Announcement[]>;
announcementDetail: ActionState<Announcement>;
myLessonPlans: ActionState<LessonPlanListItem[]>;
lessonPlanDetail: ActionState<LessonPlanDetail>;
myCoursePlans: ActionState<CoursePlanListItem[]>;
coursePlanDetail: ActionState<CoursePlanDetail>;
myReportCard: ActionState<ReportCardData>;
myDiagnosticReports: ActionState<DiagnosticReport[]>;
myMasterySummary: ActionState<MasterySummary>;
}
/** 变更响应映射 */
@@ -692,6 +1278,15 @@ export interface MutationResponses {
requestExtension: ActionState<RequestExtensionResult>;
joinClass: ActionState<JoinClassResult>;
leaveClass: ActionState<LeaveClassResult>;
addErrorBookItem: ActionState<AddErrorBookItemResult>;
updateErrorBookItem: ActionState<UpdateErrorBookItemResult>;
deleteErrorBookItem: ActionState<DeleteErrorBookItemResult>;
createLeaveRequest: ActionState<CreateLeaveRequestResult>;
cancelLeaveRequest: ActionState<CancelLeaveRequestResult>;
selectElectiveCourse: ActionState<SelectElectiveCourseResult>;
dropElectiveCourse: ActionState<DropElectiveCourseResult>;
submitPracticeAnswer: ActionState<SubmitPracticeAnswerResult>;
markAnnouncementRead: ActionState<MarkAnnouncementReadResult>;
}
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,51 @@
[
{
"id": "an-001",
"title": "关于期末考试安排的通知",
"content": "各位同学:本学期期末考试将于 7 月 20 日至 7 月 25 日进行,具体科目时间安排请查看考试日程表。请同学们提前做好复习准备,注意考场纪律,携带学生证入场。",
"type": "school",
"status": "published",
"authorName": "教务处",
"publishedAt": "2026-07-10T09:00:00.000Z",
"isPinned": true,
"isReadByCurrentUser": false,
"createdAt": "2026-07-10T08:30:00.000Z",
"updatedAt": "2026-07-10T09:00:00.000Z"
},
{
"id": "an-002",
"title": "高三年级暑期辅导报名通知",
"content": "高三年级暑期辅导班即日起开始报名,涵盖数学、物理、化学等核心科目。有意参加的同学请在 7 月 18 日前到教务处登记。名额有限,先到先得。",
"type": "grade",
"status": "published",
"authorName": "年级组",
"publishedAt": "2026-07-09T14:00:00.000Z",
"isPinned": false,
"isReadByCurrentUser": true,
"createdAt": "2026-07-09T13:00:00.000Z"
},
{
"id": "an-003",
"title": "班级卫生值日调整通知",
"content": "因近期天气炎热,本周班级卫生值日表有所调整,请值日同学按时到岗。具体安排见班级公告栏。",
"type": "class",
"status": "published",
"authorName": "王老师",
"publishedAt": "2026-07-08T10:00:00.000Z",
"isPinned": false,
"isReadByCurrentUser": true,
"createdAt": "2026-07-08T09:30:00.000Z"
},
{
"id": "an-004",
"title": "校园文化艺术节活动通知",
"content": "一年一度的校园文化艺术节将于 7 月 15 日开幕,设有歌唱、舞蹈、书法、摄影等多项比赛。欢迎同学们踊跃报名参加,报名截止日期 7 月 13 日。",
"type": "school",
"status": "published",
"authorName": "校团委",
"publishedAt": "2026-07-05T15:00:00.000Z",
"isPinned": false,
"isReadByCurrentUser": false,
"createdAt": "2026-07-05T14:00:00.000Z"
}
]

View File

@@ -0,0 +1,47 @@
[
{
"id": "cp-001",
"className": "高三(2)班",
"subjectName": "数学",
"teacherName": "张老师",
"semester": "1",
"totalHours": 72,
"completedHours": 48,
"weeklyHours": 4,
"startDate": "2026-02-20",
"endDate": "2026-07-15",
"syllabus": "高三数学上学期教学计划,涵盖函数与导数、三角函数、数列等核心章节。",
"objectives": "掌握函数与导数的基本概念与应用,提升数学建模与解题能力。",
"status": "active"
},
{
"id": "cp-002",
"className": "高三(2)班",
"subjectName": "语文",
"teacherName": "李老师",
"semester": "1",
"totalHours": 64,
"completedHours": 52,
"weeklyHours": 4,
"startDate": "2026-02-20",
"endDate": "2026-07-15",
"syllabus": "高三语文上学期教学计划,涵盖现代文阅读、古代诗文、写作训练。",
"objectives": "提升阅读理解与写作能力,掌握古代诗文鉴赏方法。",
"status": "active"
},
{
"id": "cp-003",
"className": "高三(2)班",
"subjectName": "英语",
"teacherName": "刘老师",
"semester": "1",
"totalHours": 60,
"completedHours": 60,
"weeklyHours": 4,
"startDate": "2026-02-20",
"endDate": "2026-07-15",
"syllabus": "高三英语上学期教学计划,涵盖语法复习、阅读理解、写作与听力训练。",
"objectives": "系统复习语法知识,提升综合语言运用能力。",
"status": "completed"
}
]

View File

@@ -0,0 +1,45 @@
[
{
"id": "dr-001",
"reportType": "individual",
"period": "2025-2026学年第一学期",
"summary": "该生本学期整体学习状态良好,数学与英语表现突出,语文阅读理解有待加强。建议保持优势科目,针对薄弱环节加强练习。",
"strengths": [
"数学逻辑思维能力强,函数与导数章节掌握扎实",
"英语词汇量丰富,听力与阅读理解正确率高"
],
"weaknesses": [
"语文文言文阅读得分率偏低",
"物理力学综合题解题速度需提升"
],
"recommendations": [
"每周增加 2 篇文言文阅读训练",
"针对物理力学综合题进行限时练习",
"保持数学与英语的日常练习频率"
],
"overallScore": 87,
"status": "published",
"createdAt": "2026-05-10T10:00:00.000Z"
},
{
"id": "dr-002",
"reportType": "individual",
"period": "2025-2026学年第二学期期中",
"summary": "期中考试后诊断显示,该生在各科均衡发展方面有所进步,数学成绩显著提升,化学实验题仍需加强。",
"strengths": [
"数学成绩从 85 分提升至 92 分,进步明显",
"英语写作能力提升,语法运用准确率提高"
],
"weaknesses": [
"化学实验设计题得分率仅 60%",
"历史材料分析题时间分配不足"
],
"recommendations": [
"加强化学实验原理理解与设计训练",
"历史答题注意时间管理,先易后难"
],
"overallScore": 89,
"status": "published",
"createdAt": "2026-05-05T14:00:00.000Z"
}
]

View File

@@ -0,0 +1,82 @@
[
{
"id": "ec-001",
"name": "创意写作工作坊",
"teacherName": "李老师",
"subjectName": "语文",
"gradeName": "高三",
"description": "通过沉浸式写作训练,提升文学创作与表达能力,涵盖散文、小说、诗歌等多种体裁。",
"capacity": 30,
"enrolledCount": 22,
"classroom": "文科楼 305",
"schedule": "每周三 16:00-17:40",
"startDate": "2026-09-01",
"endDate": "2027-01-15",
"selectionStartAt": "2026-07-01T00:00:00.000Z",
"selectionEndAt": "2026-07-20T23:59:59.000Z",
"dropDeadline": "2026-09-15T23:59:59.000Z",
"status": "open",
"selectionMode": "fcfs",
"credit": "2"
},
{
"id": "ec-002",
"name": "数学建模入门",
"teacherName": "张老师",
"subjectName": "数学",
"gradeName": "高三",
"description": "学习数学建模基本方法,结合实际问题进行建模实践,培养数学应用能力。",
"capacity": 40,
"enrolledCount": 40,
"classroom": "理科楼 201",
"schedule": "每周二 16:00-17:40",
"startDate": "2026-09-01",
"endDate": "2027-01-15",
"selectionStartAt": "2026-07-01T00:00:00.000Z",
"selectionEndAt": "2026-07-20T23:59:59.000Z",
"dropDeadline": "2026-09-15T23:59:59.000Z",
"status": "open",
"selectionMode": "lottery",
"credit": "3"
},
{
"id": "ec-003",
"name": "趣味物理实验",
"teacherName": "陈老师",
"subjectName": "物理",
"gradeName": "高三",
"description": "通过动手实验探索物理规律,涵盖力学、电磁学、光学等领域的经典实验。",
"capacity": 25,
"enrolledCount": 18,
"classroom": "物理实验室",
"schedule": "每周四 16:00-17:40",
"startDate": "2026-09-01",
"endDate": "2027-01-15",
"selectionStartAt": "2026-07-01T00:00:00.000Z",
"selectionEndAt": "2026-07-20T23:59:59.000Z",
"dropDeadline": "2026-09-15T23:59:59.000Z",
"status": "open",
"selectionMode": "fcfs",
"credit": "2"
},
{
"id": "ec-004",
"name": "英语演讲与辩论",
"teacherName": "刘老师",
"subjectName": "英语",
"gradeName": "高三",
"description": "提升英语口语表达与逻辑思辨能力,模拟辩论赛制进行实战训练。",
"capacity": 20,
"enrolledCount": 15,
"classroom": "外语楼 102",
"schedule": "每周五 16:00-17:40",
"startDate": "2026-09-01",
"endDate": "2027-01-15",
"selectionStartAt": "2026-07-01T00:00:00.000Z",
"selectionEndAt": "2026-07-20T23:59:59.000Z",
"dropDeadline": "2026-09-15T23:59:59.000Z",
"status": "open",
"selectionMode": "fcfs",
"credit": "2"
}
]

View File

@@ -0,0 +1,102 @@
{
"items": [
{
"id": "eb-001",
"questionId": "q-101",
"questionContent": "已知函数 f(x) = x² - 2x + 1求 f(x) 在区间 [0, 2] 上的最小值。",
"sourceType": "exam",
"subject": { "id": "subject-math", "name": "数学" },
"knowledgePointIds": ["kp-001"],
"knowledgePointName": "二次函数最值",
"status": "learning",
"masteryLevel": 0.4,
"nextReviewAt": "2026-07-14T08:00:00.000Z",
"reviewCount": 2,
"correctStreak": 1,
"note": "配方时容易漏掉常数项",
"errorTags": ["概念不清", "计算错误"],
"createdAt": "2026-07-01T10:00:00.000Z",
"updatedAt": "2026-07-10T14:30:00.000Z"
},
{
"id": "eb-002",
"questionId": "q-205",
"questionContent": "阅读下面文段,回答问题:\"月光如流水一般,静静地泻在这一片叶子和花上。\" 这句话运用了什么修辞手法?",
"sourceType": "homework",
"subject": { "id": "subject-chinese", "name": "语文" },
"knowledgePointIds": ["kp-201"],
"knowledgePointName": "修辞手法辨析",
"status": "new",
"masteryLevel": 0.2,
"nextReviewAt": "2026-07-13T08:00:00.000Z",
"reviewCount": 0,
"correctStreak": 0,
"note": null,
"errorTags": ["审题不清"],
"createdAt": "2026-07-08T09:00:00.000Z",
"updatedAt": "2026-07-08T09:00:00.000Z"
},
{
"id": "eb-003",
"questionId": "q-308",
"questionContent": "Choose the correct word to fill in the blank: She ___ to school every day.",
"sourceType": "exam",
"subject": { "id": "subject-english", "name": "英语" },
"knowledgePointIds": ["kp-301"],
"knowledgePointName": "一般现在时",
"status": "mastered",
"masteryLevel": 0.9,
"nextReviewAt": "2026-07-20T08:00:00.000Z",
"reviewCount": 5,
"correctStreak": 4,
"note": null,
"errorTags": [],
"createdAt": "2026-06-15T10:00:00.000Z",
"updatedAt": "2026-07-11T16:00:00.000Z"
},
{
"id": "eb-004",
"questionId": "q-410",
"questionContent": "计算:(-3)² + |-5| - √16 的值。",
"sourceType": "manual",
"subject": { "id": "subject-math", "name": "数学" },
"knowledgePointIds": ["kp-002"],
"knowledgePointName": "有理数运算",
"status": "new",
"masteryLevel": 0.1,
"nextReviewAt": "2026-07-13T08:00:00.000Z",
"reviewCount": 0,
"correctStreak": 0,
"note": "绝对值和平方容易混淆",
"errorTags": ["概念不清", "粗心大意"],
"createdAt": "2026-07-12T11:00:00.000Z",
"updatedAt": "2026-07-12T11:00:00.000Z"
},
{
"id": "eb-005",
"questionId": "q-502",
"questionContent": "简述光合作用的过程及其对生物圈的意义。",
"sourceType": "homework",
"subject": { "id": "subject-biology", "name": "生物" },
"knowledgePointIds": ["kp-501"],
"knowledgePointName": "光合作用",
"status": "learning",
"masteryLevel": 0.55,
"nextReviewAt": "2026-07-15T08:00:00.000Z",
"reviewCount": 3,
"correctStreak": 2,
"note": "光反应和暗反应阶段容易混淆",
"errorTags": ["记忆错误"],
"createdAt": "2026-06-28T14:00:00.000Z",
"updatedAt": "2026-07-09T10:00:00.000Z"
}
],
"stats": {
"totalCount": 5,
"newCount": 2,
"learningCount": 2,
"masteredCount": 1,
"dueReviewCount": 3,
"masteredRate": 0.2
}
}

View File

@@ -0,0 +1,58 @@
[
{
"id": "lv-001",
"leaveType": "sick",
"startDate": "2026-07-10",
"endDate": "2026-07-11",
"reason": "感冒发烧,需在家休息恢复。",
"status": "approved",
"reviewComment": "注意休息,祝早日康复。",
"reviewedAt": "2026-07-09T14:30:00.000Z",
"reviewerName": "王老师",
"className": "高三(2)班",
"attendanceSynced": true,
"createdAt": "2026-07-09T08:15:00.000Z"
},
{
"id": "lv-002",
"leaveType": "personal",
"startDate": "2026-07-15",
"endDate": "2026-07-15",
"reason": "家中有事需处理,请假一天。",
"status": "pending",
"reviewComment": null,
"reviewedAt": null,
"reviewerName": null,
"className": "高三(2)班",
"attendanceSynced": false,
"createdAt": "2026-07-13T09:00:00.000Z"
},
{
"id": "lv-003",
"leaveType": "family",
"startDate": "2026-06-20",
"endDate": "2026-06-22",
"reason": "参加亲属婚礼,需请假三天。",
"status": "rejected",
"reviewComment": "临近期末考试,建议调整时间。",
"reviewedAt": "2026-06-19T16:00:00.000Z",
"reviewerName": "王老师",
"className": "高三(2)班",
"attendanceSynced": false,
"createdAt": "2026-06-18T10:00:00.000Z"
},
{
"id": "lv-004",
"leaveType": "other",
"startDate": "2026-07-05",
"endDate": "2026-07-05",
"reason": "参加市级学科竞赛培训。",
"status": "cancelled",
"reviewComment": null,
"reviewedAt": null,
"reviewerName": null,
"className": "高三(2)班",
"attendanceSynced": false,
"createdAt": "2026-07-04T11:20:00.000Z"
}
]

View File

@@ -0,0 +1,46 @@
[
{
"id": "lp-001",
"title": "二次函数的图像与性质",
"subjectName": "数学",
"gradeName": "高三",
"chapterTitle": "第一章 函数与导数",
"creatorName": "张老师",
"status": "published",
"publishedAt": "2026-07-08T08:00:00.000Z",
"updatedAt": "2026-07-08T08:00:00.000Z"
},
{
"id": "lp-002",
"title": "文言文阅读:赤壁赋",
"subjectName": "语文",
"gradeName": "高三",
"chapterTitle": "第三章 古代散文",
"creatorName": "李老师",
"status": "published",
"publishedAt": "2026-07-07T10:00:00.000Z",
"updatedAt": "2026-07-07T10:00:00.000Z"
},
{
"id": "lp-003",
"title": "牛顿运动定律的应用",
"subjectName": "物理",
"gradeName": "高三",
"chapterTitle": "第二章 力学",
"creatorName": "陈老师",
"status": "published",
"publishedAt": "2026-07-06T09:00:00.000Z",
"updatedAt": "2026-07-06T09:00:00.000Z"
},
{
"id": "lp-004",
"title": "化学平衡常数与计算",
"subjectName": "化学",
"gradeName": "高三",
"chapterTitle": "第四章 化学反应原理",
"creatorName": "赵老师",
"status": "published",
"publishedAt": "2026-07-05T14:00:00.000Z",
"updatedAt": "2026-07-05T14:00:00.000Z"
}
]

View File

@@ -0,0 +1,49 @@
{
"studentId": "stu-001",
"studentName": "陈同学",
"averageMastery": 76,
"totalKnowledgePoints": 42,
"confidenceLevel": "high",
"mastery": [
{
"knowledgePointId": "kp-math-01",
"knowledgePointName": "函数与导数",
"masteryLevel": 92,
"totalQuestions": 35,
"correctQuestions": 32,
"lastAssessedAt": "2026-07-10T14:00:00.000Z"
},
{
"knowledgePointId": "kp-math-02",
"knowledgePointName": "三角函数",
"masteryLevel": 78,
"totalQuestions": 28,
"correctQuestions": 22,
"lastAssessedAt": "2026-07-09T10:00:00.000Z"
},
{
"knowledgePointId": "kp-chinese-01",
"knowledgePointName": "文言文阅读",
"masteryLevel": 58,
"totalQuestions": 20,
"correctQuestions": 12,
"lastAssessedAt": "2026-07-08T15:00:00.000Z"
},
{
"knowledgePointId": "kp-english-01",
"knowledgePointName": "阅读理解",
"masteryLevel": 85,
"totalQuestions": 40,
"correctQuestions": 34,
"lastAssessedAt": "2026-07-11T09:00:00.000Z"
},
{
"knowledgePointId": "kp-physics-01",
"knowledgePointName": "力学综合",
"masteryLevel": 68,
"totalQuestions": 25,
"correctQuestions": 17,
"lastAssessedAt": "2026-07-07T11:00:00.000Z"
}
]
}

View File

@@ -0,0 +1,55 @@
{
"sessions": [
{
"id": "ps-001",
"practiceType": "error_variant",
"status": "completed",
"totalQuestions": 10,
"answeredQuestions": 10,
"correctCount": 8,
"accuracy": 0.8,
"startedAt": "2026-07-10T14:00:00.000Z",
"completedAt": "2026-07-10T14:25:00.000Z"
},
{
"id": "ps-002",
"practiceType": "knowledge_point",
"status": "completed",
"totalQuestions": 8,
"answeredQuestions": 8,
"correctCount": 6,
"accuracy": 0.75,
"startedAt": "2026-07-11T19:00:00.000Z",
"completedAt": "2026-07-11T19:18:00.000Z"
},
{
"id": "ps-003",
"practiceType": "weak_chapter",
"status": "in_progress",
"totalQuestions": 12,
"answeredQuestions": 5,
"correctCount": 3,
"accuracy": 0.6,
"startedAt": "2026-07-13T10:00:00.000Z",
"completedAt": null
},
{
"id": "ps-004",
"practiceType": "ai_recommended",
"status": "abandoned",
"totalQuestions": 10,
"answeredQuestions": 2,
"correctCount": 1,
"accuracy": 0.5,
"startedAt": "2026-07-08T16:00:00.000Z",
"completedAt": null
}
],
"stats": {
"totalSessions": 4,
"completedSessions": 2,
"totalQuestionsAnswered": 25,
"totalCorrect": 18,
"overallAccuracy": 0.72
}
}

View File

@@ -0,0 +1,101 @@
{
"studentId": "stu-001",
"studentName": "陈同学",
"classId": "cls-001",
"className": "高三(2)班",
"classTeacherName": "王老师",
"academicYearName": "2025-2026学年",
"semester": "1",
"subjects": [
{
"subjectId": "subject-math",
"subjectName": "数学",
"averageScore": 88.5,
"rankInSubject": 5,
"totalStudentsInSubject": 45,
"fullScoreTotal": 442.5,
"records": [
{
"id": "gr-001",
"examName": "月考一",
"score": 85,
"maxScore": 100,
"grade": "良好",
"rank": 6,
"submittedAt": "2026-03-15T10:00:00.000Z"
},
{
"id": "gr-002",
"examName": "期中考试",
"score": 92,
"maxScore": 100,
"grade": "优秀",
"rank": 3,
"submittedAt": "2026-04-20T10:00:00.000Z"
}
]
},
{
"subjectId": "subject-chinese",
"subjectName": "语文",
"averageScore": 82.0,
"rankInSubject": 8,
"totalStudentsInSubject": 45,
"fullScoreTotal": 410.0,
"records": [
{
"id": "gr-003",
"examName": "月考一",
"score": 80,
"maxScore": 100,
"grade": "良好",
"rank": 10,
"submittedAt": "2026-03-16T10:00:00.000Z"
},
{
"id": "gr-004",
"examName": "期中考试",
"score": 84,
"maxScore": 100,
"grade": "良好",
"rank": 7,
"submittedAt": "2026-04-21T10:00:00.000Z"
}
]
},
{
"subjectId": "subject-english",
"subjectName": "英语",
"averageScore": 90.5,
"rankInSubject": 3,
"totalStudentsInSubject": 45,
"fullScoreTotal": 452.5,
"records": [
{
"id": "gr-005",
"examName": "月考一",
"score": 89,
"maxScore": 100,
"grade": "良好",
"rank": 4,
"submittedAt": "2026-03-17T10:00:00.000Z"
},
{
"id": "gr-006",
"examName": "期中考试",
"score": 92,
"maxScore": 100,
"grade": "优秀",
"rank": 2,
"submittedAt": "2026-04-22T10:00:00.000Z"
}
]
}
],
"overallAverage": 87.0,
"overallRank": 4,
"classTotalStudents": 45,
"overallPassRate": 0.96,
"overallExcellentRate": 0.33,
"generatedAt": "2026-07-13T12:00:00.000Z"
}

View File

@@ -40,6 +40,17 @@ import myScheduleFixture from "./fixtures/mySchedule.json";
import studentGrowthFixture from "./fixtures/studentGrowth.json";
import assignmentAnalysisFixture from "./fixtures/assignmentAnalysis.json";
import myProfileFixture from "./fixtures/myProfile.json";
// 批次 A 扩展 fixture
import errorBookFixture from "./fixtures/errorBook.json";
import leaveRequestsFixture from "./fixtures/leaveRequests.json";
import electiveCoursesFixture from "./fixtures/electiveCourses.json";
import practiceSessionsFixture from "./fixtures/practiceSessions.json";
import announcementsFixture from "./fixtures/announcements.json";
import lessonPlansFixture from "./fixtures/lessonPlans.json";
import coursePlansFixture from "./fixtures/coursePlans.json";
import reportCardFixture from "./fixtures/reportCard.json";
import diagnosticReportsFixture from "./fixtures/diagnosticReports.json";
import masterySummaryFixture from "./fixtures/masterySummary.json";
// ---------------------------------------------------------------------------
// GraphQL 请求/响应类型
@@ -275,6 +286,181 @@ function handleQuery(
case "myProfile":
return graphqlOk("myProfile", ok(myProfileFixture));
// === 批次 A 扩展查询 ===
case "myErrorBook":
return graphqlOk("myErrorBook", ok(errorBookFixture));
case "myLeaveRequests":
return graphqlOk("myLeaveRequests", ok(leaveRequestsFixture));
case "myElectiveSelections": {
// 从可选课程中派生选课记录
const selections = electiveCoursesFixture.slice(0, 2).map((c) => ({
id: `sel-${c.id}`,
courseId: c.id,
courseName: c.name,
status: "enrolled" as const,
selectedAt: "2026-07-02T10:00:00.000Z",
enrolledAt: "2026-07-03T08:00:00.000Z",
droppedAt: null,
dropReason: null,
course: {
id: c.id,
name: c.name,
teacherName: c.teacherName,
subjectName: c.subjectName,
capacity: c.capacity,
enrolledCount: c.enrolledCount,
classroom: c.classroom,
schedule: c.schedule,
credit: c.credit,
status: c.status,
},
}));
return graphqlOk("myElectiveSelections", ok(selections));
}
case "availableElectiveCourses":
return graphqlOk(
"availableElectiveCourses",
ok(electiveCoursesFixture),
);
case "myPracticeSessions":
return graphqlOk("myPracticeSessions", ok(practiceSessionsFixture));
case "startPracticeSession": {
const input = (variables["input"] ?? {}) as Record<string, unknown>;
const practiceType = (input["practiceType"] as string) ?? "error_variant";
const session = {
id: `ps-${Date.now()}`,
practiceType,
status: "in_progress" as const,
totalQuestions: 10,
answeredQuestions: 0,
correctCount: 0,
accuracy: 0,
startedAt: new Date().toISOString(),
currentQuestionIndex: 0,
questions: [
{
id: "pq-001",
questionId: "q-001",
content: "已知函数 f(x) = x² - 2x + 1求 f(x) 的最小值。",
type: "short-answer",
options: [],
maxScore: 5,
status: "pending" as const,
},
{
id: "pq-002",
questionId: "q-002",
content: "下列哪个是二次函数的标准形式?",
type: "single-choice",
options: [
{ id: "opt-a", text: "y = ax + b" },
{ id: "opt-b", text: "y = ax² + bx + c (a≠0)" },
{ id: "opt-c", text: "y = a/x" },
{ id: "opt-d", text: "y = √x" },
],
maxScore: 5,
status: "pending" as const,
},
],
};
return graphqlOk("startPracticeSession", ok({ session }));
}
case "announcements":
return graphqlOk("announcements", ok(announcementsFixture));
case "announcementDetail": {
const announcementId =
(variables["announcementId"] as string) ?? "an-001";
const detail =
announcementsFixture.find((a) => a.id === announcementId) ??
announcementsFixture[0];
return graphqlOk("announcementDetail", ok(detail));
}
case "myLessonPlans":
return graphqlOk("myLessonPlans", ok(lessonPlansFixture));
case "lessonPlanDetail": {
const planId = (variables["planId"] as string) ?? "lp-001";
const item =
lessonPlansFixture.find((l) => l.id === planId) ?? lessonPlansFixture[0];
const detail = {
...item,
content: {
objectives:
"1. 理解核心概念与基本原理\n2. 掌握解题方法与技巧\n3. 能够综合运用解决实际问题",
keyPoints: "重点:核心公式的推导与应用\n难点综合题型的分析与解题策略",
summary: "本节课围绕核心知识点展开,通过例题讲解与课堂练习巩固理解。",
homework: "完成课后练习第 1-5 题,预习下一节内容。",
blackboard: "板书要点:公式推导流程图 + 例题解题步骤",
},
};
return graphqlOk("lessonPlanDetail", ok(detail));
}
case "myCoursePlans":
return graphqlOk("myCoursePlans", ok(coursePlansFixture));
case "coursePlanDetail": {
const planId = (variables["planId"] as string) ?? "cp-001";
const item =
coursePlansFixture.find((c) => c.id === planId) ?? coursePlansFixture[0];
const detail = {
...item,
items: [
{
id: "cpi-001",
week: 1,
topic: "函数的基本概念",
content: "函数定义、定义域、值域、表示方法",
hours: 4,
textbookChapter: "第 1.1 节",
notes: null,
isCompleted: true,
completedAt: "2026-02-23T16:00:00.000Z",
},
{
id: "cpi-002",
week: 2,
topic: "函数的单调性与奇偶性",
content: "单调性判定、奇偶性判定、综合应用",
hours: 4,
textbookChapter: "第 1.2-1.3 节",
notes: "注意数形结合",
isCompleted: true,
completedAt: "2026-03-02T16:00:00.000Z",
},
{
id: "cpi-013",
week: 13,
topic: "期末复习与综合训练",
content: "全册知识点梳理、模拟试卷讲评",
hours: 4,
textbookChapter: "复习章节",
notes: null,
isCompleted: false,
completedAt: null,
},
],
};
return graphqlOk("coursePlanDetail", ok(detail));
}
case "myReportCard":
return graphqlOk("myReportCard", ok(reportCardFixture));
case "myDiagnosticReports":
return graphqlOk("myDiagnosticReports", ok(diagnosticReportsFixture));
case "myMasterySummary":
return graphqlOk("myMasterySummary", ok(masterySummaryFixture));
default: {
// 未覆盖的查询操作名,返回错误
return graphqlError(
@@ -433,6 +619,127 @@ function handleMutation(
return graphqlOk("leaveClass", ok(result));
}
// === 批次 A 扩展变更 ===
case "addErrorBookItem": {
const input = (variables["input"] ?? {}) as Record<string, unknown>;
const result = {
id: `eb-${Date.now()}`,
status: "new" as const,
createdAt: new Date().toISOString(),
};
void input;
return graphqlOk("addErrorBookItem", ok(result));
}
case "updateErrorBookItem": {
const input = (variables["input"] ?? {}) as Record<string, unknown>;
const id = (input["id"] as string) ?? "";
const reviewResult = input["reviewResult"] as
| "again"
| "hard"
| "good"
| "easy"
| undefined;
const masteryMap: Record<string, number> = {
again: 0.2,
hard: 0.4,
good: 0.7,
easy: 0.9,
};
const mastery = reviewResult ? masteryMap[reviewResult] ?? 0.5 : 0.5;
const nextReview = new Date();
nextReview.setDate(nextReview.getDate() + (reviewResult === "again" ? 1 : reviewResult === "hard" ? 3 : 7));
const result = {
id,
status: mastery >= 0.8 ? ("mastered" as const) : ("learning" as const),
masteryLevel: mastery,
nextReviewAt: nextReview.toISOString(),
reviewCount: 1,
};
return graphqlOk("updateErrorBookItem", ok(result));
}
case "deleteErrorBookItem": {
const input = (variables["input"] ?? {}) as Record<string, unknown>;
const id = (input["id"] as string) ?? "";
return graphqlOk("deleteErrorBookItem", ok({ id, deleted: true }));
}
case "createLeaveRequest": {
const input = (variables["input"] ?? {}) as Record<string, unknown>;
const result = {
id: `lv-${Date.now()}`,
status: "pending" as const,
createdAt: new Date().toISOString(),
};
void input;
return graphqlOk("createLeaveRequest", ok(result));
}
case "cancelLeaveRequest": {
const input = (variables["input"] ?? {}) as Record<string, unknown>;
const id = (input["id"] as string) ?? "";
return graphqlOk(
"cancelLeaveRequest",
ok({ id, status: "cancelled" as const }),
);
}
case "selectElectiveCourse": {
const input = (variables["input"] ?? {}) as Record<string, unknown>;
const courseId = (input["courseId"] as string) ?? "";
const result = {
id: `sel-${Date.now()}`,
courseId,
status: "enrolled" as const,
selectedAt: new Date().toISOString(),
};
return graphqlOk("selectElectiveCourse", ok(result));
}
case "dropElectiveCourse": {
const input = (variables["input"] ?? {}) as Record<string, unknown>;
const courseId = (input["courseId"] as string) ?? "";
const result = {
id: `sel-${courseId}`,
courseId,
status: "dropped" as const,
droppedAt: new Date().toISOString(),
};
return graphqlOk("dropElectiveCourse", ok(result));
}
case "submitPracticeAnswer": {
const input = (variables["input"] ?? {}) as Record<string, unknown>;
const questionId = (input["questionId"] as string) ?? "";
const isCorrect = questionId === "q-002" && input["answer"] === "opt-b";
const result = {
answerId: `ans-${Date.now()}`,
isCorrect,
score: isCorrect ? 5 : 0,
maxScore: 5,
correctAnswer: questionId === "q-002" ? "opt-b" : "x=1",
sessionCompleted: false,
session: {
id: (input["sessionId"] as string) ?? "",
answeredQuestions: 1,
correctCount: isCorrect ? 1 : 0,
accuracy: isCorrect ? 1 : 0,
status: "in_progress" as const,
},
};
return graphqlOk("submitPracticeAnswer", ok(result));
}
case "markAnnouncementRead": {
const input = (variables["input"] ?? {}) as Record<string, unknown>;
const announcementId = (input["announcementId"] as string) ?? "";
return graphqlOk(
"markAnnouncementRead",
ok({ announcementId, read: true }),
);
}
default: {
// 未覆盖的变更操作名,返回错误
return graphqlError(
@@ -469,6 +776,21 @@ const QUERY_NAMES = new Set<QueryOperationName>([
"studentGrowth",
"assignmentAnalysis",
"myProfile",
"myErrorBook",
"myLeaveRequests",
"myElectiveSelections",
"availableElectiveCourses",
"myPracticeSessions",
"startPracticeSession",
"announcements",
"announcementDetail",
"myLessonPlans",
"lessonPlanDetail",
"myCoursePlans",
"coursePlanDetail",
"myReportCard",
"myDiagnosticReports",
"myMasterySummary",
]);
/** 变更操作名集合 */
@@ -571,6 +893,16 @@ export const fixtures = {
studentGrowth: studentGrowthFixture,
assignmentAnalysis: assignmentAnalysisFixture,
myProfile: myProfileFixture,
errorBook: errorBookFixture,
leaveRequests: leaveRequestsFixture,
electiveCourses: electiveCoursesFixture,
practiceSessions: practiceSessionsFixture,
announcements: announcementsFixture,
lessonPlans: lessonPlansFixture,
coursePlans: coursePlansFixture,
reportCard: reportCardFixture,
diagnosticReports: diagnosticReportsFixture,
masterySummary: masterySummaryFixture,
};
/** 导出工具函数供测试使用 */