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:
SpecialX
2026-07-06 18:00:08 +08:00
parent 80d98e13e4
commit 11ddc8ccbe
6 changed files with 325 additions and 184 deletions

View File

@@ -1,14 +1,18 @@
"use client"
import type { ReactNode } from "react"
import { AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { ErrorBoundary } from "@/shared/components/ui/error-boundary"
/**
* 通用路由错误边界组件。
* 通用路由错误边界组件 preset
* 供各模块 error.tsx 复用消除重复代码P1-10 重构)。
*
* 基于 `ErrorBoundary` 基础组件,渲染路由级错误 UI非包裹式作为 error.tsx 的渲染组件)。
*
* @example
* ```tsx
* // app/(dashboard)/admin/attendance/error.tsx
@@ -25,9 +29,11 @@ export function RouteErrorBoundary({
}: {
reset: () => void
namespace?: string
}) {
}): ReactNode {
const t = useTranslations(namespace)
return (
<ErrorBoundary
fallback={() => (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
@@ -40,5 +46,9 @@ export function RouteErrorBoundary({
className="border-none shadow-none h-auto"
/>
</div>
)}
>
{null}
</ErrorBoundary>
)
}

View File

@@ -1,95 +1,12 @@
"use client"
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 { AlertCircle, RefreshCw } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
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
}
}
import { ErrorBoundary } from "@/shared/components/ui/error-boundary"
interface SectionErrorBoundaryProps {
children: ReactNode
@@ -108,9 +25,11 @@ interface SectionErrorBoundaryProps {
}
/**
* 组件级 Error Boundary包裹独立数据区块防止单区块错误导致整页崩溃。
* 组件级 Error Boundary preset:包裹独立数据区块,防止单区块错误导致整页崩溃。
* 自动使用 next-intl 提供本地化文案;如需完全自定义可传入 `fallback` 函数。
*
* 基于基础 `ErrorBoundary` 类组件实现,仅负责 i18n 文案解析与默认降级 UI。
*
* @example
* ```tsx
* // 默认 i18n 文案
@@ -134,15 +53,36 @@ export function SectionErrorBoundary({
onError,
}: SectionErrorBoundaryProps): JSX.Element {
const t = useTranslations(namespace)
return (
<SectionErrorBoundaryBase
fallback={fallback}
title={title ?? t("error.boundaryTitle")}
description={description ?? t("error.boundaryDescription")}
retryLabel={retryLabel ?? t("error.retry")}
onError={onError}
const effectiveTitle = title ?? t("error.boundaryTitle")
const effectiveDescription = description ?? t("error.boundaryDescription")
const effectiveRetryLabel = retryLabel ?? t("error.retry")
const defaultFallback = (_error: Error, reset: () => void): ReactNode => (
<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">{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}
</SectionErrorBoundaryBase>
</ErrorBoundary>
)
}

View 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
}
}

View File

@@ -1,4 +1,7 @@
import type { ReactNode } from "react"
import { cn } from "@/shared/lib/utils"
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
function Skeleton({
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 }

View 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>
)
}

View File

@@ -1,13 +1,15 @@
"use client"
/**
* 通用 Widget 边界组件shared 层)。
* 通用 Widget 边界组件 presetshared 层)。
*
* 组合三个能力:
* 1. Error Boundary — 隔离故障域,单个 Widget 抛错不影响其他区块
* 2. Suspense — 流式渲染时显示骨架屏,避免白屏等待
* 3. Skeleton — 与 Widget 尺寸匹配的占位
*
* 基于 `ErrorBoundary` 基础组件实现。
*
* 用法:
* ```tsx
* <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 { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { ErrorBoundary } from "@/shared/components/ui/error-boundary"
interface WidgetBoundaryProps {
children: ReactNode
@@ -34,69 +37,6 @@ interface WidgetBoundaryProps {
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({
height,
loadingAriaLabel,
@@ -135,21 +75,35 @@ export function WidgetBoundary({
const retryAriaLabel = t("widget.retryAriaLabel", { title: effectiveTitle })
const loadingAriaLabel = t("widget.loadingAriaLabel", { title: effectiveTitle })
return (
<WidgetErrorBoundary
title={effectiveTitle}
fallbackDescription={effectiveFallbackDescription}
retryLabel={effectiveRetryLabel}
loadFailedMessage={loadFailedMessage}
retryAriaLabel={retryAriaLabel}
const errorFallback = (_error: Error, reset: () => void): ReactNode => (
<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">{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
fallback={
<WidgetSkeleton height={skeletonHeight} loadingAriaLabel={loadingAriaLabel} />
}
fallback={<WidgetSkeleton height={skeletonHeight} loadingAriaLabel={loadingAriaLabel} />}
>
{children}
</Suspense>
</WidgetErrorBoundary>
</ErrorBoundary>
)
}