feat(app): add error/loading boundaries and update dashboard routes

- Add error.tsx and loading.tsx boundaries for admin, parent, student, teacher routes

- Add dashboard-error-fallback and dashboard-loading-skeleton components

- Add student/learning page, parent/leave routes, teacher textbook components

- Update existing app routes across auth, dashboard, and API endpoints

- Update proxy middleware and next-auth type declarations
This commit is contained in:
SpecialX
2026-06-23 17:38:28 +08:00
parent c4d3433cc9
commit 1a9377222c
90 changed files with 1690 additions and 741 deletions

View File

@@ -0,0 +1,24 @@
"use client"
import { AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function ParentAttendanceError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
const t = useTranslations("attendance")
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title={t("errors.unexpected")}
description={t("errors.unexpected")}
action={{
label: t("actions.save"),
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="space-y-8 p-6 md:p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<div className="space-y-8">
{Array.from({ length: 2 }).map((_, i) => (
<Card key={i}>
<CardHeader>
<CardTitle className="text-lg">
<Skeleton className="h-5 w-32" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, j) => (
<Skeleton key={j} className="h-20 w-full" />
))}
</div>
<Skeleton className="h-40 w-full" />
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,45 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-16 w-16 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-7 w-40" />
<Skeleton className="h-4 w-56" />
</div>
</div>
<Skeleton className="h-9 w-full max-w-md" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="text-base">
<Skeleton className="h-4 w-32" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">
<Skeleton className="h-4 w-32" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -1,9 +1,17 @@
import { notFound } from "next/navigation"
import { requireAuth } from "@/shared/lib/auth-guard"
import { verifyParentChildRelation, getChildDashboardData } from "@/modules/parent/data-access"
import { getSearchParam } from "@/shared/lib/utils"
import {
verifyParentChildRelation,
getChildDashboardData,
getChildNameList,
} from "@/modules/parent/data-access"
import { ChildDetailHeader } from "@/modules/parent/components/child-detail-header"
import { ChildDetailPanel } from "@/modules/parent/components/child-detail-panel"
import {
ChildDetailPanel,
SiblingSwitcher,
} from "@/modules/parent/components/child-detail-panel"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { ShieldAlert } from "lucide-react"
@@ -11,10 +19,13 @@ export const dynamic = "force-dynamic"
export default async function ChildDetailPage({
params,
searchParams,
}: {
params: Promise<{ studentId: string }>
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}) {
const { studentId } = await params
const sp = await searchParams
const ctx = await requireAuth()
// 校验当前家长与该子女存在关系(同时按 parentId + studentId 过滤,防止跨家庭信息泄露)
@@ -38,15 +49,29 @@ export default async function ChildDetailPage({
)
}
const child = await getChildDashboardData(studentId, relation)
const [child, siblings] = await Promise.all([
getChildDashboardData(studentId, relation),
getChildNameList(ctx.userId),
])
if (!child) {
notFound()
}
const initialTab = getSearchParam(sp, "tab")
return (
<div className="p-6 md:p-8 space-y-6">
<ChildDetailHeader child={child} />
<ChildDetailPanel child={child} />
<ChildDetailPanel
child={child}
initialTab={initialTab}
siblingSwitcher={
<SiblingSwitcher
current={{ id: child.basicInfo.id, name: child.basicInfo.name }}
siblings={siblings}
/>
}
/>
</div>
)
}

View File

@@ -1,24 +1,7 @@
"use client"
import { AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl"
import { DashboardErrorFallback } from "@/modules/dashboard/components/dashboard-error-fallback"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function ParentDashboardError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
const t = useTranslations("dashboard")
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title={t("error.loadFailed")}
description={t("error.loadFailedDesc")}
action={{
label: t("error.retry"),
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
export default function ParentDashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
return <DashboardErrorFallback error={error} reset={reset} />
}

View File

@@ -1,4 +1,4 @@
import { Stethoscope } from "lucide-react"
import { Stethoscope, AlertCircle } from "lucide-react"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
@@ -9,9 +9,29 @@ import {
ParentChildrenDataPage,
ParentNoChildrenPage,
} from "@/modules/parent/components/parent-children-data-page"
import { getUserNamesByIds } from "@/modules/users/data-access"
import { Card, CardContent } from "@/shared/components/ui/card"
export const dynamic = "force-dynamic"
/** v4-P1-9: 单个子女的诊断数据(成功) */
interface ChildDiagnosticSuccessItem {
studentId: string
studentName: string
status: "success"
summary: Awaited<ReturnType<typeof getStudentMasterySummary>>
reports: Awaited<ReturnType<typeof getDiagnosticReports>>["reports"]
}
/** v4-P1-9: 单个子女的诊断数据(失败) */
interface ChildDiagnosticErrorItem {
studentId: string
studentName: string
status: "error"
}
type ChildDiagnosticItem = ChildDiagnosticSuccessItem | ChildDiagnosticErrorItem
export default async function ParentDiagnosticPage() {
const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ)
@@ -27,25 +47,44 @@ export default async function ParentDiagnosticPage() {
)
}
// 使用 allSettled 容错:单个子女查询失败不影响其他子女展示
// 预先查询所有子女的姓名,用于错误卡片展示
const childrenIds = ctx.dataScope.childrenIds
const nameMap = await getUserNamesByIds(childrenIds)
// v4-P1-9: 使用 allSettled 容错,但保留 rejected 项渲染错误卡片
const results = await Promise.allSettled(
ctx.dataScope.childrenIds.map(async (id) => {
const [summary, reports] = await Promise.all([
childrenIds.map(async (id) => {
const [summary, reportsResult] = await Promise.all([
getStudentMasterySummary(id),
getDiagnosticReports({ studentId: id }),
// v4-P1-3: 家长仅可见已发布报告,避免草稿泄露
getDiagnosticReports(
{ studentId: id, status: "published" },
ctx.dataScope,
),
])
return { summary, reports, studentId: id }
return { summary, reports: reportsResult.reports, studentId: id }
}),
)
const validItems = results
.filter(
(r): r is PromiseFulfilledResult<{
summary: Awaited<ReturnType<typeof getStudentMasterySummary>>
reports: Awaited<ReturnType<typeof getDiagnosticReports>>
studentId: string
}> => r.status === "fulfilled",
)
.map((r) => r.value)
const items: ChildDiagnosticItem[] = results.map((r, idx) => {
const studentId = childrenIds[idx]
const studentName = nameMap.get(studentId)?.name ?? "Unknown student"
if (r.status === "fulfilled") {
return {
studentId,
studentName,
status: "success" as const,
summary: r.value.summary,
reports: r.value.reports,
}
}
// v4-P1-9: rejected 项不再静默丢弃,渲染错误卡片
return {
studentId,
studentName,
status: "error" as const,
}
})
return (
<ParentChildrenDataPage
@@ -54,15 +93,32 @@ export default async function ParentDiagnosticPage() {
icon={Stethoscope}
noRecordsTitle="No diagnostic data"
noRecordsDescription="Your children don't have any diagnostic data yet."
items={validItems}
renderItem={({ summary, reports }) => (
items={items}
renderItem={(item) => (
<>
<div className="border-b pb-2">
<h3 className="text-lg font-semibold">
{summary?.studentName ?? "Unknown student"}
</h3>
<h3 className="text-lg font-semibold">{item.studentName}</h3>
</div>
<StudentDiagnosticView summary={summary} reports={reports} />
{item.status === "success" ? (
<StudentDiagnosticView
summary={item.summary}
reports={item.reports}
practiceHrefBase={null}
/>
) : (
// v4-P1-9: 错误卡片,提示家长该子女数据加载失败
<Card className="border-destructive/50">
<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}.
</p>
<p className="text-xs text-muted-foreground">
Please refresh the page or contact the school administrator if the problem persists.
</p>
</CardContent>
</Card>
)}
</>
)}
/>

View File

@@ -0,0 +1,23 @@
"use client"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { AlertTriangle } from "lucide-react"
export default function ParentError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="p-6 md:p-8">
<EmptyState
icon={AlertTriangle}
title="Something went wrong"
description={error.message || "An unexpected error occurred. Please try again."}
action={{ label: "Try again", onClick: reset }}
/>
</div>
)
}

View File

@@ -0,0 +1,27 @@
"use client"
import { AlertCircle } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function ParentGradesError({
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title="子女成绩页面加载失败"
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
action={{
label: "重试",
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="space-y-8 p-6 md:p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<div className="space-y-8">
{Array.from({ length: 2 }).map((_, i) => (
<Card key={i}>
<CardHeader>
<CardTitle className="text-lg">
<Skeleton className="h-5 w-32" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{Array.from({ length: 4 }).map((_, j) => (
<Skeleton key={j} className="h-20 w-full" />
))}
</div>
<Skeleton className="h-40 w-full" />
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -1,16 +1,27 @@
import { getAuthContext } from "@/shared/lib/auth-guard"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getStudentGradeSummary } from "@/modules/grades/data-access"
import { getClassAverageTrend } from "@/modules/grades/data-access-ranking"
import { StudentGradeSummary } from "@/modules/grades/components/student-grade-summary"
import { GradeTrendCard } from "@/modules/grades/components/grade-trend-card"
import {
ParentChildrenDataPage,
ParentNoChildrenPage,
} from "@/modules/parent/components/parent-children-data-page"
import { ParentExportButton } from "@/modules/parent/components/parent-export-button"
import { GraduationCap } from "lucide-react"
import type { ClassAverageTrendResult } from "@/modules/grades/types"
export const dynamic = "force-dynamic"
interface ChildGradeItem {
studentId: string
summary: NonNullable<Awaited<ReturnType<typeof getStudentGradeSummary>>>
classAverageTrend: ClassAverageTrendResult | null
}
export default async function ParentGradesPage() {
const ctx = await getAuthContext()
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) {
return (
@@ -26,26 +37,49 @@ export default async function ParentGradesPage() {
// 使用 allSettled 容错:单个子女查询失败不影响其他子女展示
const results = await Promise.allSettled(
ctx.dataScope.childrenIds.map((id) => getStudentGradeSummary(id)),
ctx.dataScope.childrenIds.map(async (id) => {
const [summary, classAverageTrend] = await Promise.all([
getStudentGradeSummary(id, ctx.dataScope),
// v3-P2-8家长页面补齐趋势图复用班级平均对比线
getClassAverageTrend(id, undefined, undefined, ctx.dataScope),
])
return { summary, classAverageTrend, studentId: id }
}),
)
const validSummaries = results
const validItems: ChildGradeItem[] = results
.filter(
(r): r is PromiseFulfilledResult<NonNullable<Awaited<ReturnType<typeof getStudentGradeSummary>>>> =>
r.status === "fulfilled" && r.value !== null,
(
r,
): r is PromiseFulfilledResult<{
summary: Awaited<ReturnType<typeof getStudentGradeSummary>>
classAverageTrend: ClassAverageTrendResult | null
studentId: string
}> => r.status === "fulfilled" && r.value.summary !== null,
)
.map((r) => r.value)
.map((r) => ({
studentId: r.value.studentId,
summary: r.value.summary as NonNullable<typeof r.value.summary>,
classAverageTrend: r.value.classAverageTrend,
}))
return (
<ParentChildrenDataPage
title="Children Grades"
description="View your children's grade records."
description="Compare grades across all your children. For single-child analysis, open the child's detail page."
icon={GraduationCap}
noRecordsTitle="No grade records"
noRecordsDescription="Your children don't have any grade records yet."
items={validSummaries}
renderItem={(summary) => (
items={validItems}
renderItem={({ studentId, summary, classAverageTrend }) => (
<>
<h3 className="text-lg font-semibold border-b pb-2">{summary.studentName}</h3>
<div className="flex items-center justify-between border-b pb-2">
<h3 className="text-lg font-semibold">{summary.studentName}</h3>
{/* v4-P1-12: 接入 exportGradesAction支持按 studentId 导出 */}
<ParentExportButton studentId={studentId} studentName={summary.studentName} />
</div>
{summary.records.length > 0 && (
<GradeTrendCard summary={summary} classAverageData={classAverageTrend} />
)}
<StudentGradeSummary summary={summary} />
</>
)}

View File

@@ -0,0 +1,24 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="p-6 md:p-8 space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">
<Skeleton className="h-4 w-40" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,66 @@
import Link from "next/link"
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"
export const dynamic = "force-dynamic"
export default async function ParentLeavePage() {
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>
<p className="text-sm text-muted-foreground">
Submit a leave request for your child.
</p>
</div>
<Button asChild variant="ghost" size="sm" className="gap-2 -ml-2">
<Link href="/parent/dashboard" aria-label="Back to Dashboard">
<ArrowLeft className="h-4 w-4" />
Back to Dashboard
</Link>
</Button>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CalendarDays className="h-4 w-4 text-muted-foreground" aria-hidden />
Online Leave Request
</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."
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>
<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>
</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>
</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
</Link>
</li>
</ul>
</div>
</CardContent>
</Card>
</div>
)
}