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
This commit is contained in:
SpecialX
2026-06-24 12:03:22 +08:00
parent f0f713ff33
commit c9e46f9f80
7 changed files with 721 additions and 1 deletions

View File

@@ -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 (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-4">
<StatCard
title={t("grades.gradeDashboard.distribution.totalRecords")}
value={data.overall.totalCount}
icon={BarChart3}
valueClassName="tabular-nums"
/>
<StatCard
title={t("grades.gradeDashboard.distribution.averageScore")}
value={stats ? formatNumber(stats.average) : "-"}
icon={BarChart3}
valueClassName="tabular-nums"
/>
<StatCard
title={t("grades.gradeDashboard.distribution.passRate")}
value={stats ? `${formatNumber(stats.passRate)}%` : "-"}
icon={BarChart3}
valueClassName="tabular-nums"
/>
<StatCard
title={t("grades.gradeDashboard.distribution.excellentRate")}
value={stats ? `${formatNumber(stats.excellentRate)}%` : "-"}
icon={BarChart3}
valueClassName="tabular-nums"
/>
</div>
<Card className="shadow-none">
<CardHeader>
<CardTitle className="text-base">{t("grades.gradeDashboard.distribution.scoreBuckets")}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{buckets.map((b) => (
<div key={b.label} className="flex items-center gap-3">
<div className="w-16 text-sm text-muted-foreground">{b.label}</div>
<div className="flex-1">
<div className="h-7 rounded-md bg-muted overflow-hidden">
<div
className="h-full bg-primary transition-all"
style={{ width: `${(b.count / maxCount) * 100}%` }}
/>
</div>
</div>
<div className="w-12 text-right text-sm font-medium tabular-nums">{b.count}</div>
</div>
))}
</div>
</CardContent>
</Card>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("grades.gradeDashboard.distribution.byClass")}</CardTitle>
<Badge variant="secondary" className="tabular-nums">{data.byClass.length}</Badge>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead>{t("grades.gradeDashboard.distribution.class")}</TableHead>
<TableHead className="text-right">{t("grades.gradeDashboard.distribution.students")}</TableHead>
<TableHead className="text-right">{t("grades.gradeDashboard.distribution.averageScore")}</TableHead>
<TableHead className="text-right">{t("grades.gradeDashboard.distribution.passRate")}</TableHead>
<TableHead className="text-right">{t("grades.gradeDashboard.distribution.excellentRate")}</TableHead>
<TableHead className="text-right">{t("grades.gradeDashboard.distribution.count")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.byClass.map((c) => (
<TableRow key={c.classId}>
<TableCell className="font-medium">{c.className}</TableCell>
<TableCell className="text-right tabular-nums">{c.stats?.count ?? 0}</TableCell>
<TableCell className="text-right tabular-nums">{formatNumber(c.stats?.average ?? null)}</TableCell>
<TableCell className="text-right tabular-nums">{c.stats ? `${formatNumber(c.stats.passRate)}%` : "-"}</TableCell>
<TableCell className="text-right tabular-nums">{c.stats ? `${formatNumber(c.stats.excellentRate)}%` : "-"}</TableCell>
<TableCell className="text-right tabular-nums">{c.distribution.totalCount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -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 (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-4">
<StatCard
title={t("grades.gradeDashboard.exams.totalExams")}
value={data.totals.examCount}
icon={FileText}
valueClassName="tabular-nums"
/>
<StatCard
title={t("grades.gradeDashboard.exams.published")}
value={data.totals.publishedCount}
icon={CheckCircle2}
valueClassName="tabular-nums"
/>
<StatCard
title={t("grades.gradeDashboard.exams.totalSubmissions")}
value={data.totals.totalSubmissions}
icon={Clock}
valueClassName="tabular-nums"
/>
<StatCard
title={t("grades.gradeDashboard.exams.gradedRate")}
value={`${formatNumber(gradedRate)}%`}
icon={CheckCircle2}
valueClassName="tabular-nums"
/>
</div>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("grades.gradeDashboard.exams.examList")}</CardTitle>
<Badge variant="secondary" className="tabular-nums">{data.exams.length}</Badge>
</CardHeader>
<CardContent>
{data.exams.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">{t("grades.gradeDashboard.noData")}</p>
) : (
<div className="rounded-md border">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead>{t("grades.gradeInsights.assignment")}</TableHead>
<TableHead>{t("grades.gradeDashboard.exams.subject")}</TableHead>
<TableHead>{t("grades.gradeDashboard.exams.status")}</TableHead>
<TableHead>{t("grades.gradeDashboard.exams.scheduledAt")}</TableHead>
<TableHead className="text-right">{t("grades.gradeDashboard.exams.submissions")}</TableHead>
<TableHead className="text-right">{t("grades.gradeDashboard.exams.graded")}</TableHead>
<TableHead className="text-right">{t("grades.gradeDashboard.exams.avgScore")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.exams.map((e) => (
<TableRow key={e.id}>
<TableCell className="font-medium">{e.title}</TableCell>
<TableCell className="text-muted-foreground">{e.subjectName ?? "-"}</TableCell>
<TableCell>
<Badge variant={statusVariant(e.status)} className="capitalize">{e.status}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{e.scheduledAt ? formatDate(e.scheduledAt) : "-"}
</TableCell>
<TableCell className="text-right tabular-nums">{e.submissionCount}</TableCell>
<TableCell className="text-right tabular-nums">{e.gradedCount}</TableCell>
<TableCell className="text-right tabular-nums">{formatNumber(e.averageScore)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -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 (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-4">
<StatCard
title={t("grades.gradeDashboard.homework.totalAssignments")}
value={data.assignments.length}
icon={ClipboardCheck}
valueClassName="tabular-nums"
/>
<StatCard
title={t("grades.gradeDashboard.homework.totalSubmissions")}
value={totalSubmitted}
icon={Clock}
valueClassName="tabular-nums"
/>
<StatCard
title={t("grades.gradeDashboard.homework.avgSubmissionRate")}
value={`${formatNumber(avgSubmissionRate)}%`}
icon={ClipboardCheck}
valueClassName="tabular-nums"
/>
<StatCard
title={t("grades.gradeDashboard.homework.gradedRate")}
value={`${formatNumber(gradedRate)}%`}
icon={CheckCircle2}
valueClassName="tabular-nums"
/>
</div>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("grades.gradeDashboard.homework.assignmentList")}</CardTitle>
<Badge variant="secondary" className="tabular-nums">{data.assignments.length}</Badge>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead>{t("grades.gradeInsights.assignment")}</TableHead>
<TableHead>{t("grades.gradeInsights.status")}</TableHead>
<TableHead className="text-right">{t("grades.gradeDashboard.homework.targeted")}</TableHead>
<TableHead className="text-right">{t("grades.gradeDashboard.homework.submitted")}</TableHead>
<TableHead className="text-right">{t("grades.gradeDashboard.homework.graded")}</TableHead>
<TableHead className="text-right">{t("grades.gradeDashboard.homework.completionRate")}</TableHead>
<TableHead className="text-right">{t("grades.gradeInsights.avg")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.assignments.map((a) => {
const rate = a.targetCount > 0
? Math.round((a.submittedCount / a.targetCount) * 1000) / 10
: 0
return (
<TableRow key={a.assignmentId}>
<TableCell className="font-medium">{a.title}</TableCell>
<TableCell>
<Badge variant="secondary" className="capitalize">{a.status}</Badge>
</TableCell>
<TableCell className="text-right tabular-nums">{a.targetCount}</TableCell>
<TableCell className="text-right tabular-nums">{a.submittedCount}</TableCell>
<TableCell className="text-right tabular-nums">{a.gradedCount}</TableCell>
<TableCell className="text-right tabular-nums">{formatNumber(rate)}%</TableCell>
<TableCell className="text-right tabular-nums">{formatNumber(a.scoreStats.avg)}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("grades.gradeDashboard.homework.classComparison")}</CardTitle>
<Badge variant="secondary" className="tabular-nums">{data.classes.length}</Badge>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead>{t("grades.gradeInsights.class")}</TableHead>
<TableHead className="text-right">{t("grades.gradeInsights.students")}</TableHead>
<TableHead className="text-right">{t("grades.gradeInsights.latestAvgCol")}</TableHead>
<TableHead className="text-right">{t("grades.gradeInsights.delta")}</TableHead>
<TableHead className="text-right">{t("grades.gradeInsights.overallAvgCol")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.classes.map((c) => (
<TableRow key={c.class.id}>
<TableCell className="font-medium">{c.class.name}</TableCell>
<TableCell className="text-right tabular-nums">{c.studentCounts.total}</TableCell>
<TableCell className="text-right tabular-nums">{formatNumber(c.latestAvg)}</TableCell>
<TableCell className="text-right tabular-nums">{formatNumber(c.deltaAvg)}</TableCell>
<TableCell className="text-right tabular-nums">{formatNumber(c.overallScores.avg)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -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 (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-4">
<StatCard
title={t("grades.gradeDashboard.progress.totalPlans")}
value={overall.totalPlans}
icon={BookOpen}
valueClassName="tabular-nums"
/>
<StatCard
title={t("grades.gradeDashboard.progress.completedHours")}
value={`${overall.completedHours}/${overall.totalHours}`}
icon={Clock}
valueClassName="tabular-nums"
/>
<StatCard
title={t("grades.gradeDashboard.progress.activePlans")}
value={overall.activePlans}
icon={TrendingUp}
valueClassName="tabular-nums"
/>
<StatCard
title={t("grades.gradeDashboard.progress.completedPlans")}
value={overall.completedPlans}
icon={CheckCircle2}
valueClassName="tabular-nums"
/>
</div>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("grades.gradeDashboard.progress.overallProgress")}</CardTitle>
<Badge variant="secondary" className="tabular-nums">{formatNumber(overall.progressRate)}%</Badge>
</CardHeader>
<CardContent>
<div className="h-4 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-primary transition-all"
style={{ width: `${overall.progressRate}%` }}
/>
</div>
<p className="mt-2 text-sm text-muted-foreground">
{overall.completedHours} / {overall.totalHours} {t("grades.gradeDashboard.progress.completedHoursCol")}
</p>
</CardContent>
</Card>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("grades.gradeDashboard.progress.progressMatrix")}</CardTitle>
<Badge variant="secondary" className="tabular-nums">{items.length}</Badge>
</CardHeader>
<CardContent>
{items.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">{t("grades.gradeDashboard.noData")}</p>
) : (
<div className="rounded-md border">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead>{t("grades.gradeDashboard.progress.class")}</TableHead>
<TableHead>{t("grades.gradeDashboard.progress.subject")}</TableHead>
<TableHead>{t("grades.gradeDashboard.progress.teacher")}</TableHead>
<TableHead className="text-right">{t("grades.gradeDashboard.progress.completedHoursCol")}</TableHead>
<TableHead className="text-right">{t("grades.gradeDashboard.progress.items")}</TableHead>
<TableHead>{t("grades.gradeDashboard.progress.progress")}</TableHead>
<TableHead>{t("grades.gradeDashboard.progress.status")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((it) => (
<TableRow key={it.planId}>
<TableCell className="font-medium">{it.className ?? "-"}</TableCell>
<TableCell className="text-muted-foreground">{it.subjectName ?? "-"}</TableCell>
<TableCell className="text-muted-foreground">{it.teacherName ?? "-"}</TableCell>
<TableCell className="text-right tabular-nums">
{it.completedHours}/{it.totalHours}
</TableCell>
<TableCell className="text-right tabular-nums">
{it.completedItemCount}/{it.itemCount}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className="w-20 h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-primary transition-all"
style={{ width: `${it.progressRate}%` }}
/>
</div>
<span className="text-xs tabular-nums text-muted-foreground">
{formatNumber(it.progressRate)}%
</span>
</div>
</TableCell>
<TableCell>
<Badge variant={statusVariant(it.status)} className="capitalize">{it.status}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -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<JSX.Element> {
const t = await getTranslations("school")
const options = grades.map((g) => ({
id: g.id,
name: `${g.schoolName} / ${g.name}`,
}))
return (
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("grades.gradeInsights.title")}</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{grades.length}
</Badge>
</CardHeader>
<CardContent>
<ChipNav
options={options}
currentId={currentGradeId}
buildHref={buildHref}
allOption={{ id: "all", label: t("grades.gradeInsights.selectGrade") }}
/>
</CardContent>
</Card>
)
}

View File

@@ -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<FormState>(() => toFormState(null, defaultSchoolId))
const [editState, setEditState] = useState<FormState>(() => toFormState(null, defaultSchoolId))
// 年级概览统计映射,用于卡片视图
const statsMap = useMemo(() => {
const m = new Map<string, GradeOverviewStats>()
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 && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filteredGrades.slice(0, 8).map((g) => {
const stats = statsMap.get(g.id)
return (
<Card key={g.id} className="shadow-none">
<CardContent className="space-y-3 p-4">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold">{g.name}</div>
<div className="truncate text-xs text-muted-foreground">{g.school.name}</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" disabled={isWorking}>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
router.push(`/admin/school/grades/insights?gradeId=${encodeURIComponent(g.id)}`)
}
>
<BarChart3 className="mr-2 h-3.5 w-3.5" />
{t("grades.gradeOverview.viewInsights")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => openEdit(g)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
{t("grades.actions.edit")}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setDeleteItem(g)}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
{t("grades.actions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* 统计指标 */}
<div className="grid grid-cols-3 gap-2 text-center">
<div className="rounded-md bg-muted/50 p-2">
<div className="flex items-center justify-center text-muted-foreground">
<GraduationCap className="h-3 w-3" />
</div>
<div className="mt-0.5 text-sm font-semibold tabular-nums">
{stats?.classCount ?? 0}
</div>
<div className="text-[10px] text-muted-foreground">
{t("grades.gradeOverview.classCount")}
</div>
</div>
<div className="rounded-md bg-muted/50 p-2">
<div className="flex items-center justify-center text-muted-foreground">
<Users className="h-3 w-3" />
</div>
<div className="mt-0.5 text-sm font-semibold tabular-nums">
{stats?.studentCount ?? 0}
</div>
<div className="text-[10px] text-muted-foreground">
{t("grades.gradeOverview.studentCount")}
</div>
</div>
<div className="rounded-md bg-muted/50 p-2">
<div className="flex items-center justify-center text-muted-foreground">
<UserCog className="h-3 w-3" />
</div>
<div className="mt-0.5 text-sm font-semibold tabular-nums">
{stats?.teacherCount ?? 0}
</div>
<div className="text-[10px] text-muted-foreground">
{t("grades.gradeOverview.teacherCount")}
</div>
</div>
</div>
{/* 年级主任/教学主任 */}
<div className="space-y-1 border-t pt-2 text-xs">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t("grades.gradeOverview.gradeHead")}</span>
<span className="truncate font-medium">
{g.gradeHead?.name ?? t("grades.gradeOverview.notSet")}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t("grades.gradeOverview.teachingHead")}</span>
<span className="truncate font-medium">
{g.teachingHead?.name ?? t("grades.gradeOverview.notSet")}
</span>
</div>
</div>
{/* 快捷操作 */}
<Button
asChild
variant="outline"
size="sm"
className="w-full"
>
<a href={`/admin/school/grades/insights?gradeId=${encodeURIComponent(g.id)}`}>
<BarChart3 className="mr-1.5 h-3.5 w-3.5" />
{t("grades.gradeOverview.viewInsights")}
</a>
</Button>
</CardContent>
</Card>
)
})}
</div>
)}
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-1 flex-col gap-2 md:flex-row md:items-center">
<div className="flex-1 md:max-w-sm">
@@ -427,6 +553,7 @@ export function GradesClient({
className="h-auto border-none shadow-none"
/>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
@@ -482,6 +609,7 @@ export function GradesClient({
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>

View File

@@ -798,3 +798,51 @@ export const getOrgTree = cache(async (): Promise<OrgTreeNode[]> => {
return []
}
})
/**
* 年级概览统计:每个年级的班级数、学生数、教师数。
* 用于年级管理页面的卡片视图,让管理员一目了然看到各年级规模。
*/
export interface GradeOverviewStats {
gradeId: string
classCount: number
studentCount: number
teacherCount: number
}
export const getGradeOverviewStats = cache(async (): Promise<GradeOverviewStats[]> => {
try {
// 动态导入 classes 模块避免循环依赖
const { getAdminClasses } = await import("@/modules/classes/data-access")
const allClasses = await getAdminClasses()
// 按年级分组统计班级数和学生数
const statsByGrade = new Map<string, { classCount: number; studentCount: number; teacherIds: Set<string> }>()
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<string>() }
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 []
}
})