- Add admin/lesson-plans, parent/lesson-plans, student/lesson-plans routes - Add student/practice and teacher/practice routes for adaptive practice - Add management/grade/dashboard and management/grade/practice routes - Add teacher/lesson-plans error and loading boundaries - Update existing admin, parent, student, teacher pages with new features - Update globals.css and proxy middleware
274 lines
9.7 KiB
TypeScript
274 lines
9.7 KiB
TypeScript
import type { Metadata } from "next"
|
|
import type { JSX } from "react"
|
|
import { Suspense } from "react"
|
|
import { BarChart3 } from "lucide-react"
|
|
import { getTranslations } from "next-intl/server"
|
|
|
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
|
import { Permissions } from "@/shared/types/permissions"
|
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
|
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
|
import {
|
|
getClassIdsByGradeIds,
|
|
getStudentIdsByClassIds,
|
|
} from "@/modules/classes/data-access"
|
|
|
|
import {
|
|
getTeacherClassPracticeOverviews,
|
|
getClassStudentPracticeSummaries,
|
|
getPracticeTypeBreakdown,
|
|
getClassKnowledgePointWeakness,
|
|
getStudentsWithoutPractice,
|
|
getStudentNameMap,
|
|
} from "@/modules/adaptive-practice/data-access-analytics"
|
|
import { PracticeOverviewStatsCards } from "@/modules/adaptive-practice/components/practice-overview-stats-cards"
|
|
import { ClassPracticeComparisonTable } from "@/modules/adaptive-practice/components/class-practice-comparison-table"
|
|
import { PracticeTypeBreakdownChart } from "@/modules/adaptive-practice/components/practice-type-breakdown-chart"
|
|
import { ClassKnowledgePointWeaknessChart } from "@/modules/adaptive-practice/components/class-knowledge-point-weakness-chart"
|
|
import { StudentPracticeRankingTable } from "@/modules/adaptive-practice/components/student-practice-ranking-table"
|
|
import { InactiveStudentsAlert } from "@/modules/adaptive-practice/components/inactive-students-alert"
|
|
import { ClassFilter } from "@/modules/error-book/components/class-filter"
|
|
import type { ClassErrorOverview } from "@/modules/error-book/types"
|
|
|
|
export const dynamic = "force-dynamic"
|
|
|
|
export async function generateMetadata(): Promise<Metadata> {
|
|
const t = await getTranslations("practice")
|
|
return {
|
|
title: `${t("teacher.title")} - Next_Edu`,
|
|
description: t("teacher.description"),
|
|
}
|
|
}
|
|
|
|
async function TeacherPracticeContent({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<SearchParams>
|
|
}): Promise<JSX.Element> {
|
|
const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ)
|
|
const t = await getTranslations("practice")
|
|
|
|
const params = await searchParams
|
|
const classIds = ctx.dataScope.type === "class_taught" ? ctx.dataScope.classIds : []
|
|
const gradeIds = ctx.dataScope.type === "grade_managed" ? ctx.dataScope.gradeIds : []
|
|
|
|
if (classIds.length === 0 && gradeIds.length === 0 && ctx.dataScope.type !== "all") {
|
|
return (
|
|
<div className="flex h-full flex-col space-y-8 p-8">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight">{t("teacher.title")}</h1>
|
|
<p className="text-muted-foreground">{t("teacher.description")}</p>
|
|
</div>
|
|
<EmptyState
|
|
icon={BarChart3}
|
|
title={t("teacher.noClass")}
|
|
description={t("teacher.noClassDescription")}
|
|
className="h-[360px] bg-card"
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 年级主任/教研组长:展开年级为班级
|
|
let targetClassIds = classIds
|
|
if (gradeIds.length > 0) {
|
|
const gradeClassIds = await getClassIdsByGradeIds(gradeIds)
|
|
targetClassIds = [...new Set([...classIds, ...gradeClassIds])]
|
|
}
|
|
|
|
// 获取所有学生 ID
|
|
const allStudentIds = await getStudentIdsByClassIds(targetClassIds)
|
|
|
|
if (allStudentIds.length === 0) {
|
|
return (
|
|
<div className="flex h-full flex-col space-y-8 p-8">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight">{t("teacher.title")}</h1>
|
|
<p className="text-muted-foreground">{t("teacher.description")}</p>
|
|
</div>
|
|
<EmptyState
|
|
icon={BarChart3}
|
|
title={t("teacher.noStudent")}
|
|
description={t("teacher.noStudentDescription")}
|
|
className="h-[360px] bg-card"
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 解析 URL 参数:班级筛选
|
|
const classParam = getParam(params, "classId")
|
|
const effectiveClassId = classParam ?? "all"
|
|
|
|
// 班级概览(用于班级筛选器显示)
|
|
const classOverviews = await getTeacherClassPracticeOverviews(targetClassIds)
|
|
|
|
// 构造 ClassFilter 所需的数据格式
|
|
const classFilterData: ClassErrorOverview[] = classOverviews.map((c) => ({
|
|
classId: c.classId,
|
|
className: c.className,
|
|
studentCount: c.totalStudents,
|
|
totalErrorItems: c.totalSessions,
|
|
dueReviewCount: 0,
|
|
averageErrorPerStudent: c.totalStudents > 0 ? c.totalSessions / c.totalStudents : 0,
|
|
averageMasteryRate: c.averageAccuracy,
|
|
}))
|
|
|
|
// 确定查询的班级范围
|
|
const queryClassIds = effectiveClassId === "all" ? targetClassIds : [effectiveClassId]
|
|
const queryStudentIds = await getStudentIdsByClassIds(queryClassIds)
|
|
|
|
// 并行查询所有统计数据
|
|
const [typeBreakdown, studentSummaries, nameMap, inactiveIds] = await Promise.all([
|
|
getPracticeTypeBreakdown(queryStudentIds),
|
|
// 按班级查询学生摘要(如果是单班级视图,直接查询;如果是全部视图,按班级逐个查询后合并)
|
|
effectiveClassId === "all"
|
|
? getClassStudentPracticeSummariesForClasses(queryClassIds)
|
|
: getClassStudentPracticeSummaries(effectiveClassId),
|
|
getStudentNameMap(queryStudentIds),
|
|
effectiveClassId === "all"
|
|
? getInactiveStudentsForClasses(queryClassIds)
|
|
: getStudentsWithoutPractice(effectiveClassId),
|
|
])
|
|
|
|
// 聚合统计
|
|
const totalSessions = classOverviews.reduce((sum, c) => sum + c.totalSessions, 0)
|
|
const totalAnswered = classOverviews.reduce((sum, c) => sum + c.totalQuestionsAnswered, 0)
|
|
const totalCorrect = classOverviews.reduce((sum, c) => sum + c.totalCorrect, 0)
|
|
const totalActiveStudents = classOverviews.reduce((sum, c) => sum + c.activeStudents, 0)
|
|
const totalStudents = classOverviews.reduce((sum, c) => sum + c.totalStudents, 0)
|
|
const averageAccuracy = totalAnswered > 0 ? totalCorrect / totalAnswered : 0
|
|
const participationRate = totalStudents > 0 ? totalActiveStudents / totalStudents : 0
|
|
|
|
// 单班级视图:查询知识点薄弱度
|
|
const weakKps = effectiveClassId !== "all"
|
|
? await getClassKnowledgePointWeakness(effectiveClassId, 10)
|
|
: []
|
|
|
|
const hasData = totalSessions > 0
|
|
|
|
if (!hasData) {
|
|
return (
|
|
<div className="flex h-full flex-col space-y-8 p-8">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight">{t("teacher.title")}</h1>
|
|
<p className="text-muted-foreground">{t("teacher.description")}</p>
|
|
</div>
|
|
<EmptyState
|
|
icon={BarChart3}
|
|
title={t("teacher.noData")}
|
|
description={t("teacher.noDataDescription")}
|
|
className="h-[360px] bg-card"
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full flex-col space-y-6 p-8">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight">{t("teacher.title")}</h1>
|
|
<p className="text-muted-foreground">{t("teacher.description")}</p>
|
|
</div>
|
|
|
|
{/* 班级筛选器 */}
|
|
{classFilterData.length > 0 ? (
|
|
<Suspense fallback={<Skeleton className="h-10 w-full" />}>
|
|
<ClassFilter
|
|
classes={classFilterData}
|
|
currentClassId={effectiveClassId}
|
|
/>
|
|
</Suspense>
|
|
) : null}
|
|
|
|
{/* 统计卡片 */}
|
|
<PracticeOverviewStatsCards
|
|
totalClasses={classOverviews.length}
|
|
totalSessions={totalSessions}
|
|
totalAnswered={totalAnswered}
|
|
averageAccuracy={averageAccuracy}
|
|
participationRate={participationRate}
|
|
/>
|
|
|
|
{/* 班级对比表(仅在"全部班级"视图下显示) */}
|
|
{effectiveClassId === "all" && classOverviews.length > 1 ? (
|
|
<ClassPracticeComparisonTable data={classOverviews} />
|
|
) : null}
|
|
|
|
{/* 练习类型分布图 */}
|
|
{typeBreakdown.length > 0 ? (
|
|
<PracticeTypeBreakdownChart data={typeBreakdown} />
|
|
) : null}
|
|
|
|
{/* 知识点薄弱度(仅在单班级视图下显示) */}
|
|
{effectiveClassId !== "all" ? (
|
|
<ClassKnowledgePointWeaknessChart data={weakKps} />
|
|
) : null}
|
|
|
|
{/* 学生练习排名 */}
|
|
{studentSummaries.length > 0 ? (
|
|
<StudentPracticeRankingTable data={studentSummaries} studentNames={nameMap} />
|
|
) : null}
|
|
|
|
{/* 未参与练习学生提醒 */}
|
|
<InactiveStudentsAlert
|
|
inactiveStudentIds={inactiveIds}
|
|
studentNames={nameMap}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 获取多个班级的学生练习摘要(合并结果)。
|
|
*
|
|
* 用于"全部班级"视图,按班级逐个查询后合并。
|
|
*/
|
|
async function getClassStudentPracticeSummariesForClasses(
|
|
classIds: string[],
|
|
) {
|
|
const results = await Promise.all(
|
|
classIds.map((classId) => getClassStudentPracticeSummaries(classId)),
|
|
)
|
|
// 合并并按练习数降序排列
|
|
return results.flat().sort((a, b) => b.totalSessions - a.totalSessions)
|
|
}
|
|
|
|
/**
|
|
* 获取多个班级中未参与练习的学生 ID 列表(合并结果)。
|
|
*/
|
|
async function getInactiveStudentsForClasses(
|
|
classIds: string[],
|
|
): Promise<string[]> {
|
|
const results = await Promise.all(
|
|
classIds.map((classId) => getStudentsWithoutPractice(classId)),
|
|
)
|
|
return results.flat()
|
|
}
|
|
|
|
export default async function TeacherPracticePage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<SearchParams>
|
|
}): Promise<JSX.Element> {
|
|
return (
|
|
<Suspense
|
|
fallback={
|
|
<div className="flex h-full flex-col space-y-8 p-8">
|
|
<Skeleton className="h-10 w-48" />
|
|
<Skeleton className="h-10 w-full" />
|
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-[100px]" />
|
|
))}
|
|
</div>
|
|
<Skeleton className="h-[300px] w-full" />
|
|
</div>
|
|
}
|
|
>
|
|
<TeacherPracticeContent searchParams={searchParams} />
|
|
</Suspense>
|
|
)
|
|
}
|