新增 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 文档
171 lines
4.6 KiB
TypeScript
171 lines
4.6 KiB
TypeScript
"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>
|
|
)
|
|
}
|