Files
NextEdu/src/modules/grades/components/widget-boundary.tsx
SpecialX 5f3a1a4662 refactor(grades,diagnostic): 完成成绩和学情诊断模块审计 P1+P2 改进项
P1-1: 抽取 stats-service.ts,将 8 个统计计算纯函数从 data-access 层分离
P1-5: 创建 WidgetBoundary 组件 + 补齐 teacher 路由 loading.tsx/error.tsx (14 文件)
P1-6: 同步架构图文档 004/005,新增 stats-service 与 widget-boundary 节点
P2-1: 补充 a11y ARIA 属性(5 图表 role=img + aria-label,2 表格 caption,3 列表 role=list,3 按钮 aria-label)
P2-3: 修复班级报告 studentId 字段语义错误(schema 改为可空 + 迁移 + 代码适配)
P2-4: 修复 grade_managed scope 返回空数据(改为子查询 classes 表按 gradeId 过滤)
P2-5: 新增 /parent/diagnostic/ 页面(多子女学情诊断聚合 + loading + error)
P2-6: 统一 SearchParams 工具(student/grades 和 management/grade/insights 改用 @/shared/lib/search-params)
2026-06-22 17:07:32 +08:00

140 lines
3.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
/**
* Grades/Diagnostic 模块通用 Widget 边界组件。
*
* 组合三个能力:
* 1. Error Boundary — 隔离故障域,单个 Widget 抛错不影响其他区块
* 2. Suspense — 流式渲染时显示骨架屏,避免白屏等待
* 3. Skeleton — 与 Widget 尺寸匹配的占位
*
* 用法:
* ```tsx
* <WidgetBoundary title="成绩趋势">
* <GradeTrendChart data={data} />
* </WidgetBoundary>
* ```
*/
import { Component, Suspense, type ReactNode } from "react"
import { AlertCircle } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Skeleton } from "@/shared/components/ui/skeleton"
interface WidgetBoundaryProps {
children: ReactNode
/** Widget 标题(用于错误提示和 aria-label */
title?: string
/** 骨架屏高度(默认 200px */
skeletonHeight?: number
/** 自定义错误描述 */
fallbackDescription?: string
/** 重试按钮文案 */
retryLabel?: string
}
interface WidgetBoundaryState {
hasError: boolean
}
class WidgetErrorBoundary extends Component<
Pick<
WidgetBoundaryProps,
"title" | "fallbackDescription" | "retryLabel" | "children"
>,
WidgetBoundaryState
> {
constructor(props: WidgetBoundaryProps) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(): WidgetBoundaryState {
return { hasError: true }
}
handleReset = (): void => {
this.setState({ hasError: false })
}
render(): ReactNode {
if (this.state.hasError) {
const title = this.props.title ?? "区块"
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">
{title}
</p>
<p className="text-xs text-muted-foreground">
{this.props.fallbackDescription ?? "请重试或刷新页面"}
</p>
</div>
<Button
size="sm"
variant="outline"
onClick={this.handleReset}
aria-label={`重试加载${title}`}
>
{this.props.retryLabel ?? "重试"}
</Button>
</div>
)
}
return this.props.children
}
}
function WidgetSkeleton({
height,
title,
}: {
height: number
title?: string
}): ReactNode {
return (
<div
role="status"
aria-label={`${title ?? "区块"}加载中`}
aria-live="polite"
className="space-y-3 p-4"
style={{ minHeight: height }}
>
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-32 w-full" />
</div>
)
}
export function WidgetBoundary({
children,
title,
skeletonHeight = 200,
fallbackDescription,
retryLabel,
}: WidgetBoundaryProps): ReactNode {
return (
<WidgetErrorBoundary
title={title}
fallbackDescription={fallbackDescription}
retryLabel={retryLabel}
>
<Suspense
fallback={
<WidgetSkeleton height={skeletonHeight} title={title} />
}
>
{children}
</Suspense>
</WidgetErrorBoundary>
)
}