From c9e46f9f808c95d4a01647ad6c428b7a0a34ce07 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:03:22 +0800 Subject: [PATCH] feat(school): add grade dashboard and insights filters - Add grade-dashboard components directory for school-wide grade analytics - Add grade-insights-filters component for filtering grade insights - Update grades-view and data-access --- .../grade-distribution-panel.tsx | 115 ++++++++++++++ .../grade-dashboard/grade-exams-panel.tsx | 106 +++++++++++++ .../grade-dashboard/grade-homework-panel.tsx | 140 ++++++++++++++++++ .../grade-dashboard/grade-progress-panel.tsx | 135 +++++++++++++++++ .../components/grade-insights-filters.tsx | 48 ++++++ src/modules/school/components/grades-view.tsx | 130 +++++++++++++++- src/modules/school/data-access.ts | 48 ++++++ 7 files changed, 721 insertions(+), 1 deletion(-) create mode 100644 src/modules/school/components/grade-dashboard/grade-distribution-panel.tsx create mode 100644 src/modules/school/components/grade-dashboard/grade-exams-panel.tsx create mode 100644 src/modules/school/components/grade-dashboard/grade-homework-panel.tsx create mode 100644 src/modules/school/components/grade-dashboard/grade-progress-panel.tsx create mode 100644 src/modules/school/components/grade-insights-filters.tsx diff --git a/src/modules/school/components/grade-dashboard/grade-distribution-panel.tsx b/src/modules/school/components/grade-dashboard/grade-distribution-panel.tsx new file mode 100644 index 0000000..1df65ae --- /dev/null +++ b/src/modules/school/components/grade-dashboard/grade-distribution-panel.tsx @@ -0,0 +1,115 @@ +import type { JSX } from "react" +import { BarChart3 } from "lucide-react" + +import type { GradeDistributionByGradeResult } from "@/modules/grades/types" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Badge } from "@/shared/components/ui/badge" +import { StatCard } from "@/shared/components/ui/stat-card" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table" +import { formatNumber } from "@/shared/lib/utils" +import { useTranslations } from "next-intl" + +interface Props { + data: GradeDistributionByGradeResult +} + +export function GradeDistributionPanel({ data }: Props): JSX.Element { + const t = useTranslations("school") + + const stats = data.stats + const buckets = data.overall.buckets + + const maxCount = Math.max(...buckets.map((b) => b.count), 1) + + return ( +
+
+ + + + +
+ + + + {t("grades.gradeDashboard.distribution.scoreBuckets")} + + +
+ {buckets.map((b) => ( +
+
{b.label}
+
+
+
+
+
+
{b.count}
+
+ ))} +
+ + + + + + {t("grades.gradeDashboard.distribution.byClass")} + {data.byClass.length} + + +
+
+ + + + {t("grades.gradeDashboard.distribution.class")} + {t("grades.gradeDashboard.distribution.students")} + {t("grades.gradeDashboard.distribution.averageScore")} + {t("grades.gradeDashboard.distribution.passRate")} + {t("grades.gradeDashboard.distribution.excellentRate")} + {t("grades.gradeDashboard.distribution.count")} + + + + {data.byClass.map((c) => ( + + {c.className} + {c.stats?.count ?? 0} + {formatNumber(c.stats?.average ?? null)} + {c.stats ? `${formatNumber(c.stats.passRate)}%` : "-"} + {c.stats ? `${formatNumber(c.stats.excellentRate)}%` : "-"} + {c.distribution.totalCount} + + ))} + +
+
+
+
+
+
+ ) +} diff --git a/src/modules/school/components/grade-dashboard/grade-exams-panel.tsx b/src/modules/school/components/grade-dashboard/grade-exams-panel.tsx new file mode 100644 index 0000000..3f93844 --- /dev/null +++ b/src/modules/school/components/grade-dashboard/grade-exams-panel.tsx @@ -0,0 +1,106 @@ +import type { JSX } from "react" +import { FileText, CheckCircle2, Clock } from "lucide-react" +import { useTranslations } from "next-intl" + +import type { GradeExamsResult } from "@/modules/exams/types" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Badge } from "@/shared/components/ui/badge" +import { StatCard } from "@/shared/components/ui/stat-card" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table" +import { formatDate, formatNumber } from "@/shared/lib/utils" + +interface Props { + data: GradeExamsResult +} + +const statusVariant = (status: string): "default" | "secondary" | "outline" => { + if (status === "published") return "default" + if (status === "draft") return "secondary" + return "outline" +} + +export function GradeExamsPanel({ data }: Props): JSX.Element { + const t = useTranslations("school") + + const gradedRate = data.totals.totalSubmissions > 0 + ? Math.round((data.totals.totalGraded / data.totals.totalSubmissions) * 1000) / 10 + : 0 + + return ( +
+
+ + + + +
+ + + + {t("grades.gradeDashboard.exams.examList")} + {data.exams.length} + + + {data.exams.length === 0 ? ( +

{t("grades.gradeDashboard.noData")}

+ ) : ( +
+
+ + + + {t("grades.gradeInsights.assignment")} + {t("grades.gradeDashboard.exams.subject")} + {t("grades.gradeDashboard.exams.status")} + {t("grades.gradeDashboard.exams.scheduledAt")} + {t("grades.gradeDashboard.exams.submissions")} + {t("grades.gradeDashboard.exams.graded")} + {t("grades.gradeDashboard.exams.avgScore")} + + + + {data.exams.map((e) => ( + + {e.title} + {e.subjectName ?? "-"} + + {e.status} + + + {e.scheduledAt ? formatDate(e.scheduledAt) : "-"} + + {e.submissionCount} + {e.gradedCount} + {formatNumber(e.averageScore)} + + ))} + +
+
+
+ )} +
+
+
+ ) +} diff --git a/src/modules/school/components/grade-dashboard/grade-homework-panel.tsx b/src/modules/school/components/grade-dashboard/grade-homework-panel.tsx new file mode 100644 index 0000000..43fb253 --- /dev/null +++ b/src/modules/school/components/grade-dashboard/grade-homework-panel.tsx @@ -0,0 +1,140 @@ +import type { JSX } from "react" +import { ClipboardCheck, CheckCircle2, Clock } from "lucide-react" +import { useTranslations } from "next-intl" + +import type { GradeHomeworkInsights } from "@/modules/classes/types" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Badge } from "@/shared/components/ui/badge" +import { StatCard } from "@/shared/components/ui/stat-card" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table" +import { formatNumber } from "@/shared/lib/utils" + +interface Props { + data: GradeHomeworkInsights +} + +export function GradeHomeworkPanel({ data }: Props): JSX.Element { + const t = useTranslations("school") + + const totalTargeted = data.assignments.reduce((sum, a) => sum + a.targetCount, 0) + const totalSubmitted = data.assignments.reduce((sum, a) => sum + a.submittedCount, 0) + const totalGraded = data.assignments.reduce((sum, a) => sum + a.gradedCount, 0) + const avgSubmissionRate = totalTargeted > 0 + ? Math.round((totalSubmitted / totalTargeted) * 1000) / 10 + : 0 + const gradedRate = totalSubmitted > 0 + ? Math.round((totalGraded / totalSubmitted) * 1000) / 10 + : 0 + + return ( +
+
+ + + + +
+ + + + {t("grades.gradeDashboard.homework.assignmentList")} + {data.assignments.length} + + +
+
+ + + + {t("grades.gradeInsights.assignment")} + {t("grades.gradeInsights.status")} + {t("grades.gradeDashboard.homework.targeted")} + {t("grades.gradeDashboard.homework.submitted")} + {t("grades.gradeDashboard.homework.graded")} + {t("grades.gradeDashboard.homework.completionRate")} + {t("grades.gradeInsights.avg")} + + + + {data.assignments.map((a) => { + const rate = a.targetCount > 0 + ? Math.round((a.submittedCount / a.targetCount) * 1000) / 10 + : 0 + return ( + + {a.title} + + {a.status} + + {a.targetCount} + {a.submittedCount} + {a.gradedCount} + {formatNumber(rate)}% + {formatNumber(a.scoreStats.avg)} + + ) + })} + +
+
+
+
+
+ + + + {t("grades.gradeDashboard.homework.classComparison")} + {data.classes.length} + + +
+
+ + + + {t("grades.gradeInsights.class")} + {t("grades.gradeInsights.students")} + {t("grades.gradeInsights.latestAvgCol")} + {t("grades.gradeInsights.delta")} + {t("grades.gradeInsights.overallAvgCol")} + + + + {data.classes.map((c) => ( + + {c.class.name} + {c.studentCounts.total} + {formatNumber(c.latestAvg)} + {formatNumber(c.deltaAvg)} + {formatNumber(c.overallScores.avg)} + + ))} + +
+
+
+
+
+
+ ) +} diff --git a/src/modules/school/components/grade-dashboard/grade-progress-panel.tsx b/src/modules/school/components/grade-dashboard/grade-progress-panel.tsx new file mode 100644 index 0000000..f235c99 --- /dev/null +++ b/src/modules/school/components/grade-dashboard/grade-progress-panel.tsx @@ -0,0 +1,135 @@ +import type { JSX } from "react" +import { BookOpen, Clock, CheckCircle2, TrendingUp } from "lucide-react" +import { useTranslations } from "next-intl" + +import type { GradeCoursePlanProgressResult } from "@/modules/course-plans/types" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Badge } from "@/shared/components/ui/badge" +import { StatCard } from "@/shared/components/ui/stat-card" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table" +import { formatNumber } from "@/shared/lib/utils" + +interface Props { + data: GradeCoursePlanProgressResult +} + +const statusVariant = (status: string): "default" | "secondary" | "outline" => { + if (status === "active") return "default" + if (status === "completed") return "secondary" + return "outline" +} + +export function GradeProgressPanel({ data }: Props): JSX.Element { + const t = useTranslations("school") + const { overall, items } = data + + return ( +
+
+ + + + +
+ + + + {t("grades.gradeDashboard.progress.overallProgress")} + {formatNumber(overall.progressRate)}% + + +
+
+
+

+ {overall.completedHours} / {overall.totalHours} {t("grades.gradeDashboard.progress.completedHoursCol")} +

+ + + + + + {t("grades.gradeDashboard.progress.progressMatrix")} + {items.length} + + + {items.length === 0 ? ( +

{t("grades.gradeDashboard.noData")}

+ ) : ( +
+
+ + + + {t("grades.gradeDashboard.progress.class")} + {t("grades.gradeDashboard.progress.subject")} + {t("grades.gradeDashboard.progress.teacher")} + {t("grades.gradeDashboard.progress.completedHoursCol")} + {t("grades.gradeDashboard.progress.items")} + {t("grades.gradeDashboard.progress.progress")} + {t("grades.gradeDashboard.progress.status")} + + + + {items.map((it) => ( + + {it.className ?? "-"} + {it.subjectName ?? "-"} + {it.teacherName ?? "-"} + + {it.completedHours}/{it.totalHours} + + + {it.completedItemCount}/{it.itemCount} + + +
+
+
+
+ + {formatNumber(it.progressRate)}% + +
+ + + {it.status} + + + ))} + +
+
+
+ )} +
+
+
+ ) +} diff --git a/src/modules/school/components/grade-insights-filters.tsx b/src/modules/school/components/grade-insights-filters.tsx new file mode 100644 index 0000000..fec3acf --- /dev/null +++ b/src/modules/school/components/grade-insights-filters.tsx @@ -0,0 +1,48 @@ +import type { JSX } from "react" +import { getTranslations } from "next-intl/server" + +import { ChipNav } from "@/shared/components/ui/chip-nav" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Badge } from "@/shared/components/ui/badge" + +interface GradeInsightsFiltersProps { + grades: Array<{ id: string; name: string; schoolName: string }> + currentGradeId: string + buildHref: (gradeId: string) => string +} + +/** + * 年级洞察筛选器:使用 ChipNav 替代原生 form get, + * 点击 chip 即时通过 URL 参数切换,无整页刷新。 + */ +export async function GradeInsightsFilters({ + grades, + currentGradeId, + buildHref, +}: GradeInsightsFiltersProps): Promise { + const t = await getTranslations("school") + + const options = grades.map((g) => ({ + id: g.id, + name: `${g.schoolName} / ${g.name}`, + })) + + return ( + + + {t("grades.gradeInsights.title")} + + {grades.length} + + + + + + + ) +} diff --git a/src/modules/school/components/grades-view.tsx b/src/modules/school/components/grades-view.tsx index 7cd2ac5..91baf48 100644 --- a/src/modules/school/components/grades-view.tsx +++ b/src/modules/school/components/grades-view.tsx @@ -1,13 +1,14 @@ "use client" import { useCallback, useEffect, useMemo, useState } from "react" -import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react" +import { BarChart3, MoreHorizontal, Pencil, Plus, Trash2, Users, GraduationCap, UserCog } from "lucide-react" import { toast } from "sonner" import { useRouter } from "next/navigation" import { parseAsString, useQueryState } from "nuqs" import { useTranslations } from "next-intl" import type { GradeListItem, SchoolListItem, StaffOption } from "../types" +import type { GradeOverviewStats } from "../data-access" import { createGradeAction, deleteGradeAction, updateGradeAction } from "../actions" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" @@ -71,10 +72,12 @@ export function GradesClient({ grades, schools, staff, + gradeStats, }: { grades: GradeListItem[] schools: SchoolListItem[] staff: StaffOption[] + gradeStats: GradeOverviewStats[] }) { const t = useTranslations("school") const router = useRouter() @@ -92,6 +95,13 @@ export function GradesClient({ const [createState, setCreateState] = useState(() => toFormState(null, defaultSchoolId)) const [editState, setEditState] = useState(() => toFormState(null, defaultSchoolId)) + // 年级概览统计映射,用于卡片视图 + const statsMap = useMemo(() => { + const m = new Map() + for (const s of gradeStats) m.set(s.gradeId, s) + return m + }, [gradeStats]) + useEffect(() => { if (!createOpen) return if (createState.schoolId.trim().length > 0) return @@ -329,6 +339,122 @@ export function GradesClient({ return ( <> + {/* 年级概览卡片视图:让管理员一目了然看到各年级规模 */} + {filteredGrades.length > 0 && ( +
+ {filteredGrades.slice(0, 8).map((g) => { + const stats = statsMap.get(g.id) + return ( + + +
+
+
{g.name}
+
{g.school.name}
+
+ + + + + + + router.push(`/admin/school/grades/insights?gradeId=${encodeURIComponent(g.id)}`) + } + > + + {t("grades.gradeOverview.viewInsights")} + + + openEdit(g)}> + + {t("grades.actions.edit")} + + setDeleteItem(g)} + > + + {t("grades.actions.delete")} + + + +
+ + {/* 统计指标 */} +
+
+
+ +
+
+ {stats?.classCount ?? 0} +
+
+ {t("grades.gradeOverview.classCount")} +
+
+
+
+ +
+
+ {stats?.studentCount ?? 0} +
+
+ {t("grades.gradeOverview.studentCount")} +
+
+
+
+ +
+
+ {stats?.teacherCount ?? 0} +
+
+ {t("grades.gradeOverview.teacherCount")} +
+
+
+ + {/* 年级主任/教学主任 */} +
+
+ {t("grades.gradeOverview.gradeHead")} + + {g.gradeHead?.name ?? t("grades.gradeOverview.notSet")} + +
+
+ {t("grades.gradeOverview.teachingHead")} + + {g.teachingHead?.name ?? t("grades.gradeOverview.notSet")} + +
+
+ + {/* 快捷操作 */} + +
+
+ ) + })} +
+ )} +
@@ -427,6 +553,7 @@ export function GradesClient({ className="h-auto border-none shadow-none" /> ) : ( +
@@ -482,6 +609,7 @@ export function GradesClient({ ))}
+
)} diff --git a/src/modules/school/data-access.ts b/src/modules/school/data-access.ts index 5bbe39c..9bf7f8a 100644 --- a/src/modules/school/data-access.ts +++ b/src/modules/school/data-access.ts @@ -798,3 +798,51 @@ export const getOrgTree = cache(async (): Promise => { return [] } }) + +/** + * 年级概览统计:每个年级的班级数、学生数、教师数。 + * 用于年级管理页面的卡片视图,让管理员一目了然看到各年级规模。 + */ +export interface GradeOverviewStats { + gradeId: string + classCount: number + studentCount: number + teacherCount: number +} + +export const getGradeOverviewStats = cache(async (): Promise => { + try { + // 动态导入 classes 模块避免循环依赖 + const { getAdminClasses } = await import("@/modules/classes/data-access") + const allClasses = await getAdminClasses() + + // 按年级分组统计班级数和学生数 + const statsByGrade = new Map }>() + + for (const cls of allClasses) { + const gradeId = cls.gradeId + if (typeof gradeId !== "string" || gradeId.length === 0) continue + + const existing = statsByGrade.get(gradeId) ?? { classCount: 0, studentCount: 0, teacherIds: new Set() } + existing.classCount += 1 + existing.studentCount += cls.studentCount ?? 0 + // 班主任算一个教师 + if (cls.teacher?.id) existing.teacherIds.add(cls.teacher.id) + // 学科教师也算 + for (const st of cls.subjectTeachers ?? []) { + if (st.teacher?.id) existing.teacherIds.add(st.teacher.id) + } + statsByGrade.set(gradeId, existing) + } + + return Array.from(statsByGrade.entries()).map(([gradeId, s]) => ({ + gradeId, + classCount: s.classCount, + studentCount: s.studentCount, + teacherCount: s.teacherIds.size, + })) + } catch (error) { + console.error("getGradeOverviewStats failed:", error) + return [] + } +})