feat(app): add lesson-plans, practice, and grade dashboard routes

- 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
This commit is contained in:
SpecialX
2026-06-24 12:03:47 +08:00
parent 8c2fe14c20
commit 37d2688a28
84 changed files with 2665 additions and 661 deletions

View File

@@ -0,0 +1,273 @@
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>
)
}