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 (
-
-
-
-
- {this.props.title ?? "加载失败"}
-
-
- {this.props.description ?? "数据加载时发生错误,请重试。"}
-
-
-
-
- )
- }
- 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 => (
+
+
+
+
{effectiveTitle}
+
{effectiveDescription}
+
+
+
+ )
+
+ 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 (
-
-
-
-
- {this.props.loadFailedMessage}
-
-
- {this.props.fallbackDescription}
-
-
-
-
- )
- }
-
- 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 => (
+
+
+
+
{loadFailedMessage}
+
{effectiveFallbackDescription}
+
+
+
+ )
+
+ return (
+
- }
+ fallback={}
>
{children}
-
+
)
}