feat(dashboard): 新增分区 Error Boundary + Suspense 骨架屏(P2)

新增 components/dashboard-section.tsx,包含:

- DashboardSectionErrorBoundary:分区级 Error Boundary,单区块崩溃仅替换该区块不波及整页

- DashboardSectionSkeleton:5 种骨架变体(stats/card/chart/table/list),匹配不同数据区块布局

- DashboardSection:组合 Error Boundary + Suspense + 骨架屏的包装器

将 admin/teacher/student 三个仪表盘视图的每个独立数据区块用 DashboardSection 包裹,i18n 补充 sectionLoadFailed/sectionLoadFailedDesc 翻译键,同步更新架构图 004/005 文档
This commit is contained in:
SpecialX
2026-06-22 15:58:49 +08:00
parent 868ac5f9cf
commit 21c1e7a286
8 changed files with 454 additions and 167 deletions

View File

@@ -26,6 +26,7 @@ import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
import { DashboardSection } from "../dashboard-section"
import { UserGrowthChart } from "./user-growth-chart"
export async function AdminDashboardView({ data }: { data: AdminDashboardData }) {
@@ -62,12 +63,14 @@ export async function AdminDashboardView({ data }: { data: AdminDashboardData })
}
/>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard title={t("stats.users")} value={data.userCount} icon={Users} valueClassName="tabular-nums" />
<StatCard title={t("stats.classes")} value={data.classCount} icon={LayoutDashboard} valueClassName="tabular-nums" />
<StatCard title={t("stats.homeworkPublished")} value={data.homeworkAssignmentPublishedCount} icon={ClipboardList} valueClassName="tabular-nums" />
<StatCard title={t("stats.toGrade")} value={data.homeworkSubmissionToGradeCount} icon={FileText} valueClassName="tabular-nums" />
</div>
<DashboardSection variant="stats">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard title={t("stats.users")} value={data.userCount} icon={Users} valueClassName="tabular-nums" />
<StatCard title={t("stats.classes")} value={data.classCount} icon={LayoutDashboard} valueClassName="tabular-nums" />
<StatCard title={t("stats.homeworkPublished")} value={data.homeworkAssignmentPublishedCount} icon={ClipboardList} valueClassName="tabular-nums" />
<StatCard title={t("stats.toGrade")} value={data.homeworkSubmissionToGradeCount} icon={FileText} valueClassName="tabular-nums" />
</div>
</DashboardSection>
{/* 快捷操作 */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
@@ -111,108 +114,120 @@ export async function AdminDashboardView({ data }: { data: AdminDashboardData })
{/* 趋势图表 */}
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">{t("sections.userGrowthTrend")}</CardTitle>
</CardHeader>
<CardContent>
<UserGrowthChart data={data.userGrowth} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t("sections.homeworkSubmissionTrend")}</CardTitle>
</CardHeader>
<CardContent>
<UserGrowthChart data={data.homeworkTrend} />
</CardContent>
</Card>
<DashboardSection variant="chart">
<Card>
<CardHeader>
<CardTitle className="text-base">{t("sections.userGrowthTrend")}</CardTitle>
</CardHeader>
<CardContent>
<UserGrowthChart data={data.userGrowth} />
</CardContent>
</Card>
</DashboardSection>
<DashboardSection variant="chart">
<Card>
<CardHeader>
<CardTitle className="text-base">{t("sections.homeworkSubmissionTrend")}</CardTitle>
</CardHeader>
<CardContent>
<UserGrowthChart data={data.homeworkTrend} />
</CardContent>
</Card>
</DashboardSection>
</div>
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle>{t("sections.userRoles")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{data.userRoleCounts.length === 0 ? (
<EmptyState title={t("empty.noUsers")} description={t("empty.noUsersDesc")} />
) : (
data.userRoleCounts.map((r) => (
<div key={r.role} className="flex items-center justify-between">
<Badge variant="secondary">{r.role}</Badge>
<div className="text-sm font-medium tabular-nums">{r.count}</div>
</div>
))
)}
</CardContent>
</Card>
<DashboardSection variant="card">
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle>{t("sections.userRoles")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{data.userRoleCounts.length === 0 ? (
<EmptyState title={t("empty.noUsers")} description={t("empty.noUsersDesc")} />
) : (
data.userRoleCounts.map((r) => (
<div key={r.role} className="flex items-center justify-between">
<Badge variant="secondary">{r.role}</Badge>
<div className="text-sm font-medium tabular-nums">{r.count}</div>
</div>
))
)}
</CardContent>
</Card>
</DashboardSection>
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle>{t("sections.content")}</CardTitle>
</CardHeader>
<CardContent className="grid gap-3">
<ContentRow label={t("stats.users")} value={data.textbookCount} icon={<Library className="h-4 w-4 text-muted-foreground" />} />
<ContentRow label={t("stats.classes")} value={data.chapterCount} icon={<BookOpen className="h-4 w-4 text-muted-foreground" />} />
<ContentRow label={t("stats.toGrade")} value={data.questionCount} icon={<FileText className="h-4 w-4 text-muted-foreground" />} />
<ContentRow label={t("stats.homeworkPublished")} value={data.examCount} icon={<ClipboardList className="h-4 w-4 text-muted-foreground" />} />
</CardContent>
</Card>
<DashboardSection variant="card">
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle>{t("sections.content")}</CardTitle>
</CardHeader>
<CardContent className="grid gap-3">
<ContentRow label={t("stats.users")} value={data.textbookCount} icon={<Library className="h-4 w-4 text-muted-foreground" />} />
<ContentRow label={t("stats.classes")} value={data.chapterCount} icon={<BookOpen className="h-4 w-4 text-muted-foreground" />} />
<ContentRow label={t("stats.toGrade")} value={data.questionCount} icon={<FileText className="h-4 w-4 text-muted-foreground" />} />
<ContentRow label={t("stats.homeworkPublished")} value={data.examCount} icon={<ClipboardList className="h-4 w-4 text-muted-foreground" />} />
</CardContent>
</Card>
</DashboardSection>
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle>{t("sections.homeworkActivity")}</CardTitle>
</CardHeader>
<CardContent className="grid gap-3">
<ContentRow label={t("stats.activeAssignments")} value={data.homeworkAssignmentCount} icon={<ClipboardList className="h-4 w-4 text-muted-foreground" />} />
<ContentRow label={t("stats.submissionRate")} value={data.homeworkSubmissionCount} icon={<FileText className="h-4 w-4 text-muted-foreground" />} />
<ContentRow label={t("stats.toGrade")} value={data.homeworkSubmissionToGradeCount} icon={<Activity className="h-4 w-4 text-muted-foreground" />} />
</CardContent>
</Card>
<DashboardSection variant="card">
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle>{t("sections.homeworkActivity")}</CardTitle>
</CardHeader>
<CardContent className="grid gap-3">
<ContentRow label={t("stats.activeAssignments")} value={data.homeworkAssignmentCount} icon={<ClipboardList className="h-4 w-4 text-muted-foreground" />} />
<ContentRow label={t("stats.submissionRate")} value={data.homeworkSubmissionCount} icon={<FileText className="h-4 w-4 text-muted-foreground" />} />
<ContentRow label={t("stats.toGrade")} value={data.homeworkSubmissionToGradeCount} icon={<Activity className="h-4 w-4 text-muted-foreground" />} />
</CardContent>
</Card>
</DashboardSection>
</div>
<Card>
<CardHeader>
<CardTitle>{t("sections.recentUsers")}</CardTitle>
</CardHeader>
<CardContent>
{data.recentUsers.length === 0 ? (
<EmptyState title={t("empty.noUsersYet")} description={t("empty.seedHint")} />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("table.name")}</TableHead>
<TableHead>{t("table.email")}</TableHead>
<TableHead>{t("table.role")}</TableHead>
<TableHead>{t("table.created")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.recentUsers.map((u) => (
<TableRow key={u.id}>
<TableCell className="font-medium">{u.name || "-"}</TableCell>
<TableCell className="text-muted-foreground">{u.email}</TableCell>
<TableCell>
<Badge variant="secondary">{u.role ?? "unknown"}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{formatDate(u.createdAt)}</TableCell>
<DashboardSection variant="table">
<Card>
<CardHeader>
<CardTitle>{t("sections.recentUsers")}</CardTitle>
</CardHeader>
<CardContent>
{data.recentUsers.length === 0 ? (
<EmptyState title={t("empty.noUsersYet")} description={t("empty.seedHint")} />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("table.name")}</TableHead>
<TableHead>{t("table.email")}</TableHead>
<TableHead>{t("table.role")}</TableHead>
<TableHead>{t("table.created")}</TableHead>
</TableRow>
))}
</TableBody>
</Table>
)}
<div className="flex justify-end pt-4">
<Button asChild variant="ghost" size="sm">
<Link href="/admin/users">
{t("sections.viewAllUsers")}
<ChevronRight className="ml-1 h-4 w-4" />
</Link>
</Button>
</div>
</CardContent>
</Card>
</TableHeader>
<TableBody>
{data.recentUsers.map((u) => (
<TableRow key={u.id}>
<TableCell className="font-medium">{u.name || "-"}</TableCell>
<TableCell className="text-muted-foreground">{u.email}</TableCell>
<TableCell>
<Badge variant="secondary">{u.role ?? "unknown"}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{formatDate(u.createdAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<div className="flex justify-end pt-4">
<Button asChild variant="ghost" size="sm">
<Link href="/admin/users">
{t("sections.viewAllUsers")}
<ChevronRight className="ml-1 h-4 w-4" />
</Link>
</Button>
</div>
</CardContent>
</Card>
</DashboardSection>
</div>
)
}

View File

@@ -0,0 +1,170 @@
"use client"
import { Component, type ReactNode, Suspense } from "react"
import { AlertCircle } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { useTranslations } from "next-intl"
/**
* 仪表盘分区 Error Boundary
*
* 包裹每个独立数据区块,避免单个区块崩溃导致整页不可用。
* 与路由级 error.tsx 不同,此组件仅替换出错区块,其余区块继续渲染。
*/
export class DashboardSectionErrorBoundary extends Component<
{ children: ReactNode },
{ hasError: boolean }
> {
state: { hasError: boolean } = { hasError: false }
static getDerivedStateFromError(): { hasError: boolean } {
return { hasError: true }
}
handleRetry = (): void => {
this.setState({ hasError: false })
}
render(): ReactNode {
if (this.state.hasError) {
return <DashboardSectionErrorFallback onRetry={this.handleRetry} />
}
return this.props.children
}
}
function DashboardSectionErrorFallback({
onRetry,
}: {
onRetry: () => void
}): ReactNode {
const t = useTranslations("dashboard.error")
return (
<EmptyState
icon={AlertCircle}
title={t("sectionLoadFailed")}
description={t("sectionLoadFailedDesc")}
action={{ label: t("retry"), onClick: onRetry }}
className="h-auto border-none shadow-none"
/>
)
}
/**
* 分区骨架屏变体
*
* 不同数据区块使用不同骨架布局,提供更贴近真实内容的加载占位。
*/
type SkeletonVariant = "stats" | "card" | "chart" | "table" | "list"
export function DashboardSectionSkeleton({
variant = "card",
}: {
variant?: SkeletonVariant
}): ReactNode {
switch (variant) {
case "stats":
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardHeader className="pb-2">
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
<Skeleton className="mt-2 h-3 w-28" />
</CardContent>
</Card>
))}
</div>
)
case "chart":
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-[200px] w-full" />
</CardContent>
</Card>
)
case "table":
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
)
case "list":
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="h-9 w-9 rounded-full" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</CardContent>
</Card>
)
case "card":
default:
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
</CardContent>
</Card>
)
}
}
/**
* 仪表盘分区包装器
*
* 组合 Error Boundary + Suspense + 骨架屏,包裹每个独立数据区块。
* 单个区块出错或加载中时,仅影响该区块,不波及整页。
*
* @example
* <DashboardSection variant="stats">
* <TeacherStats ... />
* </DashboardSection>
*/
export function DashboardSection({
children,
variant = "card",
}: {
children: ReactNode
variant?: SkeletonVariant
}): ReactNode {
return (
<DashboardSectionErrorBoundary>
<Suspense fallback={<DashboardSectionSkeleton variant={variant} />}>
{children}
</Suspense>
</DashboardSectionErrorBoundary>
)
}

View File

@@ -1,5 +1,6 @@
import type { StudentDashboardProps } from "@/modules/dashboard/types"
import { DashboardSection } from "../dashboard-section"
import { StudentDashboardHeader } from "./student-dashboard-header"
import { StudentGradesCard } from "./student-grades-card"
import { StudentStatsGrid } from "./student-stats-grid"
@@ -20,21 +21,29 @@ export async function StudentDashboard({
<div className="space-y-6">
<StudentDashboardHeader studentName={studentName} />
<StudentStatsGrid
enrolledClassCount={enrolledClassCount}
dueSoonCount={dueSoonCount}
overdueCount={overdueCount}
gradedCount={gradedCount}
ranking={grades.ranking}
/>
<DashboardSection variant="stats">
<StudentStatsGrid
enrolledClassCount={enrolledClassCount}
dueSoonCount={dueSoonCount}
overdueCount={overdueCount}
gradedCount={gradedCount}
ranking={grades.ranking}
/>
</DashboardSection>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<StudentUpcomingAssignmentsCard upcomingAssignments={upcomingAssignments} />
<StudentGradesCard grades={grades} />
<DashboardSection variant="list">
<StudentUpcomingAssignmentsCard upcomingAssignments={upcomingAssignments} />
</DashboardSection>
<DashboardSection variant="card">
<StudentGradesCard grades={grades} />
</DashboardSection>
</div>
<div className="space-y-6">
<StudentTodayScheduleCard items={todayScheduleItems} />
<DashboardSection variant="card">
<StudentTodayScheduleCard items={todayScheduleItems} />
</DashboardSection>
</div>
</div>
</div>

View File

@@ -3,6 +3,7 @@ import type { TeacherDashboardMetrics } from "@/modules/dashboard/lib/dashboard-
import { getTranslations } from "next-intl/server"
import type { TeacherTodoItem } from "./teacher-todo-card"
import { DashboardSection } from "../dashboard-section"
import { TeacherClassesCard } from "./teacher-classes-card"
import { TeacherDashboardHeader } from "./teacher-dashboard-header"
import { TeacherHomeworkCard } from "./teacher-homework-card"
@@ -46,35 +47,51 @@ export async function TeacherDashboardView({ data }: TeacherDashboardViewProps)
<div className="flex h-full flex-col space-y-6 p-8">
<TeacherDashboardHeader teacherName={data.teacherName} />
<TeacherStats
toGradeCount={metrics.toGradeCount}
activeAssignmentsCount={metrics.activeAssignmentsCount}
averageScore={metrics.averageScore}
submissionRate={metrics.submissionRate}
/>
<DashboardSection variant="stats">
<TeacherStats
toGradeCount={metrics.toGradeCount}
activeAssignmentsCount={metrics.activeAssignmentsCount}
averageScore={metrics.averageScore}
submissionRate={metrics.submissionRate}
/>
</DashboardSection>
<div className="grid gap-6 lg:grid-cols-12">
{/* 移动端优先展示:今日课表 → 待办 → 待批改 */}
<div className="flex flex-col gap-6 lg:col-span-8">
<div className="lg:hidden">
<TeacherSchedule items={metrics.todayScheduleItems} />
<DashboardSection variant="card">
<TeacherSchedule items={metrics.todayScheduleItems} />
</DashboardSection>
</div>
<TeacherTodoCard items={todoItems} />
<TeacherGradeTrends trends={data.gradeTrends} />
<RecentSubmissions
submissions={metrics.submissionsToGrade}
title={t("sections.pendingGrading")}
emptyTitle={t("empty.allGraded")}
emptyDescription={t("empty.allGradedDesc")}
/>
<DashboardSection variant="card">
<TeacherTodoCard items={todoItems} />
</DashboardSection>
<DashboardSection variant="chart">
<TeacherGradeTrends trends={data.gradeTrends} />
</DashboardSection>
<DashboardSection variant="list">
<RecentSubmissions
submissions={metrics.submissionsToGrade}
title={t("sections.pendingGrading")}
emptyTitle={t("empty.allGraded")}
emptyDescription={t("empty.allGradedDesc")}
/>
</DashboardSection>
</div>
<div className="flex flex-col gap-6 lg:col-span-4">
<div className="hidden lg:block">
<TeacherSchedule items={metrics.todayScheduleItems} />
<DashboardSection variant="card">
<TeacherSchedule items={metrics.todayScheduleItems} />
</DashboardSection>
</div>
<TeacherHomeworkCard assignments={data.assignments} />
<TeacherClassesCard classes={data.classes} />
<DashboardSection variant="list">
<TeacherHomeworkCard assignments={data.assignments} />
</DashboardSection>
<DashboardSection variant="list">
<TeacherClassesCard classes={data.classes} />
</DashboardSection>
</div>
</div>
</div>

View File

@@ -115,7 +115,9 @@
"error": {
"loadFailed": "Page load failed",
"loadFailedDesc": "Sorry, an unexpected error occurred while loading the page. Please try again later.",
"retry": "Retry"
"retry": "Retry",
"sectionLoadFailed": "Section load failed",
"sectionLoadFailedDesc": "An error occurred while loading this section. Please retry."
},
"chart": {
"newUsers": "New users",

View File

@@ -115,7 +115,9 @@
"error": {
"loadFailed": "页面加载失败",
"loadFailedDesc": "抱歉,页面加载时发生了意外错误。请稍后重试。",
"retry": "重试"
"retry": "重试",
"sectionLoadFailed": "区块加载失败",
"sectionLoadFailedDesc": "该数据区块加载时出错,请重试。"
},
"chart": {
"newUsers": "新增用户",