feat(shared,tests): add error boundaries, lib utils, i18n messages, and integration tests
shared: - Add class-filter, error-state, route-error, section-error-boundary, widget-boundary components - Add ui/alert component - Add constants directory - Add breached-password, export-utils, permission-bitmap, rate-limit, resolve-action-error, route-permissions, route-resolver, type-guards lib - Add i18n messages (en, zh-CN) for invitation-codes, parent, questions, rbac tests: - Add integration tests for elective - Add tests/setup/empty-stub scripts: - Add update-md.cjs, tmp_append_en.ps1, tmp_merge_en.ps1 utilities
This commit is contained in:
139
src/shared/components/class-filter.tsx
Normal file
139
src/shared/components/class-filter.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
|
||||
export interface ClassFilterItem {
|
||||
classId: string
|
||||
className: string
|
||||
/** 可选徽章文本(已翻译,如 "5 次练习" 或 "3 错题") */
|
||||
badgeText?: string
|
||||
/** 可选次要信息文本(如 "2 待复习") */
|
||||
secondaryText?: string
|
||||
/** 次要信息是否使用警告样式(红色) */
|
||||
secondaryWarning?: boolean
|
||||
}
|
||||
|
||||
interface ClassFilterProps {
|
||||
classes: ClassFilterItem[]
|
||||
/** 当前选中的班级 ID("all" 表示全部) */
|
||||
currentClassId: string
|
||||
/** URL 参数名(默认 "classId") */
|
||||
paramName?: string
|
||||
/** "全部班级" 按钮文本(已翻译) */
|
||||
allLabel: string
|
||||
/** 无障碍标签(已翻译,如 "选择班级") */
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用班级筛选器(共享组件)。
|
||||
*
|
||||
* 通过 URL 查询参数(默认 `classId`)管理选中状态,点击切换班级。
|
||||
*
|
||||
* 解耦设计:
|
||||
* - 不依赖任何业务模块的类型或翻译
|
||||
* - 所有展示文本(allLabel、badgeText、secondaryText)由调用方翻译后传入
|
||||
* - 适用于教师/年级主任在 error-book、practice、homework 等模块的班级切换
|
||||
*
|
||||
* @example
|
||||
* // Server Component 页面调用
|
||||
* <ClassFilter
|
||||
* classes={classes.map(c => ({
|
||||
* classId: c.classId,
|
||||
* className: c.className,
|
||||
* badgeText: t("classFilter.sessionCount", { count: c.totalSessions }),
|
||||
* }))}
|
||||
* currentClassId={effectiveClassId}
|
||||
* allLabel={t("classFilter.all")}
|
||||
* ariaLabel={t("classFilter.selectClass")}
|
||||
* />
|
||||
*/
|
||||
export function ClassFilter({
|
||||
classes,
|
||||
currentClassId,
|
||||
paramName = "classId",
|
||||
allLabel,
|
||||
ariaLabel,
|
||||
}: ClassFilterProps): React.ReactNode {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
function handleSelect(classId: string): void {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (classId === "all") {
|
||||
params.delete(paramName)
|
||||
} else {
|
||||
params.set(paramName, classId)
|
||||
}
|
||||
router.push(`?${params.toString()}`, { scroll: false })
|
||||
}
|
||||
|
||||
if (classes.length === 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap gap-2"
|
||||
role="tablist"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={currentClassId === "all"}
|
||||
onClick={() => handleSelect("all")}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors",
|
||||
currentClassId === "all"
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "bg-card hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">{allLabel}</span>
|
||||
</button>
|
||||
{classes.map((cls) => {
|
||||
const isActive = currentClassId === cls.classId
|
||||
return (
|
||||
<button
|
||||
key={cls.classId}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => handleSelect(cls.classId)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors",
|
||||
isActive
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "bg-card hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">{cls.className}</span>
|
||||
{cls.badgeText ? (
|
||||
<Badge
|
||||
variant={isActive ? "secondary" : "outline"}
|
||||
className="text-xs"
|
||||
>
|
||||
{cls.badgeText}
|
||||
</Badge>
|
||||
) : null}
|
||||
{cls.secondaryText ? (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs",
|
||||
cls.secondaryWarning
|
||||
? isActive
|
||||
? "text-primary-foreground/80"
|
||||
: "text-rose-600"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{cls.secondaryText}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
35
src/shared/components/error-state.tsx
Normal file
35
src/shared/components/error-state.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import { AlertTriangle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
/**
|
||||
* 通用错误边界展示组件(P1-4 审计修复)。
|
||||
*
|
||||
* 统一 error.tsx 的渲染逻辑,避免 30+ error.tsx 重复同样的代码。
|
||||
* 各模块 error.tsx 只需调用 `<ErrorState error={error} reset={reset} namespace="xxx" />`。
|
||||
*
|
||||
* 翻译键查找规则:在传入 namespace 下查找 error.title / error.description / error.retry。
|
||||
*/
|
||||
export function ErrorState({
|
||||
error,
|
||||
reset,
|
||||
namespace,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
namespace: string
|
||||
}) {
|
||||
const t = useTranslations(namespace)
|
||||
|
||||
return (
|
||||
<EmptyState
|
||||
icon={AlertTriangle}
|
||||
title={t("error.title")}
|
||||
description={error.message || t("error.description")}
|
||||
action={{ label: t("error.retry"), onClick: reset }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useSession } from "next-auth/react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
type Role = "student" | "teacher" | "parent" | "admin"
|
||||
|
||||
const TEACHER_SUBJECTS = ["语文", "数学", "英语", "美术", "体育", "科学", "社会", "音乐"] as const
|
||||
type TeacherSubject = (typeof TEACHER_SUBJECTS)[number]
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === "object" && v !== null
|
||||
}
|
||||
|
||||
export function OnboardingGate() {
|
||||
const router = useRouter()
|
||||
const { status, data: session, update } = useSession()
|
||||
const [required, setRequired] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [step, setStep] = useState(0)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const [role, setRole] = useState<Role>("student")
|
||||
const [name, setName] = useState("")
|
||||
const [phone, setPhone] = useState("")
|
||||
const [address, setAddress] = useState("")
|
||||
|
||||
const [classCodes, setClassCodes] = useState("")
|
||||
const [teacherSubjects, setTeacherSubjects] = useState<TeacherSubject[]>([])
|
||||
|
||||
const canClose = useMemo(() => !required, [required])
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "authenticated") return
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
const res = await fetch("/api/onboarding/status", { cache: "no-store" }).catch(() => null)
|
||||
const json = res ? await res.json().catch(() => null) : null
|
||||
if (cancelled) return
|
||||
if (isRecord(json)) {
|
||||
const required = Boolean(json.required)
|
||||
const role = String(json.role ?? "student") as Role
|
||||
setRequired(required)
|
||||
setRole(role === "admin" ? "admin" : role)
|
||||
setName(String(session?.user?.name ?? "").trim())
|
||||
if (required) {
|
||||
setOpen(true)
|
||||
setStep(0)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [status, session?.user?.name])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (!required) return
|
||||
setOpen(true)
|
||||
}, [open, required])
|
||||
|
||||
const title =
|
||||
step === 0 ? "角色选择" : step === 1 ? "通用信息" : step === 2 ? "角色信息(可跳过)" : "完成"
|
||||
const description =
|
||||
step === 0
|
||||
? "请选择你在系统中的角色"
|
||||
: step === 1
|
||||
? "填写姓名、电话、住址等信息"
|
||||
: step === 2
|
||||
? "不同角色可配置班级代码、教学科目等"
|
||||
: "配置完成,可以进入系统"
|
||||
|
||||
const canNextFromStep0 = role.length > 0
|
||||
const canNextFromStep1 = name.trim().length > 0 && phone.trim().length > 0
|
||||
|
||||
const permissions = (session?.user?.permissions ?? []) as string[]
|
||||
const isAdmin = permissions.includes(Permissions.SETTINGS_ADMIN)
|
||||
const isTeacher = permissions.includes(Permissions.EXAM_CREATE)
|
||||
const isStudent = permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)
|
||||
const isParent = !permissions.includes(Permissions.EXAM_CREATE) && !permissions.includes(Permissions.HOMEWORK_SUBMIT) && permissions.includes(Permissions.EXAM_READ)
|
||||
|
||||
const onNext = async () => {
|
||||
if (step === 0) {
|
||||
if (!canNextFromStep0) return
|
||||
setStep(1)
|
||||
return
|
||||
}
|
||||
if (step === 1) {
|
||||
if (!canNextFromStep1) {
|
||||
toast.error("请填写姓名与电话")
|
||||
return
|
||||
}
|
||||
if (isAdmin) {
|
||||
setStep(3)
|
||||
} else {
|
||||
setStep(2)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (step === 2) {
|
||||
setStep(3)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const onBack = () => {
|
||||
if (step === 0) return
|
||||
setStep((s) => Math.max(0, s - 1))
|
||||
}
|
||||
|
||||
const toggleSubject = (subject: TeacherSubject) => {
|
||||
setTeacherSubjects((prev) => (prev.includes(subject) ? prev.filter((s) => s !== subject) : [...prev, subject]))
|
||||
}
|
||||
|
||||
const onFinish = async () => {
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const res = await fetch("/api/onboarding/complete", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
role,
|
||||
name,
|
||||
phone,
|
||||
address,
|
||||
classCodes,
|
||||
teacherSubjects,
|
||||
}),
|
||||
})
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok || !isRecord(json) || json.success !== true) {
|
||||
const msg = isRecord(json) ? String(json.message ?? "") : ""
|
||||
throw new Error(msg || "提交失败")
|
||||
}
|
||||
|
||||
await update?.()
|
||||
toast.success("配置完成")
|
||||
setRequired(false)
|
||||
setOpen(false)
|
||||
router.push("/dashboard")
|
||||
router.refresh()
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "提交失败"
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (canClose) setOpen(v)
|
||||
else setOpen(true)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[720px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className={cn("h-1 flex-1 rounded", step >= 0 ? "bg-primary" : "bg-muted")} />
|
||||
<div className={cn("h-1 flex-1 rounded", step >= 1 ? "bg-primary" : "bg-muted")} />
|
||||
<div className={cn("h-1 flex-1 rounded", step >= 2 ? "bg-primary" : "bg-muted")} />
|
||||
<div className={cn("h-1 flex-1 rounded", step >= 3 ? "bg-primary" : "bg-muted")} />
|
||||
</div>
|
||||
|
||||
{step === 0 ? (
|
||||
<div className="grid gap-2">
|
||||
<Label>Role</Label>
|
||||
{isAdmin ? (
|
||||
<div className="rounded-md border px-3 py-2 text-sm">admin</div>
|
||||
) : (
|
||||
<Select value={role} onValueChange={(v) => setRole(v as Role)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="student">student</SelectItem>
|
||||
<SelectItem value="teacher">teacher</SelectItem>
|
||||
<SelectItem value="parent">parent</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 1 ? (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="onb_name">姓名</Label>
|
||||
<Input id="onb_name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="onb_phone">电话</Label>
|
||||
<Input id="onb_phone" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="onb_address">住址</Label>
|
||||
<Input id="onb_address" value={address} onChange={(e) => setAddress(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 2 ? (
|
||||
<div className="grid gap-4">
|
||||
{isTeacher ? (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="onb_codes_teacher">班级代码(可多个)</Label>
|
||||
<Textarea
|
||||
id="onb_codes_teacher"
|
||||
value={classCodes}
|
||||
onChange={(e) => setClassCodes(e.target.value)}
|
||||
placeholder="每行一个或用逗号分隔"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>教学科目</Label>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{TEACHER_SUBJECTS.map((s) => (
|
||||
<label key={s} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={teacherSubjects.includes(s)} onCheckedChange={() => toggleSubject(s)} />
|
||||
{s}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{isStudent ? (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="onb_codes_student">班级代码</Label>
|
||||
<Textarea
|
||||
id="onb_codes_student"
|
||||
value={classCodes}
|
||||
onChange={(e) => setClassCodes(e.target.value)}
|
||||
placeholder="每行一个或用逗号分隔"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isParent ? (
|
||||
<div className="rounded-md border px-3 py-2 text-sm text-muted-foreground">
|
||||
家长角色暂不需要配置,可跳过
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 3 ? (
|
||||
<div className="rounded-md border px-3 py-3 text-sm">
|
||||
<div className="font-medium">已准备完成</div>
|
||||
<div className="text-muted-foreground">点击完成后进入系统。</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={onBack} disabled={step === 0 || isSubmitting}>
|
||||
上一步
|
||||
</Button>
|
||||
{step === 2 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setStep(3)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
跳过
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
{step < 3 ? (
|
||||
<Button type="button" onClick={onNext} disabled={isSubmitting}>
|
||||
下一步
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" onClick={onFinish} disabled={isSubmitting}>
|
||||
完成
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
44
src/shared/components/route-error.tsx
Normal file
44
src/shared/components/route-error.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
/**
|
||||
* 通用路由错误边界组件。
|
||||
* 供各模块 error.tsx 复用,消除重复代码(P1-10 重构)。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // app/(dashboard)/admin/attendance/error.tsx
|
||||
* "use client"
|
||||
* import { RouteErrorBoundary } from "@/shared/components/route-error"
|
||||
* export default function Error({ reset }: { error: Error; reset: () => void }) {
|
||||
* return <RouteErrorBoundary reset={reset} namespace="attendance" />
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function RouteErrorBoundary({
|
||||
reset,
|
||||
namespace = "attendance",
|
||||
}: {
|
||||
reset: () => void
|
||||
namespace?: string
|
||||
}) {
|
||||
const t = useTranslations(namespace)
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("errors.unexpected")}
|
||||
description={t("errors.unexpected")}
|
||||
action={{
|
||||
label: t("actions.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
148
src/shared/components/section-error-boundary.tsx
Normal file
148
src/shared/components/section-error-boundary.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { Component, type ErrorInfo, type ReactNode } 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
|
||||
}
|
||||
}
|
||||
|
||||
interface SectionErrorBoundaryProps {
|
||||
children: ReactNode
|
||||
/** i18n namespace,默认 "common"。需保证该 namespace 下存在 error.boundaryTitle / error.boundaryDescription / error.retry 键 */
|
||||
namespace?: string
|
||||
/** 自定义降级 UI(函数形式,优先级最高)。若提供则忽略 title/description/retryLabel */
|
||||
fallback?: (error: Error, reset: () => void) => ReactNode
|
||||
/** 显式覆盖标题(优先级高于 i18n 默认) */
|
||||
title?: string
|
||||
/** 显式覆盖描述 */
|
||||
description?: string
|
||||
/** 显式覆盖重试按钮文案 */
|
||||
retryLabel?: string
|
||||
/** 错误回调(用于埋点/监控) */
|
||||
onError?: (error: Error, info: ErrorInfo) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件级 Error Boundary:包裹独立数据区块,防止单区块错误导致整页崩溃。
|
||||
* 自动使用 next-intl 提供本地化文案;如需完全自定义可传入 `fallback` 函数。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // 默认 i18n 文案
|
||||
* <SectionErrorBoundary namespace="coursePlans">
|
||||
* <CoursePlanProgress plan={plan} />
|
||||
* </SectionErrorBoundary>
|
||||
*
|
||||
* // 完全自定义降级 UI
|
||||
* <SectionErrorBoundary fallback={(error, reset) => <CustomUI error={error} onRetry={reset} />}>
|
||||
* <CoursePlanProgress plan={plan} />
|
||||
* </SectionErrorBoundary>
|
||||
* ```
|
||||
*/
|
||||
export function SectionErrorBoundary({
|
||||
children,
|
||||
namespace = "common",
|
||||
fallback,
|
||||
title,
|
||||
description,
|
||||
retryLabel,
|
||||
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}
|
||||
>
|
||||
{children}
|
||||
</SectionErrorBoundaryBase>
|
||||
)
|
||||
}
|
||||
42
src/shared/components/ui/alert.tsx
Normal file
42
src/shared/components/ui/alert.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
function Alert({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="alert"
|
||||
className={cn(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
155
src/shared/components/widget-boundary.tsx
Normal file
155
src/shared/components/widget-boundary.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
"use client"
|
||||
|
||||
/**
|
||||
* 通用 Widget 边界组件(shared 层)。
|
||||
*
|
||||
* 组合三个能力:
|
||||
* 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 { useTranslations } from "next-intl"
|
||||
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
|
||||
}
|
||||
|
||||
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,
|
||||
}: {
|
||||
height: number
|
||||
loadingAriaLabel: string
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={loadingAriaLabel}
|
||||
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 {
|
||||
const t = useTranslations("common")
|
||||
const effectiveTitle = title ?? t("widget.block")
|
||||
const effectiveFallbackDescription = fallbackDescription ?? t("widget.defaultFallback")
|
||||
const effectiveRetryLabel = retryLabel ?? t("widget.retry")
|
||||
const loadFailedMessage = t("widget.loadFailed", { title: effectiveTitle })
|
||||
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}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<WidgetSkeleton height={skeletonHeight} loadingAriaLabel={loadingAriaLabel} />
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Suspense>
|
||||
</WidgetErrorBoundary>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user