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

@@ -5,8 +5,7 @@ import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getAnnouncementById } from "@/modules/announcements/data-access"
import { getGrades } from "@/modules/school/data-access"
import { getEditAnnouncementPageData } from "@/modules/announcements/data-access"
import { AnnouncementForm } from "@/modules/announcements/components/announcement-form"
export const dynamic = "force-dynamic"
@@ -28,10 +27,7 @@ export default async function EditAnnouncementPage({
const { id } = await params
const t = await getTranslations("announcements")
const [announcement, grades] = await Promise.all([
getAnnouncementById(id),
getGrades(),
])
const { announcement, grades } = await getEditAnnouncementPageData(id)
if (!announcement) notFound()
@@ -44,7 +40,7 @@ export default async function EditAnnouncementPage({
<AnnouncementForm
mode="edit"
announcement={announcement}
grades={grades.map((g) => ({ id: g.id, name: g.name }))}
grades={grades}
/>
</div>
)

View File

@@ -4,9 +4,7 @@ import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getAnnouncements } from "@/modules/announcements/data-access"
import { getGrades } from "@/modules/school/data-access"
import { getAdminClasses } from "@/modules/classes/data-access"
import { getAdminAnnouncementsPageData } from "@/modules/announcements/data-access"
import { AdminAnnouncementsView } from "@/modules/announcements/components/admin-announcements-view"
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
import type { AnnouncementStatus } from "@/modules/announcements/types"
@@ -34,17 +32,13 @@ export default async function AdminAnnouncementsPage({
const statusParam = getSearchParam(sp, "status")
const status = isValidStatus(statusParam) ? statusParam : undefined
const [announcements, grades, classes] = await Promise.all([
getAnnouncements({ status }),
getGrades(),
getAdminClasses(),
])
const { announcements, grades, classes } = await getAdminAnnouncementsPageData(status)
return (
<AdminAnnouncementsView
announcements={announcements}
grades={grades.map((g) => ({ id: g.id, name: g.name }))}
classes={classes.map((c) => ({ id: c.id, name: c.name }))}
grades={grades}
classes={classes}
initialStatus={status}
/>
)

View File

@@ -0,0 +1,7 @@
"use client"
import { DashboardErrorFallback } from "@/modules/dashboard/components/dashboard-error-fallback"
export default function AdminDashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
return <DashboardErrorFallback error={error} reset={reset} />
}

View File

@@ -0,0 +1,5 @@
import { DashboardLoadingSkeleton } from "@/modules/dashboard/components/dashboard-loading-skeleton"
export default function AdminDashboardLoading() {
return <DashboardLoadingSkeleton />
}

View File

@@ -1,18 +1,20 @@
"use client"
import { AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function AdminError({ 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="页面加载失败"
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
title={t("error.loadFailed")}
description={t("error.loadFailedDesc")}
action={{
label: "重试",
label: t("error.retry"),
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"

View File

@@ -0,0 +1,27 @@
"use client"
import { AlertCircle } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function AdminGradesInsightsError({
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,22 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function AdminGradesInsightsLoading() {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="space-y-2">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-96" />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-32" />
))}
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{Array.from({ length: 2 }).map((_, i) => (
<Skeleton key={i} className="h-80" />
))}
</div>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import Link from "next/link"
import Link from "next/link"
import type { Metadata } from "next"
import type { JSX } from "react"
import { BarChart3 } from "lucide-react"
@@ -7,13 +7,16 @@ import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getGrades } from "@/modules/school/data-access"
import { getGradeHomeworkInsights } from "@/modules/classes/data-access"
import { getSchoolWideGradeSummary } from "@/modules/grades/data-access-analytics"
import { SchoolWideSummaryCard } from "@/modules/grades/components/school-wide-summary-card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { StatCard } from "@/shared/components/ui/stat-card"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatDate, formatNumber, getSearchParam, type SearchParams } from "@/shared/lib/utils"
import { formatDate, formatNumber } from "@/shared/lib/utils"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
export const metadata: Metadata = {
title: "年级作业洞察 - Next_Edu",
@@ -27,15 +30,17 @@ export default async function AdminGradeInsightsPage({
}: {
searchParams: Promise<SearchParams>
}): Promise<JSX.Element> {
await requirePermission(Permissions.SCHOOL_MANAGE)
const ctx = await requirePermission(Permissions.SCHOOL_MANAGE)
const params = await searchParams
const gradeId = getSearchParam(params, "gradeId")
const gradeId = getParam(params, "gradeId")
const selected = gradeId && gradeId !== "all" ? gradeId : ""
// gradesinsights 无数据依赖,并行查询
const [grades, insights] = await Promise.all([
// gradesinsights、全校汇总无数据依赖,并行查询
const [grades, insights, schoolWideSummary] = await Promise.all([
getGrades(),
selected ? getGradeHomeworkInsights({ gradeId: selected, limit: 50 }) : Promise.resolve(null),
// v3-P2-9管理员全校成绩汇总视图
getSchoolWideGradeSummary(ctx.dataScope),
])
return (
@@ -50,6 +55,11 @@ export default async function AdminGradeInsightsPage({
</Button>
</div>
{/* v3-P2-9: 全校成绩汇总视图 */}
{schoolWideSummary.grades.length > 0 && (
<SchoolWideSummaryCard summary={schoolWideSummary} />
)}
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base"></CardTitle>

View File

@@ -3,5 +3,5 @@ import { redirect } from "next/navigation"
export const dynamic = "force-dynamic"
export default function AdminSchoolPage(): never {
redirect("/admin/school/classes")
redirect("/admin/school/schools")
}