- 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 无新增错误
219 lines
8.0 KiB
TypeScript
219 lines
8.0 KiB
TypeScript
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>
|
||
)
|
||
}
|