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

@@ -14,6 +14,7 @@ import {
} from "@/modules/parent/components/child-detail-panel"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { ShieldAlert } from "lucide-react"
import { getTranslations } from "next-intl/server"
export const dynamic = "force-dynamic"
@@ -27,6 +28,7 @@ export default async function ChildDetailPage({
const { studentId } = await params
const sp = await searchParams
const ctx = await requireAuth()
const t = await getTranslations("common")
// 校验当前家长与该子女存在关系(同时按 parentId + studentId 过滤,防止跨家庭信息泄露)
const relation = await verifyParentChildRelation(studentId, ctx.userId)
@@ -41,8 +43,8 @@ export default async function ChildDetailPage({
<div className="p-6 md:p-8">
<EmptyState
icon={ShieldAlert}
title="Access denied"
description="This student is not linked to your account. Please contact the school administrator if you believe this is an error."
title={t("accessDenied")}
description={t("accessDeniedDesc")}
className="border-none shadow-none"
/>
</div>

View File

@@ -11,6 +11,7 @@ import {
} from "@/modules/parent/components/parent-children-data-page"
import { getUserNamesByIds } from "@/modules/users/data-access"
import { Card, CardContent } from "@/shared/components/ui/card"
import { getTranslations } from "next-intl/server"
export const dynamic = "force-dynamic"
@@ -34,15 +35,16 @@ type ChildDiagnosticItem = ChildDiagnosticSuccessItem | ChildDiagnosticErrorItem
export default async function ParentDiagnosticPage() {
const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ)
const t = await getTranslations("diagnostic")
if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) {
return (
<ParentNoChildrenPage
title="Children Diagnostic"
description="View your children's knowledge point mastery and diagnostic reports."
title={t("parent.title")}
description={t("parent.description")}
icon={Stethoscope}
emptyTitle="No children linked"
emptyDescription="Your account is not linked to any student accounts yet. Please contact the school administrator."
emptyTitle={t("parent.noChildren")}
emptyDescription={t("parent.noChildrenDesc")}
/>
)
}
@@ -68,7 +70,7 @@ export default async function ParentDiagnosticPage() {
const items: ChildDiagnosticItem[] = results.map((r, idx) => {
const studentId = childrenIds[idx]
const studentName = nameMap.get(studentId)?.name ?? "Unknown student"
const studentName = nameMap.get(studentId)?.name ?? t("parent.selectChild")
if (r.status === "fulfilled") {
return {
studentId,
@@ -88,11 +90,11 @@ export default async function ParentDiagnosticPage() {
return (
<ParentChildrenDataPage
title="Children Diagnostic"
description="View knowledge point mastery and diagnostic reports for all your children."
title={t("parent.title")}
description={t("parent.description")}
icon={Stethoscope}
noRecordsTitle="No diagnostic data"
noRecordsDescription="Your children don't have any diagnostic data yet."
noRecordsTitle={t("parent.noReports")}
noRecordsDescription={t("parent.noReports")}
items={items}
renderItem={(item) => (
<>
@@ -111,10 +113,10 @@ export default async function ParentDiagnosticPage() {
<CardContent className="flex flex-col items-center gap-3 py-8 text-center">
<AlertCircle className="h-8 w-8 text-destructive" aria-hidden="true" />
<p className="text-sm font-medium text-destructive">
Failed to load diagnostic data for {item.studentName}.
{t("error.loadFailed")} for {item.studentName}.
</p>
<p className="text-xs text-muted-foreground">
Please refresh the page or contact the school administrator if the problem persists.
{t("error.loadFailed")}. Please refresh the page or contact the school administrator if the problem persists.
</p>
</CardContent>
</Card>

View File

@@ -17,23 +17,25 @@ import {
} from "@/modules/error-book/data-access"
import { ErrorBookStatsCards } from "@/modules/error-book/components/error-book-stats-cards"
import { TopWrongQuestions } from "@/modules/error-book/components/top-wrong-questions"
import { getTranslations } from "next-intl/server"
export const dynamic = "force-dynamic"
export default async function ParentErrorBookPage(): Promise<JSX.Element> {
const ctx = await requirePermission(Permissions.ERROR_BOOK_READ)
const t = await getTranslations("errorBook")
if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground"></p>
<h1 className="text-2xl font-bold tracking-tight">{t("parent.title")}</h1>
<p className="text-muted-foreground">{t("parent.description")}</p>
</div>
<EmptyState
icon={Users}
title="暂无子女关联"
description="您的账号尚未关联子女,请联系学校管理员进行关联。"
title={t("parent.noChild")}
description={t("parent.noChildDesc")}
className="h-[360px] bg-card"
/>
</div>
@@ -56,8 +58,8 @@ export default async function ParentErrorBookPage(): Promise<JSX.Element> {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground"></p>
<h1 className="text-2xl font-bold tracking-tight">{t("parent.title")}</h1>
<p className="text-muted-foreground">{t("parent.description")}</p>
</div>
{childrenIds.length === 1 ? (
@@ -68,35 +70,35 @@ export default async function ParentErrorBookPage(): Promise<JSX.Element> {
<div className="grid gap-4 md:grid-cols-2">
{childrenIds.map((childId, idx) => {
const stats = childStatsList[idx]
const name = nameMap.get(childId) ?? "未知"
const name = nameMap.get(childId) ?? t("parent.unknown")
return (
<Card key={childId}>
<CardHeader>
<CardTitle className="flex items-center justify-between text-base">
<span>{name}</span>
<Badge variant="outline">
{formatNumber(stats.masteredRate * 100, 0)}%
{t("parent.mastery", { rate: formatNumber(stats.masteredRate * 100, 0) })}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<div className="text-muted-foreground"></div>
<div className="text-muted-foreground">{t("parent.totalErrors")}</div>
<div className="text-lg font-bold">{stats.totalCount}</div>
</div>
<div>
<div className="text-muted-foreground"></div>
<div className="text-muted-foreground">{t("parent.dueReview")}</div>
<div className="text-lg font-bold text-rose-600 dark:text-rose-400">
{stats.dueReviewCount}
</div>
</div>
<div>
<div className="text-muted-foreground"></div>
<div className="text-muted-foreground">{t("parent.newItems")}</div>
<div className="font-medium text-blue-600 dark:text-blue-400">{stats.newCount}</div>
</div>
<div>
<div className="text-muted-foreground"></div>
<div className="text-muted-foreground">{t("parent.mastered")}</div>
<div className="font-medium text-emerald-600 dark:text-emerald-400">{stats.masteredCount}</div>
</div>
</div>
@@ -112,7 +114,7 @@ export default async function ParentErrorBookPage(): Promise<JSX.Element> {
{weakKps.length > 0 ? (
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardTitle className="text-base">{t("parent.weakPoints")}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
@@ -121,7 +123,7 @@ export default async function ParentErrorBookPage(): Promise<JSX.Element> {
<div className="flex items-center justify-between text-sm">
<span>{kp.knowledgePointName}</span>
<span className="text-muted-foreground">
{kp.errorCount} · {formatNumber(kp.masteryRate * 100, 0)}%
{t("parent.errorsAndMastery", { count: kp.errorCount, rate: formatNumber(kp.masteryRate * 100, 0) })}
</span>
</div>
<Progress value={kp.masteryRate * 100} className="h-1.5" />

View File

@@ -2,6 +2,7 @@
import { EmptyState } from "@/shared/components/ui/empty-state"
import { AlertTriangle } from "lucide-react"
import { useTranslations } from "next-intl"
export default function ParentError({
error,
@@ -10,13 +11,15 @@ export default function ParentError({
error: Error & { digest?: string }
reset: () => void
}) {
const t = useTranslations("common")
return (
<div className="p-6 md:p-8">
<EmptyState
icon={AlertTriangle}
title="Something went wrong"
title={t("somethingWentWrong")}
description={error.message || "An unexpected error occurred. Please try again."}
action={{ label: "Try again", onClick: reset }}
action={{ label: t("tryAgain"), onClick: reset }}
/>
</div>
)

View File

@@ -11,6 +11,7 @@ import {
import { ParentExportButton } from "@/modules/parent/components/parent-export-button"
import { GraduationCap } from "lucide-react"
import type { ClassAverageTrendResult } from "@/modules/grades/types"
import { getTranslations } from "next-intl/server"
export const dynamic = "force-dynamic"
@@ -22,15 +23,16 @@ interface ChildGradeItem {
export default async function ParentGradesPage() {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const t = await getTranslations("grades")
if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) {
return (
<ParentNoChildrenPage
title="Children Grades"
description="View your children's grade records."
title={t("parent.title")}
description={t("parent.description")}
icon={GraduationCap}
emptyTitle="No children linked"
emptyDescription="Your account is not linked to any student accounts yet. Please contact the school administrator."
emptyTitle={t("parent.noChildren")}
emptyDescription={t("parent.noChildrenDesc")}
/>
)
}
@@ -64,11 +66,11 @@ export default async function ParentGradesPage() {
return (
<ParentChildrenDataPage
title="Children Grades"
description="Compare grades across all your children. For single-child analysis, open the child's detail page."
title={t("parent.title")}
description={t("parent.description")}
icon={GraduationCap}
noRecordsTitle="No grade records"
noRecordsDescription="Your children don't have any grade records yet."
noRecordsTitle={t("parent.noGrades")}
noRecordsDescription={t("parent.noGradesDesc")}
items={validItems}
renderItem={({ studentId, summary, classAverageTrend }) => (
<>

View File

@@ -4,23 +4,26 @@ import { CalendarDays, ArrowLeft, Phone, Mail } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { getTranslations } from "next-intl/server"
export const dynamic = "force-dynamic"
export default async function ParentLeavePage() {
const t = await getTranslations("leave")
return (
<div className="p-6 md:p-8 space-y-6">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">Leave Request</h1>
<h1 className="text-2xl font-bold tracking-tight">{t("title")}</h1>
<p className="text-sm text-muted-foreground">
Submit a leave request for your child.
{t("description")}
</p>
</div>
<Button asChild variant="ghost" size="sm" className="gap-2 -ml-2">
<Link href="/parent/dashboard" aria-label="Back to Dashboard">
<Link href="/parent/dashboard" aria-label={t("backToDashboard")}>
<ArrowLeft className="h-4 w-4" />
Back to Dashboard
{t("backToDashboard")}
</Link>
</Button>
@@ -28,33 +31,33 @@ export default async function ParentLeavePage() {
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CalendarDays className="h-4 w-4 text-muted-foreground" aria-hidden />
Online Leave Request
{t("onlineLeave")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<EmptyState
icon={CalendarDays}
title="Coming soon"
description="The online leave request feature is being developed and will be available soon. For now, please contact the homeroom teacher or school office directly."
title={t("comingSoon")}
description={t("comingSoonDesc")}
className="border-none shadow-none"
/>
<div className="rounded-md border bg-muted/30 p-4 space-y-2">
<div className="text-sm font-medium">Contact options</div>
<div className="text-sm font-medium">{t("contactOptions")}</div>
<ul className="space-y-2 text-sm text-muted-foreground">
<li className="flex items-center gap-2">
<Phone className="h-4 w-4" aria-hidden />
<span>Call the school office during working hours</span>
<span>{t("callOffice")}</span>
</li>
<li className="flex items-center gap-2">
<Mail className="h-4 w-4" aria-hidden />
<span>Send a message to the homeroom teacher via the Messages page</span>
<span>{t("sendMessage")}</span>
</li>
<li>
<Link
href="/messages"
className="inline-flex h-9 items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
Go to Messages
{t("goToMessages")}
</Link>
</li>
</ul>

View File

@@ -0,0 +1,20 @@
"use client"
import { useTranslations } from "next-intl"
import { BookOpen } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function ParentLessonPlanViewError() {
const t = useTranslations("lessonPreparation")
return (
<div className="p-8">
<EmptyState
icon={BookOpen}
title={t("error.loadFailed")}
description={t("error.loadFailedDesc")}
action={{ label: t("error.retry"), onClick: () => window.location.reload() }}
className="border-none shadow-none"
/>
</div>
)
}

View File

@@ -0,0 +1,11 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function ParentLessonPlanViewLoading() {
return (
<div className="h-[calc(100vh-4rem)]">
<div className="flex h-full items-center justify-center">
<Skeleton className="h-[80%] w-[80%]" />
</div>
</div>
)
}

View File

@@ -0,0 +1,81 @@
import type { JSX } from "react"
import { Suspense } from "react"
import { getTranslations } from "next-intl/server"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { getLessonPlanById } from "@/modules/lesson-preparation/data-access"
import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access"
import { LessonPlanReadonlyView } from "@/modules/lesson-preparation/components/lesson-plan-readonly-view"
import { Skeleton } from "@/shared/components/ui/skeleton"
export const dynamic = "force-dynamic"
export default async function ParentLessonPlanViewPage({
params,
}: {
params: Promise<{ planId: string }>
}): Promise<JSX.Element> {
const { planId } = await params
const t = await getTranslations("lessonPreparation")
const ctx = await getAuthContext()
const plan = await getLessonPlanById(planId, ctx.userId)
if (!plan) {
return (
<div className="p-6">
<div className="rounded-md border border-outline-variant bg-surface-container-low p-4 text-on-surface-variant">
{t("readonly.notFound")}
</div>
</div>
)
}
if (plan.status !== "published") {
return (
<div className="p-6">
<div className="rounded-md border border-outline-variant bg-surface-container-low p-4 text-on-surface-variant">
{t("readonly.notPublished")}
</div>
</div>
)
}
let textbookTitle: string | undefined
let chapterTitle: string | undefined
if (plan.textbookId) {
const textbook = await getTextbookById(plan.textbookId)
textbookTitle = textbook?.title
if (plan.chapterId) {
const chapters = await getChaptersByTextbookId(plan.textbookId)
const findChapter = (list: typeof chapters): typeof chapters[number] | undefined => {
for (const ch of list) {
if (ch.id === plan.chapterId) return ch
if (ch.children && ch.children.length > 0) {
const found = findChapter(ch.children as typeof chapters)
if (found) return found
}
}
return undefined
}
const chapter = findChapter(chapters)
chapterTitle = chapter?.title
}
}
return (
<div className="h-[calc(100vh-4rem)]">
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<Skeleton className="h-[80%] w-[80%]" />
</div>
}
>
<LessonPlanReadonlyView
doc={plan.content}
textbookTitle={textbookTitle}
chapterTitle={chapterTitle}
/>
</Suspense>
</div>
)
}

View File

@@ -0,0 +1,20 @@
"use client"
import { useTranslations } from "next-intl"
import { BookOpen } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function ParentLessonPlansError() {
const t = useTranslations("lessonPreparation")
return (
<div className="p-8">
<EmptyState
icon={BookOpen}
title={t("error.loadFailed")}
description={t("error.loadFailedDesc")}
action={{ label: t("error.retry"), onClick: () => window.location.reload() }}
className="border-none shadow-none"
/>
</div>
)
}

View File

@@ -0,0 +1,17 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function ParentLessonPlansLoading() {
return (
<div className="p-6 space-y-4">
<div className="space-y-2">
<Skeleton className="h-8 w-[180px]" />
<Skeleton className="h-4 w-[300px]" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-[180px] w-full" />
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,44 @@
import type { JSX } from "react"
import { Suspense } from "react"
import { getTranslations } from "next-intl/server"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { getLessonPlans } from "@/modules/lesson-preparation/data-access"
import { getSubjectOptions } from "@/modules/school/data-access"
import { LessonPlanList } from "@/modules/lesson-preparation/components/lesson-plan-list"
import { Skeleton } from "@/shared/components/ui/skeleton"
export const dynamic = "force-dynamic"
export default async function ParentLessonPlansPage(): Promise<JSX.Element> {
const t = await getTranslations("lessonPreparation")
const ctx = await getAuthContext()
const [items, subjects] = await Promise.all([
getLessonPlans({ status: "published" }, ctx.dataScope, ctx.userId),
getSubjectOptions(),
])
return (
<div className="p-6 space-y-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t("parent.title")}</h1>
<p className="text-muted-foreground">{t("parent.description")}</p>
</div>
<Suspense
fallback={
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-[180px] w-full" />
))}
</div>
}
>
<LessonPlanList
initialItems={items}
subjects={subjects}
viewMode="parent"
/>
</Suspense>
</div>
)
}