136 lines
4.5 KiB
TypeScript
136 lines
4.5 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* 全局错误边界组件
|
||
*
|
||
* - 捕获子树中的 React 渲染错误,防止整个页面白屏
|
||
* - 在开发环境输出详细错误栈到 console
|
||
* - 预留 Sentry 集成点(已集成)
|
||
* - 提供可重置的友好错误 UI
|
||
*/
|
||
|
||
import React, { Component, ErrorInfo, ReactNode } from "react";
|
||
|
||
interface Props {
|
||
children: ReactNode;
|
||
/** 自定义 fallback UI;不传则使用内置样式 */
|
||
fallback?: ReactNode;
|
||
}
|
||
|
||
interface State {
|
||
hasError: boolean;
|
||
error: Error | null;
|
||
errorInfo: ErrorInfo | null;
|
||
}
|
||
|
||
export class ErrorBoundary extends Component<Props, State> {
|
||
constructor(props: Props) {
|
||
super(props);
|
||
this.state = { hasError: false, error: null, errorInfo: null };
|
||
}
|
||
|
||
static getDerivedStateFromError(error: Error): Partial<State> {
|
||
return { hasError: true, error };
|
||
}
|
||
|
||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||
this.setState({ errorInfo });
|
||
|
||
// 开发环境:完整错误栈
|
||
if (process.env.NODE_ENV !== "production") {
|
||
console.group("[ErrorBoundary] 捕获到未处理的渲染错误");
|
||
console.error("Error:", error);
|
||
console.error("Component Stack:", errorInfo.componentStack);
|
||
console.groupEnd();
|
||
} else {
|
||
console.error("[ErrorBoundary]", error.message);
|
||
}
|
||
|
||
// Sentry 错误上报(DSN 未配置时自动禁用)
|
||
try {
|
||
import("@sentry/nextjs").then((Sentry) => {
|
||
Sentry.captureException(error, { extra: { componentStack: errorInfo.componentStack } });
|
||
});
|
||
} catch {
|
||
// Sentry 未安装或未配置,静默忽略
|
||
}
|
||
}
|
||
|
||
handleReset = (): void => {
|
||
this.setState({ hasError: false, error: null, errorInfo: null });
|
||
};
|
||
|
||
render(): ReactNode {
|
||
if (!this.state.hasError) {
|
||
return this.props.children;
|
||
}
|
||
|
||
// 使用自定义 fallback
|
||
if (this.props.fallback) {
|
||
return this.props.fallback;
|
||
}
|
||
|
||
// 内置友好错误页面
|
||
return (
|
||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||
<div className="max-w-md w-full bg-white rounded-2xl shadow-lg p-8 text-center">
|
||
{/* 图标 */}
|
||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-50">
|
||
<svg
|
||
className="h-8 w-8 text-red-500"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
strokeWidth={1.5}
|
||
stroke="currentColor"
|
||
aria-hidden="true"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
|
||
<h1 className="text-xl font-semibold text-gray-900 mb-2">
|
||
页面出现了错误
|
||
</h1>
|
||
<p className="text-sm text-gray-500 mb-6">
|
||
应用遇到了一个意外错误。您可以尝试刷新页面,或点击下方按钮重试。
|
||
</p>
|
||
|
||
{/* 开发环境:展示错误摘要 */}
|
||
{process.env.NODE_ENV !== "production" && this.state.error && (
|
||
<details className="mb-6 text-left rounded-lg bg-red-50 p-4 text-xs text-red-700">
|
||
<summary className="cursor-pointer font-medium select-none mb-1">
|
||
错误详情(开发模式)
|
||
</summary>
|
||
<pre className="whitespace-pre-wrap break-all mt-2 opacity-80">
|
||
{this.state.error.message}
|
||
{this.state.errorInfo?.componentStack}
|
||
</pre>
|
||
</details>
|
||
)}
|
||
|
||
<div className="flex flex-col gap-3">
|
||
<button
|
||
onClick={this.handleReset}
|
||
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||
>
|
||
重试
|
||
</button>
|
||
<button
|
||
onClick={() => window.location.reload()}
|
||
className="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2"
|
||
>
|
||
刷新页面
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
}
|
||
|
||
export default ErrorBoundary;
|