feat(shared): 组件化重构 Phase 1 - 底座收敛与补全
- 新增 ErrorBoundary 基础类组件 (ui/error-boundary.tsx) - 新增 StatsGrid 容器 (ui/stats-grid.tsx) - 扩展 Skeleton 新增 SkeletonCard variant - 收敛 SectionErrorBoundary/RouteErrorBoundary/WidgetBoundary 为 ErrorBoundary preset
This commit is contained in:
@@ -1,14 +1,18 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import type { ReactNode } from "react"
|
||||||
import { AlertCircle } from "lucide-react"
|
import { AlertCircle } from "lucide-react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { ErrorBoundary } from "@/shared/components/ui/error-boundary"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通用路由错误边界组件。
|
* 通用路由错误边界组件 preset。
|
||||||
* 供各模块 error.tsx 复用,消除重复代码(P1-10 重构)。
|
* 供各模块 error.tsx 复用,消除重复代码(P1-10 重构)。
|
||||||
*
|
*
|
||||||
|
* 基于 `ErrorBoundary` 基础组件,渲染路由级错误 UI(非包裹式,作为 error.tsx 的渲染组件)。
|
||||||
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```tsx
|
* ```tsx
|
||||||
* // app/(dashboard)/admin/attendance/error.tsx
|
* // app/(dashboard)/admin/attendance/error.tsx
|
||||||
@@ -25,20 +29,26 @@ export function RouteErrorBoundary({
|
|||||||
}: {
|
}: {
|
||||||
reset: () => void
|
reset: () => void
|
||||||
namespace?: string
|
namespace?: string
|
||||||
}) {
|
}): ReactNode {
|
||||||
const t = useTranslations(namespace)
|
const t = useTranslations(namespace)
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
<ErrorBoundary
|
||||||
<EmptyState
|
fallback={() => (
|
||||||
icon={AlertCircle}
|
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||||
title={t("errors.unexpected")}
|
<EmptyState
|
||||||
description={t("errors.unexpected")}
|
icon={AlertCircle}
|
||||||
action={{
|
title={t("errors.unexpected")}
|
||||||
label: t("actions.retry"),
|
description={t("errors.unexpected")}
|
||||||
onClick: () => reset(),
|
action={{
|
||||||
}}
|
label: t("actions.retry"),
|
||||||
className="border-none shadow-none h-auto"
|
onClick: () => reset(),
|
||||||
/>
|
}}
|
||||||
</div>
|
className="border-none shadow-none h-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{null}
|
||||||
|
</ErrorBoundary>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,95 +1,12 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
import { Component, type ErrorInfo, type ReactNode } from "react"
|
import type { ReactNode, ErrorInfo } from "react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { AlertCircle, RefreshCw } from "lucide-react"
|
import { AlertCircle, RefreshCw } from "lucide-react"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { ErrorBoundary } from "@/shared/components/ui/error-boundary"
|
||||||
interface BoundaryProps {
|
|
||||||
children: ReactNode
|
|
||||||
/** 自定义降级 UI(函数形式,优先级最高)。若提供则忽略 title/description/retryLabel */
|
|
||||||
fallback?: (error: Error, reset: () => void) => ReactNode
|
|
||||||
/** 渲染失败时的标题(已国际化,默认取 {namespace}.error.boundaryTitle) */
|
|
||||||
title?: string
|
|
||||||
/** 渲染失败时的描述(已国际化,默认取 {namespace}.error.boundaryDescription) */
|
|
||||||
description?: string
|
|
||||||
/** 重试按钮文案(已国际化,默认取 {namespace}.error.retry) */
|
|
||||||
retryLabel?: string
|
|
||||||
/** 错误回调(用于埋点/监控) */
|
|
||||||
onError?: (error: Error, info: ErrorInfo) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
hasError: boolean
|
|
||||||
error: Error | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 组件级 Error Boundary(类组件实现,承载错误捕获能力)。
|
|
||||||
* 不直接使用,请使用默认导出的函数式 `SectionErrorBoundary` 包装器以获得 i18n 支持。
|
|
||||||
*
|
|
||||||
* 支持两种降级形式:
|
|
||||||
* 1. `fallback`(函数 `(error, reset) => ReactNode`)— 完全自定义降级 UI(优先级最高)
|
|
||||||
* 2. `title` / `description` / `retryLabel` — 默认降级 UI + i18n 文案
|
|
||||||
*/
|
|
||||||
class SectionErrorBoundaryBase extends Component<BoundaryProps, State> {
|
|
||||||
constructor(props: BoundaryProps) {
|
|
||||||
super(props)
|
|
||||||
this.state = { hasError: false, error: null }
|
|
||||||
}
|
|
||||||
|
|
||||||
static getDerivedStateFromError(error: Error): State {
|
|
||||||
return { hasError: true, error }
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
|
||||||
console.error("[SectionErrorBoundary]", error, errorInfo)
|
|
||||||
this.props.onError?.(error, errorInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleRetry = (): void => {
|
|
||||||
this.setState({ hasError: false, error: null })
|
|
||||||
}
|
|
||||||
|
|
||||||
render(): ReactNode {
|
|
||||||
if (this.state.hasError && this.state.error) {
|
|
||||||
// 优先使用自定义 fallback
|
|
||||||
if (this.props.fallback) {
|
|
||||||
return this.props.fallback(this.state.error, this.handleRetry)
|
|
||||||
}
|
|
||||||
// 默认降级 UI
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
aria-live="assertive"
|
|
||||||
className="flex flex-col items-center justify-center space-y-3 rounded-lg border border-dashed p-8 text-center"
|
|
||||||
>
|
|
||||||
<AlertCircle className="h-8 w-8 text-muted-foreground" aria-hidden="true" />
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-medium">
|
|
||||||
{this.props.title ?? "加载失败"}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{this.props.description ?? "数据加载时发生错误,请重试。"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={this.handleRetry}
|
|
||||||
aria-label={this.props.retryLabel ?? "重试"}
|
|
||||||
>
|
|
||||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
|
|
||||||
{this.props.retryLabel ?? "重试"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return this.props.children
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SectionErrorBoundaryProps {
|
interface SectionErrorBoundaryProps {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
@@ -108,9 +25,11 @@ interface SectionErrorBoundaryProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 组件级 Error Boundary:包裹独立数据区块,防止单区块错误导致整页崩溃。
|
* 组件级 Error Boundary preset:包裹独立数据区块,防止单区块错误导致整页崩溃。
|
||||||
* 自动使用 next-intl 提供本地化文案;如需完全自定义可传入 `fallback` 函数。
|
* 自动使用 next-intl 提供本地化文案;如需完全自定义可传入 `fallback` 函数。
|
||||||
*
|
*
|
||||||
|
* 基于基础 `ErrorBoundary` 类组件实现,仅负责 i18n 文案解析与默认降级 UI。
|
||||||
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```tsx
|
* ```tsx
|
||||||
* // 默认 i18n 文案
|
* // 默认 i18n 文案
|
||||||
@@ -134,15 +53,36 @@ export function SectionErrorBoundary({
|
|||||||
onError,
|
onError,
|
||||||
}: SectionErrorBoundaryProps): JSX.Element {
|
}: SectionErrorBoundaryProps): JSX.Element {
|
||||||
const t = useTranslations(namespace)
|
const t = useTranslations(namespace)
|
||||||
return (
|
const effectiveTitle = title ?? t("error.boundaryTitle")
|
||||||
<SectionErrorBoundaryBase
|
const effectiveDescription = description ?? t("error.boundaryDescription")
|
||||||
fallback={fallback}
|
const effectiveRetryLabel = retryLabel ?? t("error.retry")
|
||||||
title={title ?? t("error.boundaryTitle")}
|
|
||||||
description={description ?? t("error.boundaryDescription")}
|
const defaultFallback = (_error: Error, reset: () => void): ReactNode => (
|
||||||
retryLabel={retryLabel ?? t("error.retry")}
|
<div
|
||||||
onError={onError}
|
role="alert"
|
||||||
|
aria-live="assertive"
|
||||||
|
className="flex flex-col items-center justify-center space-y-3 rounded-lg border border-dashed p-8 text-center"
|
||||||
>
|
>
|
||||||
|
<AlertCircle className="h-8 w-8 text-muted-foreground" aria-hidden="true" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium">{effectiveTitle}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{effectiveDescription}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={reset}
|
||||||
|
aria-label={effectiveRetryLabel}
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
{effectiveRetryLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary fallback={fallback ?? defaultFallback} onError={onError}>
|
||||||
{children}
|
{children}
|
||||||
</SectionErrorBoundaryBase>
|
</ErrorBoundary>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
58
src/shared/components/ui/error-boundary.tsx
Normal file
58
src/shared/components/ui/error-boundary.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Component, type ErrorInfo, type ReactNode } from "react"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基础 ErrorBoundary 类组件。
|
||||||
|
* 不直接使用,请使用 SectionErrorBoundary / RouteErrorBoundary / WidgetBoundary 等 preset。
|
||||||
|
*
|
||||||
|
* 支持两种降级形式:
|
||||||
|
* 1. `fallback`(函数 `(error, reset) => ReactNode`)— 完全自定义降级 UI(优先级最高)
|
||||||
|
* 2. `fallback`(ReactNode)— 静态降级 UI
|
||||||
|
*/
|
||||||
|
interface ErrorBoundaryProps {
|
||||||
|
children: ReactNode
|
||||||
|
/** 自定义降级 UI:可为静态 ReactNode 或函数式 (error, reset) => ReactNode */
|
||||||
|
fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode)
|
||||||
|
/** 错误回调(用于埋点/监控) */
|
||||||
|
onError?: (error: Error, info: ErrorInfo) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBoundaryState {
|
||||||
|
hasError: boolean
|
||||||
|
error: Error | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
constructor(props: ErrorBoundaryProps) {
|
||||||
|
super(props)
|
||||||
|
this.state = { hasError: false, error: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||||
|
return { hasError: true, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||||
|
console.error("[ErrorBoundary]", error, errorInfo)
|
||||||
|
this.props.onError?.(error, errorInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleReset = (): void => {
|
||||||
|
this.setState({ hasError: false, error: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): ReactNode {
|
||||||
|
if (this.state.hasError && this.state.error) {
|
||||||
|
const { fallback } = this.props
|
||||||
|
if (typeof fallback === "function") {
|
||||||
|
return fallback(this.state.error, this.handleReset)
|
||||||
|
}
|
||||||
|
if (fallback !== undefined) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import type { ReactNode } from "react"
|
||||||
|
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||||
|
|
||||||
function Skeleton({
|
function Skeleton({
|
||||||
className,
|
className,
|
||||||
@@ -12,4 +15,106 @@ function Skeleton({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SkeletonCardProps {
|
||||||
|
variant: "table" | "list" | "chart" | "form" | "stats-grid"
|
||||||
|
rows?: number
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置驱动骨架卡片:按 variant 渲染不同布局的骨架占位。
|
||||||
|
*
|
||||||
|
* 覆盖以下重复模式:
|
||||||
|
* - message-list-skeleton / announcement-list-skeleton → variant="list"
|
||||||
|
* - audit-log-table-skeleton → variant="table"
|
||||||
|
* - class-skeleton / school-skeleton → variant="form"
|
||||||
|
* - ai-skeleton → variant="chart"
|
||||||
|
* - dashboard-loading-skeleton → variant="stats-grid"
|
||||||
|
* - lesson-plan-skeleton → variant="form"(备课编辑器布局)
|
||||||
|
*/
|
||||||
|
export function SkeletonCard({
|
||||||
|
variant,
|
||||||
|
rows = 5,
|
||||||
|
className,
|
||||||
|
}: SkeletonCardProps): ReactNode {
|
||||||
|
switch (variant) {
|
||||||
|
case "table":
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-1/4" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{Array.from({ length: rows }).map((_, i) => (
|
||||||
|
<div key={i} className="flex gap-4">
|
||||||
|
<Skeleton className="h-4 flex-1" />
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
case "list":
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-3", className)}>
|
||||||
|
{Array.from({ length: rows }).map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardContent className="flex items-center gap-4 p-4">
|
||||||
|
<Skeleton className="h-10 w-10 rounded-full" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-4 w-1/3" />
|
||||||
|
<Skeleton className="h-3 w-1/2" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-8 w-20" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case "chart":
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-1/3" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-[200px] w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
case "form":
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardContent className="space-y-4 p-6">
|
||||||
|
{Array.from({ length: rows }).map((_, i) => (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-1/4" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Skeleton className="h-10 w-32" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
case "stats-grid":
|
||||||
|
return (
|
||||||
|
<div className={cn("grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4", className)}>
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<Skeleton className="h-4 w-[100px]" />
|
||||||
|
<Skeleton className="h-4 w-4 rounded-full" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="mb-2 h-8 w-[60px]" />
|
||||||
|
<Skeleton className="h-3 w-[140px]" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export { Skeleton }
|
export { Skeleton }
|
||||||
|
|||||||
74
src/shared/components/ui/stats-grid.tsx
Normal file
74
src/shared/components/ui/stats-grid.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import type { ComponentType, ReactNode } from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
import { StatCard } from "@/shared/components/ui/stat-card"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计卡片网格容器:基于 StatCard 渲染响应式网格。
|
||||||
|
*
|
||||||
|
* 覆盖以下重复模式:
|
||||||
|
* - attendance-stats-cards / error-book-stats-cards / elective-stats-cards
|
||||||
|
* - practice-stats-cards / practice-overview-stats-cards
|
||||||
|
* - grade-stats-card / analytics-stats-cards
|
||||||
|
*/
|
||||||
|
interface StatItem {
|
||||||
|
/** 卡片标题 */
|
||||||
|
label: string
|
||||||
|
/** 统计数值 */
|
||||||
|
value: string | number
|
||||||
|
/** 图标组件 */
|
||||||
|
icon?: ComponentType<{ className?: string }>
|
||||||
|
/** 描述文本 */
|
||||||
|
description?: string
|
||||||
|
/** 图标颜色类名 */
|
||||||
|
color?: string
|
||||||
|
/** 是否高亮 */
|
||||||
|
highlight?: boolean
|
||||||
|
/** 点击跳转链接 */
|
||||||
|
href?: string
|
||||||
|
/** 数值自定义类名 */
|
||||||
|
valueClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatsGridProps {
|
||||||
|
/** 统计项列表 */
|
||||||
|
items: StatItem[]
|
||||||
|
/** 列数(默认响应式:mobile=1, md=2, lg=4) */
|
||||||
|
columns?: 2 | 3 | 4
|
||||||
|
/** 加载态 */
|
||||||
|
isLoading?: boolean
|
||||||
|
/** 容器额外类名 */
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnsClass: Record<2 | 3 | 4, string> = {
|
||||||
|
2: "grid-cols-1 md:grid-cols-2",
|
||||||
|
3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
|
||||||
|
4: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatsGrid({
|
||||||
|
items,
|
||||||
|
columns = 4,
|
||||||
|
isLoading = false,
|
||||||
|
className,
|
||||||
|
}: StatsGridProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className={cn("grid gap-4", columnsClass[columns], className)}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<StatCard
|
||||||
|
key={item.label}
|
||||||
|
title={item.label}
|
||||||
|
value={item.value}
|
||||||
|
icon={item.icon}
|
||||||
|
description={item.description}
|
||||||
|
color={item.color}
|
||||||
|
highlight={item.highlight}
|
||||||
|
href={item.href}
|
||||||
|
isLoading={isLoading}
|
||||||
|
valueClassName={item.valueClassName}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通用 Widget 边界组件(shared 层)。
|
* 通用 Widget 边界组件 preset(shared 层)。
|
||||||
*
|
*
|
||||||
* 组合三个能力:
|
* 组合三个能力:
|
||||||
* 1. Error Boundary — 隔离故障域,单个 Widget 抛错不影响其他区块
|
* 1. Error Boundary — 隔离故障域,单个 Widget 抛错不影响其他区块
|
||||||
* 2. Suspense — 流式渲染时显示骨架屏,避免白屏等待
|
* 2. Suspense — 流式渲染时显示骨架屏,避免白屏等待
|
||||||
* 3. Skeleton — 与 Widget 尺寸匹配的占位
|
* 3. Skeleton — 与 Widget 尺寸匹配的占位
|
||||||
*
|
*
|
||||||
|
* 基于 `ErrorBoundary` 基础组件实现。
|
||||||
|
*
|
||||||
* 用法:
|
* 用法:
|
||||||
* ```tsx
|
* ```tsx
|
||||||
* <WidgetBoundary title="成绩趋势">
|
* <WidgetBoundary title="成绩趋势">
|
||||||
@@ -16,11 +18,12 @@
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, Suspense, type ReactNode } from "react"
|
import { Suspense, type ReactNode } from "react"
|
||||||
import { AlertCircle } from "lucide-react"
|
import { AlertCircle } from "lucide-react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
|
import { ErrorBoundary } from "@/shared/components/ui/error-boundary"
|
||||||
|
|
||||||
interface WidgetBoundaryProps {
|
interface WidgetBoundaryProps {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
@@ -34,69 +37,6 @@ interface WidgetBoundaryProps {
|
|||||||
retryLabel?: string
|
retryLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WidgetBoundaryState {
|
|
||||||
hasError: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WidgetErrorBoundaryProps {
|
|
||||||
title: string
|
|
||||||
fallbackDescription: string
|
|
||||||
retryLabel: string
|
|
||||||
loadFailedMessage: string
|
|
||||||
retryAriaLabel: string
|
|
||||||
children: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
class WidgetErrorBoundary extends Component<
|
|
||||||
WidgetErrorBoundaryProps,
|
|
||||||
WidgetBoundaryState
|
|
||||||
> {
|
|
||||||
constructor(props: WidgetErrorBoundaryProps) {
|
|
||||||
super(props)
|
|
||||||
this.state = { hasError: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
static getDerivedStateFromError(): WidgetBoundaryState {
|
|
||||||
return { hasError: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
handleReset = (): void => {
|
|
||||||
this.setState({ hasError: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
render(): ReactNode {
|
|
||||||
if (this.state.hasError) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
aria-live="assertive"
|
|
||||||
className="flex h-full min-h-[200px] flex-col items-center justify-center gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-6 text-center"
|
|
||||||
>
|
|
||||||
<AlertCircle className="h-8 w-8 text-destructive" aria-hidden="true" />
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-medium text-foreground">
|
|
||||||
{this.props.loadFailedMessage}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{this.props.fallbackDescription}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={this.handleReset}
|
|
||||||
aria-label={this.props.retryAriaLabel}
|
|
||||||
>
|
|
||||||
{this.props.retryLabel}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.props.children
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function WidgetSkeleton({
|
function WidgetSkeleton({
|
||||||
height,
|
height,
|
||||||
loadingAriaLabel,
|
loadingAriaLabel,
|
||||||
@@ -135,21 +75,35 @@ export function WidgetBoundary({
|
|||||||
const retryAriaLabel = t("widget.retryAriaLabel", { title: effectiveTitle })
|
const retryAriaLabel = t("widget.retryAriaLabel", { title: effectiveTitle })
|
||||||
const loadingAriaLabel = t("widget.loadingAriaLabel", { title: effectiveTitle })
|
const loadingAriaLabel = t("widget.loadingAriaLabel", { title: effectiveTitle })
|
||||||
|
|
||||||
return (
|
const errorFallback = (_error: Error, reset: () => void): ReactNode => (
|
||||||
<WidgetErrorBoundary
|
<div
|
||||||
title={effectiveTitle}
|
role="alert"
|
||||||
fallbackDescription={effectiveFallbackDescription}
|
aria-live="assertive"
|
||||||
retryLabel={effectiveRetryLabel}
|
className="flex h-full min-h-[200px] flex-col items-center justify-center gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-6 text-center"
|
||||||
loadFailedMessage={loadFailedMessage}
|
|
||||||
retryAriaLabel={retryAriaLabel}
|
|
||||||
>
|
>
|
||||||
|
<AlertCircle className="h-8 w-8 text-destructive" aria-hidden="true" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-foreground">{loadFailedMessage}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{effectiveFallbackDescription}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={reset}
|
||||||
|
aria-label={retryAriaLabel}
|
||||||
|
>
|
||||||
|
{effectiveRetryLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary fallback={errorFallback}>
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={<WidgetSkeleton height={skeletonHeight} loadingAriaLabel={loadingAriaLabel} />}
|
||||||
<WidgetSkeleton height={skeletonHeight} loadingAriaLabel={loadingAriaLabel} />
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</WidgetErrorBoundary>
|
</ErrorBoundary>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user