Files
NextEdu/src/modules/dashboard/components/dashboard-section.tsx
SpecialX 138b6f1b00 feat(dashboard,diagnostic,elective): add widgets, layout, parent dashboard, role-config, services, elective components
dashboard:

- Add comparison-badge, dashboard-notification-widget, dashboard-responsive-layout, dashboard-time-range-filter

- Add parent-dashboard components directory

- Add config, hooks, and services directories

diagnostic:

- Add role-config and services directory

elective:

- Add elective-course-detail, elective-stats-cards, parent-selection-view components

- Add data-access-settings and data-access-stats
2026-07-03 10:25:46 +08:00

157 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { 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"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
/**
* 分区骨架屏变体
*
* 不同数据区块使用不同骨架布局,提供更贴近真实内容的加载占位。
*/
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 + 骨架屏,包裹每个独立数据区块。
* 单个区块出错或加载中时,仅影响该区块,不波及整页。
*
* 使用共享 SectionErrorBoundary 替代模块特定的 DashboardSectionErrorBoundary 类。
*
* V4P3-1新增 `ariaLabel` prop传入时渲染 `<section role="region" tabIndex={0}>`
* 使键盘用户可按逻辑顺序遍历各 Widget提升 a11y。
*
* @example
* <DashboardSection variant="stats" ariaLabel={t("sections.userStats")}>
* <TeacherStats ... />
* </DashboardSection>
*/
export function DashboardSection({
children,
variant = "card",
ariaLabel,
}: {
children: ReactNode
variant?: SkeletonVariant
/** 传入时渲染为可聚焦的 region提升键盘导航 a11y */
ariaLabel?: string
}): ReactNode {
const t = useTranslations("dashboard.error")
const fallback = (): ReactNode => (
<EmptyState
icon={AlertCircle}
title={t("sectionLoadFailed")}
description={t("sectionLoadFailedDesc")}
action={{ label: t("retry"), onClick: () => window.location.reload() }}
className="h-auto border-none shadow-none"
/>
)
const content = (
<SectionErrorBoundary namespace="common" fallback={fallback}>
<Suspense fallback={<DashboardSectionSkeleton variant={variant} />}>
{children}
</Suspense>
</SectionErrorBoundary>
)
if (ariaLabel) {
return (
<section role="region" aria-label={ariaLabel} tabIndex={0} className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-lg">
{content}
</section>
)
}
return content
}