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
157 lines
4.6 KiB
TypeScript
157 lines
4.6 KiB
TypeScript
"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 类。
|
||
*
|
||
* V4(P3-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
|
||
}
|