From 11ddc8ccbed80b4ea9669a309385f91053ec8c65 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:00:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(shared):=20=E7=BB=84=E4=BB=B6=E5=8C=96?= =?UTF-8?q?=E9=87=8D=E6=9E=84=20Phase=201=20-=20=E5=BA=95=E5=BA=A7?= =?UTF-8?q?=E6=94=B6=E6=95=9B=E4=B8=8E=E8=A1=A5=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ErrorBoundary 基础类组件 (ui/error-boundary.tsx) - 新增 StatsGrid 容器 (ui/stats-grid.tsx) - 扩展 Skeleton 新增 SkeletonCard variant - 收敛 SectionErrorBoundary/RouteErrorBoundary/WidgetBoundary 为 ErrorBoundary preset --- src/shared/components/route-error.tsx | 38 ++++-- .../components/section-error-boundary.tsx | 128 +++++------------- src/shared/components/ui/error-boundary.tsx | 58 ++++++++ src/shared/components/ui/skeleton.tsx | 105 ++++++++++++++ src/shared/components/ui/stats-grid.tsx | 74 ++++++++++ src/shared/components/widget-boundary.tsx | 106 ++++----------- 6 files changed, 325 insertions(+), 184 deletions(-) create mode 100644 src/shared/components/ui/error-boundary.tsx create mode 100644 src/shared/components/ui/stats-grid.tsx diff --git a/src/shared/components/route-error.tsx b/src/shared/components/route-error.tsx index d479110..39b5538 100644 --- a/src/shared/components/route-error.tsx +++ b/src/shared/components/route-error.tsx @@ -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,20 +29,26 @@ export function RouteErrorBoundary({ }: { reset: () => void namespace?: string -}) { +}): ReactNode { const t = useTranslations(namespace) return ( -
- reset(), - }} - className="border-none shadow-none h-auto" - /> -
+ ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ )} + > + {null} +
) } diff --git a/src/shared/components/section-error-boundary.tsx b/src/shared/components/section-error-boundary.tsx index 44c8080..706726c 100644 --- a/src/shared/components/section-error-boundary.tsx +++ b/src/shared/components/section-error-boundary.tsx @@ -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 { - 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 ( -
-
- ) - } - 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 ( - void): ReactNode => ( +
+
+ ) + + return ( + {children} -
+ ) } diff --git a/src/shared/components/ui/error-boundary.tsx b/src/shared/components/ui/error-boundary.tsx new file mode 100644 index 0000000..43f05db --- /dev/null +++ b/src/shared/components/ui/error-boundary.tsx @@ -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 { + 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 + } +} diff --git a/src/shared/components/ui/skeleton.tsx b/src/shared/components/ui/skeleton.tsx index b90b2c6..14ee6a5 100644 --- a/src/shared/components/ui/skeleton.tsx +++ b/src/shared/components/ui/skeleton.tsx @@ -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 ( + + + + + + {Array.from({ length: rows }).map((_, i) => ( +
+ + + +
+ ))} +
+
+ ) + case "list": + return ( +
+ {Array.from({ length: rows }).map((_, i) => ( + + + +
+ + +
+ +
+
+ ))} +
+ ) + case "chart": + return ( + + + + + + + + + ) + case "form": + return ( + + + {Array.from({ length: rows }).map((_, i) => ( +
+ + +
+ ))} + +
+
+ ) + case "stats-grid": + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + + + + + + + ))} +
+ ) + } +} + export { Skeleton } diff --git a/src/shared/components/ui/stats-grid.tsx b/src/shared/components/ui/stats-grid.tsx new file mode 100644 index 0000000..5a4d0f3 --- /dev/null +++ b/src/shared/components/ui/stats-grid.tsx @@ -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 ( +
+ {items.map((item) => ( + + ))} +
+ ) +} diff --git a/src/shared/components/widget-boundary.tsx b/src/shared/components/widget-boundary.tsx index 52c0112..9f5f34c 100644 --- a/src/shared/components/widget-boundary.tsx +++ b/src/shared/components/widget-boundary.tsx @@ -1,13 +1,15 @@ "use client" /** - * 通用 Widget 边界组件(shared 层)。 + * 通用 Widget 边界组件 preset(shared 层)。 * * 组合三个能力: * 1. Error Boundary — 隔离故障域,单个 Widget 抛错不影响其他区块 * 2. Suspense — 流式渲染时显示骨架屏,避免白屏等待 * 3. Skeleton — 与 Widget 尺寸匹配的占位 * + * 基于 `ErrorBoundary` 基础组件实现。 + * * 用法: * ```tsx * @@ -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 ( -
-
- ) - } - - 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 ( - void): ReactNode => ( +
+
+ ) + + return ( + - } + fallback={} > {children} -
+ ) }