feat(student-portal): 实现CICD参考项目差距功能 - 错题本/请假/选课/自适应练习/公告/课案/课程计划/成绩报告卡/学情诊断增强
This commit is contained in:
104
apps/student-portal/src/app/announcements/[id]/page.tsx
Normal file
104
apps/student-portal/src/app/announcements/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
162
apps/student-portal/src/app/announcements/page.tsx
Normal file
162
apps/student-portal/src/app/announcements/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
221
apps/student-portal/src/app/course-plans/[id]/page.tsx
Normal file
221
apps/student-portal/src/app/course-plans/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
153
apps/student-portal/src/app/course-plans/page.tsx
Normal file
153
apps/student-portal/src/app/course-plans/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,11 @@
|
||||
/**
|
||||
* 学情诊断页(ai14,P4,路由 /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>
|
||||
);
|
||||
}
|
||||
|
||||
238
apps/student-portal/src/app/elective/[id]/page.tsx
Normal file
238
apps/student-portal/src/app/elective/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
269
apps/student-portal/src/app/elective/page.tsx
Normal file
269
apps/student-portal/src/app/elective/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
341
apps/student-portal/src/app/error-book/page.tsx
Normal file
341
apps/student-portal/src/app/error-book/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
354
apps/student-portal/src/app/leave/page.tsx
Normal file
354
apps/student-portal/src/app/leave/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
141
apps/student-portal/src/app/lesson-plans/[id]/view/page.tsx
Normal file
141
apps/student-portal/src/app/lesson-plans/[id]/view/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
apps/student-portal/src/app/lesson-plans/page.tsx
Normal file
124
apps/student-portal/src/app/lesson-plans/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
176
apps/student-portal/src/app/my-grades/report-card/page.tsx
Normal file
176
apps/student-portal/src/app/my-grades/report-card/page.tsx
Normal 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";
|
||||
}
|
||||
224
apps/student-portal/src/app/practice/[sessionId]/page.tsx
Normal file
224
apps/student-portal/src/app/practice/[sessionId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
207
apps/student-portal/src/app/practice/page.tsx
Normal file
207
apps/student-portal/src/app/practice/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 类型导出(供响应映射使用)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
51
apps/student-portal/src/mocks/fixtures/announcements.json
Normal file
51
apps/student-portal/src/mocks/fixtures/announcements.json
Normal 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"
|
||||
}
|
||||
]
|
||||
47
apps/student-portal/src/mocks/fixtures/coursePlans.json
Normal file
47
apps/student-portal/src/mocks/fixtures/coursePlans.json
Normal 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"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
82
apps/student-portal/src/mocks/fixtures/electiveCourses.json
Normal file
82
apps/student-portal/src/mocks/fixtures/electiveCourses.json
Normal 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"
|
||||
}
|
||||
]
|
||||
102
apps/student-portal/src/mocks/fixtures/errorBook.json
Normal file
102
apps/student-portal/src/mocks/fixtures/errorBook.json
Normal 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
|
||||
}
|
||||
}
|
||||
58
apps/student-portal/src/mocks/fixtures/leaveRequests.json
Normal file
58
apps/student-portal/src/mocks/fixtures/leaveRequests.json
Normal 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"
|
||||
}
|
||||
]
|
||||
46
apps/student-portal/src/mocks/fixtures/lessonPlans.json
Normal file
46
apps/student-portal/src/mocks/fixtures/lessonPlans.json
Normal 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"
|
||||
}
|
||||
]
|
||||
49
apps/student-portal/src/mocks/fixtures/masterySummary.json
Normal file
49
apps/student-portal/src/mocks/fixtures/masterySummary.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
55
apps/student-portal/src/mocks/fixtures/practiceSessions.json
Normal file
55
apps/student-portal/src/mocks/fixtures/practiceSessions.json
Normal 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
|
||||
}
|
||||
}
|
||||
101
apps/student-portal/src/mocks/fixtures/reportCard.json
Normal file
101
apps/student-portal/src/mocks/fixtures/reportCard.json
Normal 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"
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/** 导出工具函数供测试使用 */
|
||||
|
||||
Reference in New Issue
Block a user