Files
NextEdu/src/app/(dashboard)/teacher/grades/analytics/page.tsx
SpecialX 19a05091d3 refactor(grades,attendance,error-book,elective): 组件化重构第 1 批 - 低风险模块
- grades: 拆分 batch-grade-entry (457→374) + grade-record-list (523→231),新增 4 个子组件
- attendance: 迁移 stats-cards/filters 到 StatsGrid/FilterBar 底座
- error-book: 迁移 stats-cards/filters 到 StatsGrid/FilterBar 底座
- elective: 迁移 stats-cards/filters 到 StatsGrid/FilterBar 底座
- 删除 12 个重复组件,新增 app 层 filter 组件就近放置
- tsc 零错误,lint 无新增错误
2026-07-06 19:01:57 +08:00

219 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { JSX } from "react"
import Link from "next/link"
import { BarChart3, ArrowLeft } from "lucide-react"
import { getTranslations } from "next-intl/server"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getGrades } from "@/modules/school/data-access"
import { getSubjectOptions } from "@/modules/school/data-access"
import {
getClassComparisonWithSignificance,
getExamOptionsForGrades,
getGradeDistribution,
getGradeTrend,
getSubjectComparison,
} from "@/modules/grades/data-access-analytics"
import { GradeTrendChart } from "@/modules/grades/components/grade-trend-chart"
import { ClassComparisonChart } from "@/modules/grades/components/class-comparison-chart"
import { SubjectComparisonChart } from "@/modules/grades/components/subject-comparison-chart"
import { GradeDistributionChart } from "@/modules/grades/components/grade-distribution-chart"
import { AnalyticsGradeFilters } from "./analytics-grade-filters"
import { KnowledgePointMasteryChart } from "@/modules/grades/components/knowledge-point-mastery-chart"
import { getClassMasterySummary } from "@/modules/diagnostic/data-access"
import { WidgetBoundary } from "@/shared/components/widget-boundary"
export const dynamic = "force-dynamic"
export default async function GradeAnalyticsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}): Promise<JSX.Element> {
const sp = await searchParams
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const t = await getTranslations("grades")
const classId = getParam(sp, "classId")
const subjectId = getParam(sp, "subjectId")
const gradeId = getParam(sp, "gradeId")
// v3-P2-7: 学期和考试筛选
const examId = getParam(sp, "examId")
const semester = getParam(sp, "semester")
const [classes, allGrades, allSubjects] = await Promise.all([
getTeacherClasses(),
getGrades(),
getSubjectOptions(),
])
if (classes.length === 0) {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t("title.analytics")}</h1>
<p className="text-muted-foreground">{t("page.analytics.description")}</p>
</div>
<EmptyState
title={t("page.analytics.noClassesTitle")}
description={t("page.analytics.noClassesDescription")}
icon={BarChart3}
className="border-none shadow-none"
/>
</div>
)
}
const targetClassId = classId ?? classes[0].id
const targetSubjectId =
subjectId && subjectId !== "all" ? subjectId : undefined
const targetGradeId = gradeId ?? allGrades[0]?.id
// v3-P2-7: 解析 semester 和 examId
const targetSemester: "1" | "2" | undefined =
semester === "1" || semester === "2" ? semester : undefined
const targetExamId = examId && examId !== "all" ? examId : undefined
// Run analytics queries in parallel
// P3-3: 集成 diagnostic 模块的知识点掌握度视图classId 已由 getTeacherClasses 校验在 scope 内)
const [
trend,
distribution,
subjectComparison,
classComparisonResult,
examOptions,
classMasterySummary,
] = await Promise.all([
getGradeTrend({
classId: targetClassId,
subjectId: targetSubjectId,
semester: targetSemester,
examId: targetExamId,
scope: ctx.dataScope,
currentUserId: ctx.userId,
}),
getGradeDistribution({
classId: targetClassId,
subjectId: targetSubjectId,
examId: targetExamId,
semester: targetSemester,
scope: ctx.dataScope,
currentUserId: ctx.userId,
}),
getSubjectComparison({
classId: targetClassId,
examId: targetExamId,
semester: targetSemester,
scope: ctx.dataScope,
}),
targetGradeId
? getClassComparisonWithSignificance({
gradeId: targetGradeId,
subjectId: targetSubjectId ?? allSubjects[0]?.id ?? "",
examId: targetExamId,
semester: targetSemester,
scope: ctx.dataScope,
})
: Promise.resolve({ items: [], significance: null }),
getExamOptionsForGrades({
classId: targetClassId,
subjectId: targetSubjectId,
scope: ctx.dataScope,
}),
getClassMasterySummary(targetClassId),
])
return (
<div className="h-full flex-1 flex-col space-y-6 p-8 md:flex">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t("title.analytics")}</h1>
<p className="text-muted-foreground">{t("page.analytics.description")}</p>
</div>
<Button asChild variant="ghost" size="sm">
<Link href="/teacher/grades">
<ArrowLeft className="mr-2 h-4 w-4" aria-hidden="true" />
{t("page.analytics.backToGrades")}
</Link>
</Button>
</div>
<AnalyticsGradeFilters
classes={classes.map((c) => ({ id: c.id, name: c.name }))}
grades={allGrades.map((g) => ({ id: g.id, name: g.name }))}
subjects={allSubjects.map((s) => ({ id: s.id, name: s.name ?? "Unknown" }))}
exams={examOptions}
currentClassId={targetClassId}
currentSubjectId={subjectId ?? "all"}
currentGradeId={targetGradeId ?? ""}
currentExamId={examId ?? "all"}
currentSemester={semester ?? "all"}
/>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<WidgetBoundary title={t("page.analytics.trendTitle")}>
{trend ? (
<GradeTrendChart data={trend} />
) : (
<EmptyState
title={t("page.analytics.trendEmptyTitle")}
description={t("page.analytics.trendEmptyDescription")}
icon={BarChart3}
className="border-none shadow-none"
/>
)}
</WidgetBoundary>
<WidgetBoundary title={t("page.analytics.distributionTitle")}>
{distribution.totalCount > 0 ? (
<GradeDistributionChart data={distribution} />
) : (
<EmptyState
title={t("page.analytics.distributionEmptyTitle")}
description={t("page.analytics.distributionEmptyDescription")}
icon={BarChart3}
className="border-none shadow-none"
/>
)}
</WidgetBoundary>
<WidgetBoundary title={t("page.analytics.subjectComparisonTitle")}>
{subjectComparison.length > 0 ? (
<SubjectComparisonChart data={subjectComparison} />
) : (
<EmptyState
title={t("page.analytics.subjectComparisonEmptyTitle")}
description={t("page.analytics.subjectComparisonEmptyDescription")}
icon={BarChart3}
className="border-none shadow-none"
/>
)}
</WidgetBoundary>
<WidgetBoundary title={t("page.analytics.classComparisonTitle")}>
{classComparisonResult.items.length > 0 ? (
<ClassComparisonChart
data={classComparisonResult.items}
significance={classComparisonResult.significance}
/>
) : (
<EmptyState
title={t("page.analytics.classComparisonEmptyTitle")}
description={t("page.analytics.classComparisonEmptyDescription")}
icon={BarChart3}
className="border-none shadow-none"
/>
)}
</WidgetBoundary>
<WidgetBoundary title={t("knowledgePointMastery.title")}>
<KnowledgePointMasteryChart
data={classMasterySummary?.knowledgePointStats ?? []}
detailHref="/teacher/diagnostic"
/>
</WidgetBoundary>
</div>
</div>
)
}