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)
|
* 学情诊断页(ai14,P4,路由 /dashboard/weakness)
|
||||||
*
|
*
|
||||||
* - 消费 useMyWeakness 查询
|
* - 消费 useMyWeakness 查询:科目掌握度雷达图 + 薄弱知识点卡片
|
||||||
* - 雷达图(recharts)按科目展示掌握度
|
* - 消费 useMyMasterySummary 查询:知识点掌握度雷达图 + 置信度等级
|
||||||
* - 薄弱知识点卡片:科目、知识点、掌握度、建议
|
* - 消费 useMyDiagnosticReports 查询:诊断报告列表(优势/弱项/建议)
|
||||||
|
*
|
||||||
|
* 三态覆盖:加载 / 错误 / 空
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
@@ -18,27 +20,54 @@ import {
|
|||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { useMyWeakness } from "@/lib/graphql/hooks";
|
import {
|
||||||
import type { RecommendedAction, WeaknessPoint } from "@/lib/graphql/types";
|
useMyDiagnosticReports,
|
||||||
|
useMyMasterySummary,
|
||||||
|
useMyWeakness,
|
||||||
|
} from "@/lib/graphql/hooks";
|
||||||
|
import type {
|
||||||
|
ConfidenceLevel,
|
||||||
|
DiagnosticReport,
|
||||||
|
RecommendedAction,
|
||||||
|
WeaknessPoint,
|
||||||
|
} from "@/lib/graphql/types";
|
||||||
|
|
||||||
interface RadarDatum {
|
interface SubjectRadarDatum {
|
||||||
subject: string;
|
subject: string;
|
||||||
mastery: number;
|
mastery: number;
|
||||||
fullMark: number;
|
fullMark: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface KnowledgeRadarDatum {
|
||||||
|
knowledge: string;
|
||||||
|
mastery: number;
|
||||||
|
fullMark: number;
|
||||||
|
}
|
||||||
|
|
||||||
const RECOMMENDED_ACTION_LABEL: Record<RecommendedAction, string> = {
|
const RECOMMENDED_ACTION_LABEL: Record<RecommendedAction, string> = {
|
||||||
review: "建议复习",
|
review: "建议复习",
|
||||||
practice: "建议练习",
|
practice: "建议练习",
|
||||||
mastered: "已掌握",
|
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 {
|
function masteryPercent(mastery: number): number {
|
||||||
return Math.round(Math.max(0, Math.min(1, mastery)) * 100);
|
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 }>();
|
const map = new Map<string, { sum: number; count: number }>();
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const name = item.subject.name;
|
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 {
|
export default function WeaknessPage(): React.ReactNode {
|
||||||
const { data, fetching, error, reexecute } = useMyWeakness();
|
const {
|
||||||
const items = data ?? [];
|
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 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 (
|
return (
|
||||||
<main
|
<main
|
||||||
@@ -68,33 +136,135 @@ export default function WeaknessPage(): React.ReactNode {
|
|||||||
<h1 id="weakness-title" className="font-serif text-2xl text-ink">
|
<h1 id="weakness-title" className="font-serif text-2xl text-ink">
|
||||||
学情诊断
|
学情诊断
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-xs text-sm text-ink-muted">各科目知识点掌握度雷达图</p>
|
<p className="mt-xs text-sm text-ink-muted">
|
||||||
|
各科目与知识点掌握度雷达图 + 诊断报告
|
||||||
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="rule-thin mb-lg" />
|
<div className="rule-thin mb-lg" />
|
||||||
|
|
||||||
{fetching ? (
|
{/* 综合掌握度概览(来自 masterySummary) */}
|
||||||
<p className="text-sm text-ink-muted" aria-live="polite">
|
{masteryFetching ? (
|
||||||
加载中…
|
<p className="text-sm text-ink-muted mb-lg" aria-live="polite">
|
||||||
|
加载掌握度摘要…
|
||||||
</p>
|
</p>
|
||||||
) : error ? (
|
) : masteryError ? (
|
||||||
<div className="mark-left py-md px-md mb-md" aria-live="assertive">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-secondary mt-sm"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<p className="text-sm text-ink-muted" role="status">
|
<p className="text-sm text-ink-muted mb-lg" role="status">
|
||||||
暂无薄弱知识点数据,继续保持!
|
暂无薄弱知识点数据,继续保持!
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* 雷达图 */}
|
|
||||||
<section
|
<section
|
||||||
className="card-paper p-lg mb-lg"
|
className="card-paper p-lg mb-lg"
|
||||||
aria-label="科目掌握度雷达图"
|
aria-label="科目掌握度雷达图"
|
||||||
@@ -135,7 +305,7 @@ export default function WeaknessPage(): React.ReactNode {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 薄弱知识点卡片 */}
|
{/* 薄弱知识点卡片 */}
|
||||||
<section aria-label="薄弱知识点列表">
|
<section aria-label="薄弱知识点列表" className="mb-lg">
|
||||||
<h2 className="font-serif text-base text-ink mb-md">薄弱知识点</h2>
|
<h2 className="font-serif text-base text-ink mb-md">薄弱知识点</h2>
|
||||||
<ul className="space-y-md">
|
<ul className="space-y-md">
|
||||||
{items.map((item: WeaknessPoint) => {
|
{items.map((item: WeaknessPoint) => {
|
||||||
@@ -177,6 +347,146 @@ export default function WeaknessPage(): React.ReactNode {
|
|||||||
</section>
|
</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>
|
</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[] = [
|
const NAV_ITEMS: NavItem[] = [
|
||||||
{ label: "Dashboard", route: "/dashboard" },
|
{ label: "Dashboard", route: "/dashboard" },
|
||||||
{ label: "我的班级", route: "/my-classes" },
|
{ label: "我的班级", route: "/my-classes" },
|
||||||
{ label: "我的考试", route: "/my-exams" },
|
{ label: "我的课表", route: "/schedule" },
|
||||||
|
{ label: "选课中心", route: "/elective" },
|
||||||
{ label: "我的作业", route: "/my-homework" },
|
{ label: "我的作业", route: "/my-homework" },
|
||||||
|
{ label: "我的考试", route: "/my-exams" },
|
||||||
{ label: "我的成绩", route: "/my-grades" },
|
{ label: "我的成绩", route: "/my-grades" },
|
||||||
{ label: "我的考勤", route: "/my-attendance" },
|
{ label: "我的考勤", route: "/my-attendance" },
|
||||||
{ label: "我的课表", route: "/schedule" },
|
{ label: "错题本", route: "/error-book" },
|
||||||
|
{ label: "在线请假", route: "/leave" },
|
||||||
{ label: "学习路径", route: "/learning-path" },
|
{ 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: "/textbooks" },
|
||||||
|
{ label: "公告", route: "/announcements" },
|
||||||
{ label: "通知中心", route: "/notifications" },
|
{ label: "通知中心", route: "/notifications" },
|
||||||
{ label: "AI辅学", route: "/ai-tutor" },
|
{ label: "AI辅学", route: "/ai-tutor" },
|
||||||
{ label: "系统设置", route: "/settings" },
|
{ label: "系统设置", route: "/settings" },
|
||||||
|
|||||||
@@ -17,13 +17,30 @@ import type { CombinedError } from "urql";
|
|||||||
import type {
|
import type {
|
||||||
ActionError,
|
ActionError,
|
||||||
ActionState,
|
ActionState,
|
||||||
|
AddErrorBookItemInput,
|
||||||
|
AddErrorBookItemResult,
|
||||||
|
Announcement,
|
||||||
AssignmentAnalysis,
|
AssignmentAnalysis,
|
||||||
AttendanceRecord,
|
AttendanceRecord,
|
||||||
|
CancelLeaveRequestInput,
|
||||||
|
CancelLeaveRequestResult,
|
||||||
ChangePasswordInput,
|
ChangePasswordInput,
|
||||||
ChangePasswordResult,
|
ChangePasswordResult,
|
||||||
ChapterNode,
|
ChapterNode,
|
||||||
ClassInfo,
|
ClassInfo,
|
||||||
|
CoursePlanDetail,
|
||||||
|
CoursePlanListItem,
|
||||||
|
CourseSelection,
|
||||||
|
CreateLeaveRequestInput,
|
||||||
|
CreateLeaveRequestResult,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
|
DeleteErrorBookItemInput,
|
||||||
|
DeleteErrorBookItemResult,
|
||||||
|
DiagnosticReport,
|
||||||
|
DropElectiveCourseInput,
|
||||||
|
DropElectiveCourseResult,
|
||||||
|
ElectiveCourse,
|
||||||
|
ErrorBookData,
|
||||||
ExamDetail,
|
ExamDetail,
|
||||||
ExamListItem,
|
ExamListItem,
|
||||||
GradeRecord,
|
GradeRecord,
|
||||||
@@ -34,33 +51,49 @@ import type {
|
|||||||
JoinClassResult,
|
JoinClassResult,
|
||||||
LeaveClassInput,
|
LeaveClassInput,
|
||||||
LeaveClassResult,
|
LeaveClassResult,
|
||||||
|
LeaveRequest,
|
||||||
LearningPathNode,
|
LearningPathNode,
|
||||||
|
LessonPlanDetail,
|
||||||
|
LessonPlanListItem,
|
||||||
MarkAllAsReadInput,
|
MarkAllAsReadInput,
|
||||||
MarkAllAsReadResult,
|
MarkAllAsReadResult,
|
||||||
|
MarkAnnouncementReadInput,
|
||||||
|
MarkAnnouncementReadResult,
|
||||||
MarkAsReadInput,
|
MarkAsReadInput,
|
||||||
MarkAsReadResult,
|
MarkAsReadResult,
|
||||||
|
MasterySummary,
|
||||||
MutationResponses,
|
MutationResponses,
|
||||||
|
MyPracticeSessionsData,
|
||||||
NotificationItem,
|
NotificationItem,
|
||||||
|
PracticeSessionDetail,
|
||||||
QueryResponses,
|
QueryResponses,
|
||||||
RecordExamViolationInput,
|
RecordExamViolationInput,
|
||||||
RecordExamViolationResult,
|
RecordExamViolationResult,
|
||||||
RecordPasteEventInput,
|
RecordPasteEventInput,
|
||||||
RecordPasteEventResult,
|
RecordPasteEventResult,
|
||||||
|
ReportCardData,
|
||||||
RequestExtensionInput,
|
RequestExtensionInput,
|
||||||
RequestExtensionResult,
|
RequestExtensionResult,
|
||||||
SaveExamDraftInput,
|
SaveExamDraftInput,
|
||||||
SaveExamDraftResult,
|
SaveExamDraftResult,
|
||||||
ScheduleItem,
|
ScheduleItem,
|
||||||
|
SelectElectiveCourseInput,
|
||||||
|
SelectElectiveCourseResult,
|
||||||
ServerTime,
|
ServerTime,
|
||||||
|
StartPracticeSessionInput,
|
||||||
StudentDashboard,
|
StudentDashboard,
|
||||||
StudentProfile,
|
StudentProfile,
|
||||||
SubmitExamInput,
|
SubmitExamInput,
|
||||||
SubmitExamResult,
|
SubmitExamResult,
|
||||||
SubmitHomeworkInput,
|
SubmitHomeworkInput,
|
||||||
SubmitHomeworkResult,
|
SubmitHomeworkResult,
|
||||||
|
SubmitPracticeAnswerInput,
|
||||||
|
SubmitPracticeAnswerResult,
|
||||||
SubjectRef,
|
SubjectRef,
|
||||||
Textbook,
|
Textbook,
|
||||||
TrendDataPoint,
|
TrendDataPoint,
|
||||||
|
UpdateErrorBookItemInput,
|
||||||
|
UpdateErrorBookItemResult,
|
||||||
UpdateNotificationPreferenceInput,
|
UpdateNotificationPreferenceInput,
|
||||||
UpdateNotificationPreferenceResult,
|
UpdateNotificationPreferenceResult,
|
||||||
UpdateProfileInput,
|
UpdateProfileInput,
|
||||||
@@ -68,24 +101,44 @@ import type {
|
|||||||
WeaknessPoint,
|
WeaknessPoint,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import {
|
import {
|
||||||
|
ADD_ERROR_BOOK_ITEM_MUTATION,
|
||||||
|
ANNOUNCEMENT_DETAIL_QUERY,
|
||||||
|
ANNOUNCEMENTS_QUERY,
|
||||||
ASSIGNMENT_ANALYSIS_QUERY,
|
ASSIGNMENT_ANALYSIS_QUERY,
|
||||||
|
AVAILABLE_ELECTIVE_COURSES_QUERY,
|
||||||
|
CANCEL_LEAVE_REQUEST_MUTATION,
|
||||||
CHANGE_PASSWORD_MUTATION,
|
CHANGE_PASSWORD_MUTATION,
|
||||||
CHAPTERS_QUERY,
|
CHAPTERS_QUERY,
|
||||||
|
COURSE_PLAN_DETAIL_QUERY,
|
||||||
|
CREATE_LEAVE_REQUEST_MUTATION,
|
||||||
CURRENT_USER_QUERY,
|
CURRENT_USER_QUERY,
|
||||||
|
DELETE_ERROR_BOOK_ITEM_MUTATION,
|
||||||
|
DROP_ELECTIVE_COURSE_MUTATION,
|
||||||
EXAM_DETAIL_QUERY,
|
EXAM_DETAIL_QUERY,
|
||||||
HOMEWORK_DETAIL_QUERY,
|
HOMEWORK_DETAIL_QUERY,
|
||||||
JOIN_CLASS_MUTATION,
|
JOIN_CLASS_MUTATION,
|
||||||
LEAVE_CLASS_MUTATION,
|
LEAVE_CLASS_MUTATION,
|
||||||
LEARNING_PATH_QUERY,
|
LEARNING_PATH_QUERY,
|
||||||
|
LESSON_PLAN_DETAIL_QUERY,
|
||||||
MARK_ALL_AS_READ_MUTATION,
|
MARK_ALL_AS_READ_MUTATION,
|
||||||
|
MARK_ANNOUNCEMENT_READ_MUTATION,
|
||||||
MARK_AS_READ_MUTATION,
|
MARK_AS_READ_MUTATION,
|
||||||
MY_ATTENDANCE_QUERY,
|
MY_ATTENDANCE_QUERY,
|
||||||
MY_CLASSES_QUERY,
|
MY_CLASSES_QUERY,
|
||||||
|
MY_COURSE_PLANS_QUERY,
|
||||||
|
MY_DIAGNOSTIC_REPORTS_QUERY,
|
||||||
|
MY_ELECTIVE_SELECTIONS_QUERY,
|
||||||
|
MY_ERROR_BOOK_QUERY,
|
||||||
MY_EXAMS_QUERY,
|
MY_EXAMS_QUERY,
|
||||||
MY_GRADES_QUERY,
|
MY_GRADES_QUERY,
|
||||||
MY_HOMEWORK_QUERY,
|
MY_HOMEWORK_QUERY,
|
||||||
|
MY_LEAVE_REQUESTS_QUERY,
|
||||||
|
MY_LESSON_PLANS_QUERY,
|
||||||
|
MY_MASTERY_SUMMARY_QUERY,
|
||||||
MY_NOTIFICATIONS_QUERY,
|
MY_NOTIFICATIONS_QUERY,
|
||||||
|
MY_PRACTICE_SESSIONS_QUERY,
|
||||||
MY_PROFILE_QUERY,
|
MY_PROFILE_QUERY,
|
||||||
|
MY_REPORT_CARD_QUERY,
|
||||||
MY_SCHEDULE_QUERY,
|
MY_SCHEDULE_QUERY,
|
||||||
MY_TREND_QUERY,
|
MY_TREND_QUERY,
|
||||||
MY_WEAKNESS_QUERY,
|
MY_WEAKNESS_QUERY,
|
||||||
@@ -93,12 +146,16 @@ import {
|
|||||||
RECORD_PASTE_EVENT_MUTATION,
|
RECORD_PASTE_EVENT_MUTATION,
|
||||||
REQUEST_EXTENSION_MUTATION,
|
REQUEST_EXTENSION_MUTATION,
|
||||||
SAVE_EXAM_DRAFT_MUTATION,
|
SAVE_EXAM_DRAFT_MUTATION,
|
||||||
|
SELECT_ELECTIVE_COURSE_MUTATION,
|
||||||
SERVER_TIME_QUERY,
|
SERVER_TIME_QUERY,
|
||||||
|
START_PRACTICE_SESSION_QUERY,
|
||||||
STUDENT_DASHBOARD_QUERY,
|
STUDENT_DASHBOARD_QUERY,
|
||||||
STUDENT_GROWTH_QUERY,
|
STUDENT_GROWTH_QUERY,
|
||||||
SUBMIT_EXAM_MUTATION,
|
SUBMIT_EXAM_MUTATION,
|
||||||
SUBMIT_HOMEWORK_MUTATION,
|
SUBMIT_HOMEWORK_MUTATION,
|
||||||
|
SUBMIT_PRACTICE_ANSWER_MUTATION,
|
||||||
TEXTBOOKS_QUERY,
|
TEXTBOOKS_QUERY,
|
||||||
|
UPDATE_ERROR_BOOK_ITEM_MUTATION,
|
||||||
UPDATE_NOTIFICATION_PREFERENCE_MUTATION,
|
UPDATE_NOTIFICATION_PREFERENCE_MUTATION,
|
||||||
UPDATE_PROFILE_MUTATION,
|
UPDATE_PROFILE_MUTATION,
|
||||||
} from "./operations";
|
} 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;
|
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 输入类型
|
// Mutation 输入类型
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -504,6 +917,140 @@ export interface LeaveClassInput {
|
|||||||
classId: string;
|
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 响应数据类型
|
// Mutation 响应数据类型
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -624,7 +1171,22 @@ export type QueryOperationName =
|
|||||||
| "mySchedule"
|
| "mySchedule"
|
||||||
| "studentGrowth"
|
| "studentGrowth"
|
||||||
| "assignmentAnalysis"
|
| "assignmentAnalysis"
|
||||||
| "myProfile";
|
| "myProfile"
|
||||||
|
| "myErrorBook"
|
||||||
|
| "myLeaveRequests"
|
||||||
|
| "myElectiveSelections"
|
||||||
|
| "availableElectiveCourses"
|
||||||
|
| "myPracticeSessions"
|
||||||
|
| "startPracticeSession"
|
||||||
|
| "announcements"
|
||||||
|
| "announcementDetail"
|
||||||
|
| "myLessonPlans"
|
||||||
|
| "lessonPlanDetail"
|
||||||
|
| "myCoursePlans"
|
||||||
|
| "coursePlanDetail"
|
||||||
|
| "myReportCard"
|
||||||
|
| "myDiagnosticReports"
|
||||||
|
| "myMasterySummary";
|
||||||
|
|
||||||
/** 变更操作名 */
|
/** 变更操作名 */
|
||||||
export type MutationOperationName =
|
export type MutationOperationName =
|
||||||
@@ -640,7 +1202,16 @@ export type MutationOperationName =
|
|||||||
| "changePassword"
|
| "changePassword"
|
||||||
| "requestExtension"
|
| "requestExtension"
|
||||||
| "joinClass"
|
| "joinClass"
|
||||||
| "leaveClass";
|
| "leaveClass"
|
||||||
|
| "addErrorBookItem"
|
||||||
|
| "updateErrorBookItem"
|
||||||
|
| "deleteErrorBookItem"
|
||||||
|
| "createLeaveRequest"
|
||||||
|
| "cancelLeaveRequest"
|
||||||
|
| "selectElectiveCourse"
|
||||||
|
| "dropElectiveCourse"
|
||||||
|
| "submitPracticeAnswer"
|
||||||
|
| "markAnnouncementRead";
|
||||||
|
|
||||||
/** 全部操作名 */
|
/** 全部操作名 */
|
||||||
export type OperationName = QueryOperationName | MutationOperationName;
|
export type OperationName = QueryOperationName | MutationOperationName;
|
||||||
@@ -675,6 +1246,21 @@ export interface QueryResponses {
|
|||||||
studentGrowth: ActionState<GrowthDataPoint[]>;
|
studentGrowth: ActionState<GrowthDataPoint[]>;
|
||||||
assignmentAnalysis: ActionState<AssignmentAnalysis>;
|
assignmentAnalysis: ActionState<AssignmentAnalysis>;
|
||||||
myProfile: ActionState<StudentProfile>;
|
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>;
|
requestExtension: ActionState<RequestExtensionResult>;
|
||||||
joinClass: ActionState<JoinClassResult>;
|
joinClass: ActionState<JoinClassResult>;
|
||||||
leaveClass: ActionState<LeaveClassResult>;
|
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 studentGrowthFixture from "./fixtures/studentGrowth.json";
|
||||||
import assignmentAnalysisFixture from "./fixtures/assignmentAnalysis.json";
|
import assignmentAnalysisFixture from "./fixtures/assignmentAnalysis.json";
|
||||||
import myProfileFixture from "./fixtures/myProfile.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 请求/响应类型
|
// GraphQL 请求/响应类型
|
||||||
@@ -275,6 +286,181 @@ function handleQuery(
|
|||||||
case "myProfile":
|
case "myProfile":
|
||||||
return graphqlOk("myProfile", ok(myProfileFixture));
|
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: {
|
default: {
|
||||||
// 未覆盖的查询操作名,返回错误
|
// 未覆盖的查询操作名,返回错误
|
||||||
return graphqlError(
|
return graphqlError(
|
||||||
@@ -433,6 +619,127 @@ function handleMutation(
|
|||||||
return graphqlOk("leaveClass", ok(result));
|
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: {
|
default: {
|
||||||
// 未覆盖的变更操作名,返回错误
|
// 未覆盖的变更操作名,返回错误
|
||||||
return graphqlError(
|
return graphqlError(
|
||||||
@@ -469,6 +776,21 @@ const QUERY_NAMES = new Set<QueryOperationName>([
|
|||||||
"studentGrowth",
|
"studentGrowth",
|
||||||
"assignmentAnalysis",
|
"assignmentAnalysis",
|
||||||
"myProfile",
|
"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,
|
studentGrowth: studentGrowthFixture,
|
||||||
assignmentAnalysis: assignmentAnalysisFixture,
|
assignmentAnalysis: assignmentAnalysisFixture,
|
||||||
myProfile: myProfileFixture,
|
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