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 []
+ }
+})