diff --git a/apps/student-portal/src/app/announcements/[id]/page.tsx b/apps/student-portal/src/app/announcements/[id]/page.tsx new file mode 100644 index 0000000..955a5e5 --- /dev/null +++ b/apps/student-portal/src/app/announcements/[id]/page.tsx @@ -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 = { + 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 ( +
+

+ 加载中… +

+
+ ); + } + + if (error) { + return ( +
+
+

+ 公告加载失败:{error.message} +

+
+
+ ); + } + + if (!data) { + return ( +
+
+

+ 未找到该公告 +

+ + 返回公告列表 + +
+
+ ); + } + + return ( +
+ + ← 返回公告列表 + + +
+
+
+ {TYPE_LABELS[data.type]} + {data.isPinned && 置顶} +
+

+ {data.title} +

+
+ {data.authorName ?? "—"} + {data.publishedAt && ( + {new Date(data.publishedAt).toLocaleString("zh-CN")} + )} +
+
+ +
+ +
+

+ {data.content} +

+
+
+
+ ); +} diff --git a/apps/student-portal/src/app/announcements/page.tsx b/apps/student-portal/src/app/announcements/page.tsx new file mode 100644 index 0000000..62fae36 --- /dev/null +++ b/apps/student-portal/src/app/announcements/page.tsx @@ -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 = { + school: "校级", + grade: "年级", + class: "班级", +}; + +export default function AnnouncementsPage(): JSX.Element { + const { data, fetching, error, reexecute } = useAnnouncements(); + const [typeFilter, setTypeFilter] = useState("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 ( +
+
+

+ 信息公告 +

+

+ 公告 +

+

+ 查看学校、年级及班级发布的通知与公告。 +

+
+ +
+ +
+ {(["all", "school", "grade", "class"] as const).map((t) => ( + + ))} +
+ + {fetching ? ( + + ) : error ? ( + reexecute()} /> + ) : filtered.length === 0 ? ( + + ) : ( +
    + {filtered.map((a) => ( +
  • + +
    +
    +
    + {TYPE_LABELS[a.type]} + {a.isPinned && ( + 置顶 + )} + {!a.isReadByCurrentUser && ( + + )} +
    + + {a.publishedAt ? new Date(a.publishedAt).toLocaleDateString("zh-CN") : "—"} + +
    +

    + {a.title} +

    +

    + {a.content.slice(0, 80)}... +

    +

    + {a.authorName ?? "—"} +

    +
    + +
  • + ))} +
+ )} +
+ ); +} + +function LoadingSkeleton(): JSX.Element { + return ( +
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} + 正在加载公告... +
+ ); +} + +function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }): JSX.Element { + return ( +
+

+ 公告加载失败 +

+

+ {message} +

+ +
+ ); +} + +function EmptyState(): JSX.Element { + return ( +
+ +

+ 暂无公告 +

+
+ ); +} diff --git a/apps/student-portal/src/app/course-plans/[id]/page.tsx b/apps/student-portal/src/app/course-plans/[id]/page.tsx new file mode 100644 index 0000000..788698f --- /dev/null +++ b/apps/student-portal/src/app/course-plans/[id]/page.tsx @@ -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 = { + planning: "规划中", + active: "进行中", + completed: "已完成", + paused: "已暂停", +}; + +const SEMESTER_LABELS: Record = { + "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 ( +
+

+ 加载中… +

+
+ ); + } + + if (error) { + return ( +
+
+

+ 课程计划加载失败:{error.message} +

+
+
+ ); + } + + if (!data) { + return ( +
+
+

+ 未找到该课程计划 +

+ + 返回课程计划 + +
+
+ ); + } + + const progress = data.totalHours > 0 ? (data.completedHours / data.totalHours) * 100 : 0; + + return ( +
+ + ← 返回课程计划 + + +
+
+ {data.subjectName && {data.subjectName}} + + {data.className ?? "—"} · {SEMESTER_LABELS[data.semester]} + +
+

+ {data.subjectName ?? "课程计划"} · {data.teacherName ?? "—"} +

+
+ +
+ +
+
+
+

+ 总课时 +

+

+ {data.totalHours} +

+
+
+

+ 已完成 +

+

+ {data.completedHours} +

+
+
+

+ 周课时 +

+

+ {data.weeklyHours} +

+
+
+

+ 状态 +

+

+ {STATUS_LABELS[data.status]} +

+
+
+
+
+
+
+ + {Math.round(progress)}% + +
+
+ + {data.syllabus && ( +
+

+ 教学大纲 +

+
+

+ {data.syllabus} +

+
+
+ )} + + {data.objectives && ( +
+

+ 教学目标 +

+
+

+ {data.objectives} +

+
+
+ )} + +
+

+ 周次安排 +

+
    + {data.items.map((item) => ( +
  • +
    +
    +
    +
    + + 第 {item.week} 周 + + + {item.hours} 课时 + + {item.isCompleted ? ( + 已完成 + ) : ( + 未完成 + )} +
    +

    + {item.topic} +

    + {item.content && ( +

    + {item.content} +

    + )} + {item.textbookChapter && ( +

    + 教材:{item.textbookChapter} +

    + )} + {item.notes && ( +

    + 备注:{item.notes} +

    + )} +
    +
    +
    +
  • + ))} +
+
+
+ ); +} diff --git a/apps/student-portal/src/app/course-plans/page.tsx b/apps/student-portal/src/app/course-plans/page.tsx new file mode 100644 index 0000000..58e059e --- /dev/null +++ b/apps/student-portal/src/app/course-plans/page.tsx @@ -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 = { + planning: "规划中", + active: "进行中", + completed: "已完成", + paused: "已暂停", +}; + +const STATUS_BADGE: Record = { + planning: "badge-warning", + active: "badge-success", + completed: "badge-info", + paused: "badge-warning", +}; + +const SEMESTER_LABELS: Record = { + "1": "上学期", + "2": "下学期", +}; + +export default function CoursePlansPage(): JSX.Element { + const { data, fetching, error, reexecute } = useMyCoursePlans(); + const plans = data ?? []; + + return ( +
+
+

+ 教学进度 +

+

+ 课程计划 +

+

+ 查看各学科本学期教学计划与完成进度。 +

+
+ +
+ + {fetching ? ( + + ) : error ? ( + reexecute()} /> + ) : plans.length === 0 ? ( + + ) : ( +
    + {plans.map((p) => { + const progress = p.totalHours > 0 ? (p.completedHours / p.totalHours) * 100 : 0; + return ( +
  • + +
    +
    +
    + {p.subjectName && {p.subjectName}} + + {STATUS_LABELS[p.status]} + + + {SEMESTER_LABELS[p.semester]} + +
    + + {p.completedHours}/{p.totalHours} 课时 + +
    +

    + {p.className ?? "—"} · {p.teacherName ?? "—"} +

    + {p.syllabus && ( +

    + {p.syllabus} +

    + )} +
    +
    +
    +
    + + {Math.round(progress)}% + +
    +
    + +
  • + ); + })} +
+ )} +
+ ); +} + +function LoadingSkeleton(): JSX.Element { + return ( +
+ {[0, 1, 2].map((i) => ( +
+ ))} + 正在加载课程计划... +
+ ); +} + +function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }): JSX.Element { + return ( +
+

+ 课程计划加载失败 +

+

+ {message} +

+ +
+ ); +} + +function EmptyState(): JSX.Element { + return ( +
+ +

+ 暂无课程计划 +

+
+ ); +} diff --git a/apps/student-portal/src/app/dashboard/weakness/page.tsx b/apps/student-portal/src/app/dashboard/weakness/page.tsx index 360cbfc..66cce7f 100644 --- a/apps/student-portal/src/app/dashboard/weakness/page.tsx +++ b/apps/student-portal/src/app/dashboard/weakness/page.tsx @@ -3,9 +3,11 @@ /** * 学情诊断页(ai14,P4,路由 /dashboard/weakness) * - * - 消费 useMyWeakness 查询 - * - 雷达图(recharts)按科目展示掌握度 - * - 薄弱知识点卡片:科目、知识点、掌握度、建议 + * - 消费 useMyWeakness 查询:科目掌握度雷达图 + 薄弱知识点卡片 + * - 消费 useMyMasterySummary 查询:知识点掌握度雷达图 + 置信度等级 + * - 消费 useMyDiagnosticReports 查询:诊断报告列表(优势/弱项/建议) + * + * 三态覆盖:加载 / 错误 / 空 */ import { useMemo } from "react"; @@ -18,27 +20,54 @@ import { ResponsiveContainer, Tooltip, } from "recharts"; -import { useMyWeakness } from "@/lib/graphql/hooks"; -import type { RecommendedAction, WeaknessPoint } from "@/lib/graphql/types"; +import { + useMyDiagnosticReports, + useMyMasterySummary, + useMyWeakness, +} from "@/lib/graphql/hooks"; +import type { + ConfidenceLevel, + DiagnosticReport, + RecommendedAction, + WeaknessPoint, +} from "@/lib/graphql/types"; -interface RadarDatum { +interface SubjectRadarDatum { subject: string; mastery: number; fullMark: number; } +interface KnowledgeRadarDatum { + knowledge: string; + mastery: number; + fullMark: number; +} + const RECOMMENDED_ACTION_LABEL: Record = { review: "建议复习", practice: "建议练习", mastered: "已掌握", }; +const CONFIDENCE_LEVEL_LABEL: Record = { + low: "低置信度", + medium: "中置信度", + high: "高置信度", +}; + +const CONFIDENCE_LEVEL_COLOR: Record = { + low: "var(--color-danger)", + medium: "var(--color-warning)", + high: "var(--color-success)", +}; + function masteryPercent(mastery: number): number { return Math.round(Math.max(0, Math.min(1, mastery)) * 100); } /** 按科目聚合平均掌握度 */ -function aggregateBySubject(items: WeaknessPoint[]): RadarDatum[] { +function aggregateBySubject(items: WeaknessPoint[]): SubjectRadarDatum[] { const map = new Map(); for (const item of items) { const name = item.subject.name; @@ -54,10 +83,49 @@ function aggregateBySubject(items: WeaknessPoint[]): RadarDatum[] { })); } +/** 排序诊断报告(按 createdAt 降序) */ +function sortReportsByDateDesc(reports: DiagnosticReport[]): DiagnosticReport[] { + return [...reports].sort((a, b) => { + const ta = new Date(a.createdAt).getTime(); + const tb = new Date(b.createdAt).getTime(); + return tb - ta; + }); +} + export default function WeaknessPage(): React.ReactNode { - const { data, fetching, error, reexecute } = useMyWeakness(); - const items = data ?? []; + const { + data: weaknessData, + fetching: weaknessFetching, + error: weaknessError, + reexecute: reexecuteWeakness, + } = useMyWeakness(); + const { + data: masteryData, + fetching: masteryFetching, + error: masteryError, + reexecute: reexecuteMastery, + } = useMyMasterySummary(); + const { + data: reportsData, + fetching: reportsFetching, + error: reportsError, + reexecute: reexecuteReports, + } = useMyDiagnosticReports(); + + const items = weaknessData ?? []; const radarData = useMemo(() => aggregateBySubject(items), [items]); + const masteryRadarData = useMemo(() => { + 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 (
学情诊断 -

各科目知识点掌握度雷达图

+

+ 各科目与知识点掌握度雷达图 + 诊断报告 +

- {fetching ? ( -

- 加载中… + {/* 综合掌握度概览(来自 masterySummary) */} + {masteryFetching ? ( +

+ 加载掌握度摘要…

- ) : error ? ( + ) : masteryError ? (
-

{error.message || "加载失败"}

+

+ {masteryError.message || "掌握度摘要加载失败"} +

+
+ ) : masteryData ? ( +
+
+
+

+ 综合掌握度 +

+

+ 覆盖 {masteryData.totalKnowledgePoints} 个知识点 +

+
+
+
+

+ {masteryData.averageMastery} + % +

+

平均掌握度

+
+ + {CONFIDENCE_LEVEL_LABEL[masteryData.confidenceLevel]} + +
+
+
+ ) : null} + + {/* 知识点掌握度雷达图(来自 masterySummary) */} + {masteryData && masteryRadarData.length > 0 && ( +
+

+ 知识点掌握度 +

+
+ + + + + + + + + +
+
+ )} + + {/* 科目掌握度雷达图(来自 weakness 聚合) */} + {weaknessFetching ? ( +

+ 加载中… +

+ ) : weaknessError ? ( +
+

+ {weaknessError.message || "加载失败"} +

+
) : items.length === 0 ? ( -

+

暂无薄弱知识点数据,继续保持!

) : ( <> - {/* 雷达图 */}
{/* 薄弱知识点卡片 */} -
+

薄弱知识点

    {items.map((item: WeaknessPoint) => { @@ -177,6 +347,146 @@ export default function WeaknessPage(): React.ReactNode {
)} + + {/* 诊断报告列表(来自 diagnosticReports) */} +
+

诊断报告

+ {reportsFetching ? ( +

+ 加载诊断报告… +

+ ) : reportsError ? ( +
+

+ {reportsError.message || "诊断报告加载失败"} +

+ +
+ ) : sortedReports.length === 0 ? ( +

+ 暂无诊断报告。 +

+ ) : ( +
    + {sortedReports.map((report) => ( +
  • + +
  • + ))} +
+ )} +
); } + +/** 诊断报告卡片 */ +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 ( +
+
+
+ {report.period ?? "—"} + {report.overallScore !== null && ( + + 综合分 {report.overallScore} + + )} +
+ {dateLabel} +
+ + {report.summary && ( +

{report.summary}

+ )} + + {report.strengths && report.strengths.length > 0 && ( +
+

优势

+
    + {report.strengths.map((s, i) => ( +
  • + + {s} +
  • + ))} +
+
+ )} + + {report.weaknesses && report.weaknesses.length > 0 && ( +
+

弱项

+
    + {report.weaknesses.map((w, i) => ( +
  • + + {w} +
  • + ))} +
+
+ )} + + {report.recommendations && report.recommendations.length > 0 && ( +
+

建议

+
    + {report.recommendations.map((r, i) => ( +
  • + + {r} +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/apps/student-portal/src/app/elective/[id]/page.tsx b/apps/student-portal/src/app/elective/[id]/page.tsx new file mode 100644 index 0000000..e4a2d48 --- /dev/null +++ b/apps/student-portal/src/app/elective/[id]/page.tsx @@ -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 = { + 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 ( +
+

+ 加载中… +

+
+ ); + } + + if (error) { + return ( +
+
+

+ 课程加载失败:{error.message} +

+ +
+
+ ); + } + + if (!course) { + return ( +
+
+

+ 未找到该课程 +

+ + 返回选课中心 + +
+
+ ); + } + + const fillRate = course.capacity > 0 ? course.enrolledCount / course.capacity : 0; + const isFull = course.enrolledCount >= course.capacity; + + return ( +
+ + ← 返回选课中心 + + +
+
+ {course.subjectName ?? "—"} + + {COURSE_STATUS_LABELS[course.status]} + + + {course.selectionMode === "fcfs" ? "先到先得" : "抽签"} + +
+

+ {course.name} +

+
+ +
+ + {course.description && ( +
+

+ 课程简介 +

+

+ {course.description} +

+
+ )} + +
+
+
+
+ 授课教师 +
+
{course.teacherName ?? "—"}
+
+
+
+ 上课教室 +
+
{course.classroom ?? "—"}
+
+
+
+ 上课时间 +
+
{course.schedule ?? "—"}
+
+
+
+ 学分 +
+
{course.credit}
+
+
+
+ 课程周期 +
+
+ {course.startDate ?? "—"} 至 {course.endDate ?? "—"} +
+
+
+
+ 年级 +
+
{course.gradeName ?? "—"}
+
+
+
+ +
+
+
+

+ 容量 +

+

+ {course.enrolledCount}/{course.capacity} +

+
+
+
+
+
+
+ + {course.selectionStartAt && course.selectionEndAt && ( +
+

+ 选课时间 +

+

+ {new Date(course.selectionStartAt).toLocaleString("zh-CN")} 至{" "} + {new Date(course.selectionEndAt).toLocaleString("zh-CN")} +

+ {course.dropDeadline && ( +

+ 退选截止:{new Date(course.dropDeadline).toLocaleString("zh-CN")} +

+ )} +
+ )} + +
+ {course.status === "open" && !isEnrolled && ( + + )} + {isEnrolled && ( + + )} +
+
+ ); +} diff --git a/apps/student-portal/src/app/elective/page.tsx b/apps/student-portal/src/app/elective/page.tsx new file mode 100644 index 0000000..19ab9e3 --- /dev/null +++ b/apps/student-portal/src/app/elective/page.tsx @@ -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 = { + draft: "草稿", + open: "开放选课", + closed: "已截止", + cancelled: "已取消", +}; + +const SELECTION_STATUS_LABELS: Record = { + 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 ( +
+
+

+ 选修课程 +

+

+ 选课中心 +

+

+ 浏览可选课程并在线选课,支持先到先得与抽签两种模式。 +

+
+ +
+ + {mySelections.length > 0 && ( +
+

+ 我的选课 +

+
    + {mySelections.map((sel) => ( +
  • +
    +
    +
    + + {SELECTION_STATUS_LABELS[sel.status]} + + + 学分 {sel.course.credit} + +
    + + {sel.courseName} + +

    + {sel.course.teacherName ?? "—"} · {sel.course.schedule ?? "—"} +

    +
    + {sel.status === "enrolled" && ( + + )} +
    +
  • + ))} +
+
+ )} + +
+

+ 可选课程 +

+ {fetching ? ( + + ) : error ? ( + reexecute()} /> + ) : availableCourses.length === 0 ? ( + + ) : ( +
    + {availableCourses.map((course) => ( + s.courseId === course.id && (s.status === "enrolled" || s.status === "selected"), + )} + onSelect={() => handleSelect(course.id)} + /> + ))} +
+ )} +
+
+ ); +} + +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 ( +
  • +
    +
    +
    + {course.subjectName ?? "—"} + + {COURSE_STATUS_LABELS[course.status]} + +
    + + {course.selectionMode === "fcfs" ? "先到先得" : "抽签"} + +
    + + {course.name} + + {course.description && ( +

    + {course.description} +

    + )} +
    + 教师:{course.teacherName ?? "—"} + 教室:{course.classroom ?? "—"} + 时间:{course.schedule ?? "—"} + 学分:{course.credit} +
    +
    +
    +
    +
    + + {course.enrolledCount}/{course.capacity} + + {course.status === "open" && !enrolled && ( + + )} + {enrolled && ( + + 已选 + + )} +
    +
    +
  • + ); +} + +function LoadingSkeleton(): JSX.Element { + return ( +
    + {[0, 1, 2].map((i) => ( +
    + ))} + 正在加载可选课程... +
    + ); +} + +function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }): JSX.Element { + return ( +
    +

    + 课程列表加载失败 +

    +

    + {message} +

    + +
    + ); +} + +function EmptyState(): JSX.Element { + return ( +
    + +

    + 暂无可选课程 +

    +

    + 选课开放期间将显示可用课程。 +

    +
    + ); +} diff --git a/apps/student-portal/src/app/error-book/page.tsx b/apps/student-portal/src/app/error-book/page.tsx new file mode 100644 index 0000000..b47c8e2 --- /dev/null +++ b/apps/student-portal/src/app/error-book/page.tsx @@ -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 = { + new: "待学习", + learning: "学习中", + mastered: "已掌握", + archived: "已归档", +}; + +const STATUS_BADGE: Record = { + 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("all"); + const [statusFilter, setStatusFilter] = useState("all"); + const [reviewingId, setReviewingId] = useState(null); + + const items = data?.items ?? []; + const stats = data?.stats; + + const subjectOptions = useMemo(() => { + const set = new Set(); + 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 ( +
    +
    +

    + 错题归纳 +

    +

    + 错题本 +

    +

    + 基于 SM2 间隔重复算法安排复习,标记复习自评以优化记忆曲线。 +

    +
    + +
    + + {fetching ? ( + + ) : error ? ( + reexecute()} /> + ) : items.length === 0 ? ( + + ) : ( + <> + {stats && } + +
    + +
    + +
      + {filtered.map((item) => ( + setReviewingId(item.id)} + /> + ))} +
    + + )} + + {reviewingItem && ( + setReviewingId(null)} + /> + )} +
    + ); +} + +function StatsOverview({ stats }: { stats: NonNullable["data"]>["stats"] }): JSX.Element { + const rate = Math.round(stats.masteredRate * 100); + return ( +
    +
    + + + + +
    +
    + 待复习:{stats.dueReviewCount} 题 + 掌握率:{rate}% +
    +
    + ); +} + +function StatBox({ label, value }: { label: string; value: number }): JSX.Element { + return ( +
    +

    + {label} +

    +

    + {value} +

    +
    + ); +} + +function Select({ + label, + value, + onChange, + options, +}: { + label: string; + value: string; + onChange: (v: string) => void; + options: Array<{ value: string; label: string }>; +}): JSX.Element { + return ( + + ); +} + +function ErrorCard({ item, onReview }: { item: ErrorBookItem; onReview: () => void }): JSX.Element { + const masteryPct = Math.round(item.masteryLevel * 100); + return ( +
  • +
    +
    +
    + {item.subject.name} + + {STATUS_LABELS[item.status]} + +
    + + 掌握度 {masteryPct}% + +
    +

    + {item.questionContent} +

    +

    + 知识点:{item.knowledgePointName} · 已复习 {item.reviewCount} 次 +

    + {item.note && ( +

    + 笔记:{item.note} +

    + )} + {item.nextReviewAt && ( +

    + 下次复习:{new Date(item.nextReviewAt).toLocaleDateString("zh-CN")} +

    + )} +
    + +
    +
    +
  • + ); +} + +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 ( +
    +
    e.stopPropagation()} + > +

    + 复习自评 +

    +

    + {item.questionContent} +

    +
    + {REVIEW_OPTIONS.map((opt) => ( + + ))} +
    + +
    +
    + ); +} + +function LoadingSkeleton(): JSX.Element { + return ( +
    +
    + {[0, 1, 2, 3].map((i) => ( +
    + ))} +
    +
    + {[0, 1, 2].map((i) => ( +
    + ))} +
    + 正在加载错题本... +
    + ); +} + +function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }): JSX.Element { + return ( +
    +

    + 错题本加载失败 +

    +

    + {message} +

    + +
    + ); +} + +function EmptyState(): JSX.Element { + return ( +
    + +

    + 暂无错题记录 +

    +

    + 做错的题目会自动收录到这里,方便针对性复习。 +

    +
    + ); +} diff --git a/apps/student-portal/src/app/leave/page.tsx b/apps/student-portal/src/app/leave/page.tsx new file mode 100644 index 0000000..c94a76a --- /dev/null +++ b/apps/student-portal/src/app/leave/page.tsx @@ -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; + +const LEAVE_TYPE_LABELS: Record = { + sick: "病假", + personal: "事假", + family: "家事假", + other: "其他", +}; + +const LEAVE_STATUS_LABELS: Record = { + pending: "待审批", + approved: "已批准", + rejected: "已驳回", + cancelled: "已撤销", +}; + +const LEAVE_STATUS_BADGE: Record = { + 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 ( +
    +
    +

    + 考勤事务 +

    +

    + 在线请假 +

    +

    + 提交请假申请,审批通过后自动同步考勤记录。 +

    +
    + +
    + +
    + +
    + + {showForm && ( + { + setShowForm(false); + reexecute(); + }} + /> + )} + + {fetching ? ( + + ) : error ? ( + reexecute()} /> + ) : requests.length === 0 ? ( + + ) : ( +
      + {requests.map((req) => ( + reexecute()} /> + ))} +
    + )} +
    + ); +} + +function LeaveForm({ onSuccess }: { onSuccess: () => void }): JSX.Element { + const { execute, fetching, errors } = useCreateLeaveRequest(); + const { + register, + handleSubmit, + reset, + formState: { errors: formErrors }, + } = useForm({ + 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 ( +
    +
    + + +
    + +
    +
    + + + {formErrors.startDate && ( +

    + {formErrors.startDate.message} +

    + )} +
    +
    + + + {formErrors.endDate && ( +

    + {formErrors.endDate.message} +

    + )} +
    +
    + +
    + +