Files
aiops/frontend/src/components/layout/ErrorBoundary.tsx
T
2026-07-09 14:16:56 +08:00

152 lines
5.6 KiB
TypeScript

import { Component, type ErrorInfo, type ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import { AlertTriangle, RotateCcw, Home, Bug } from 'lucide-react';
import { toast } from 'sonner';
import i18n from '@/i18n';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
function isChunkLoadError(error: Error | null) {
// 发版后旧入口引用已删除 chunk 时,浏览器会抛动态 import 加载失败。 // Logan.Li
const message = error?.message || '';
return message.includes('Failed to fetch dynamically imported module') ||
message.includes('Importing a module script failed') ||
message.includes('ChunkLoadError') ||
message.includes('Loading chunk');
}
/**
* ErrorBoundary - 全局错误边界组件
*
* 捕获 React 组件树中的错误,防止整个应用崩溃
* 提供友好的错误提示和恢复操作
*/
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error, errorInfo: null };
}
override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error(i18n.t('common.ebCaughtError'), error);
console.error(i18n.t('common.ebErrorInfo'), errorInfo.componentStack);
this.setState({ error, errorInfo });
}
handleRetry = () => {
if (isChunkLoadError(this.state.error)) {
window.location.reload();
return;
}
this.setState({ hasError: false, error: null, errorInfo: null });
};
handleGoHome = () => {
window.location.href = '/';
};
handleReport = () => {
const { error, errorInfo } = this.state;
const body = `## ${i18n.t('common.ebReportTitle')}\n\n**${i18n.t('common.ebReportTime')}:** ${new Date().toISOString()}\n**URL:** ${window.location.href}\n**${i18n.t('common.ebReportUA')}:** ${navigator.userAgent}\n\n### ${i18n.t('common.ebReportErrorMsg')}\n\n\`\`\`\n${error?.name}: ${error?.message}\n\`\`\`\n\n### ${i18n.t('common.ebReportStack')}\n\n\`\`\`\n${errorInfo?.componentStack}\n\`\`\`\n`;
navigator.clipboard.writeText(body).then(() => {
toast.success(i18n.t('common.errorCopiedToClipboard'));
}).catch(() => {
toast.error(i18n.t('common.copyFailedManual'));
});
};
override render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
const chunkLoadError = isChunkLoadError(this.state.error);
return (
<div className="min-h-[60vh] flex items-center justify-center p-6">
<div className="dg-card max-w-lg w-full p-8 text-center space-y-6">
{/* Error Icon */}
<div className="mx-auto w-16 h-16 rounded-2xl bg-red-500/10 flex items-center justify-center">
<AlertTriangle className="h-8 w-8 dg-text-red" />
</div>
{/* Title */}
<div>
<h2 className="text-xl font-bold text-[var(--dg-text-primary)]">
{i18n.t('common.ebPageError')}
</h2>
<p className="text-sm text-[var(--dg-text-muted)] mt-2">
{chunkLoadError ? i18n.t('common.ebChunkDescription') : i18n.t('common.ebDescription')}
</p>
</div>
{/* Error Detail (collapsible) */}
{this.state.error && (
<div className="text-left">
<details className="group">
<summary className="cursor-pointer text-xs text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors flex items-center gap-1.5">
<Bug className="h-3.5 w-3.5" />
{i18n.t('common.ebViewDetails')}
</summary>
<div className="mt-2 p-3 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] overflow-auto max-h-48">
<code className="text-xs dg-text-red font-mono block whitespace-pre-wrap">
{this.state.error.name}: {this.state.error.message}
</code>
{this.state.errorInfo && (
<pre className="text-xs text-[var(--dg-text-muted)] mt-2 whitespace-pre-wrap">
{this.state.errorInfo.componentStack}
</pre>
)}
</div>
</details>
</div>
)}
{/* Actions */}
<div className="flex items-center justify-center gap-3">
<Button
variant="outline"
className="dg-btn-secondary"
onClick={this.handleGoHome}
>
<Home className="h-4 w-4 mr-1.5" />
{i18n.t('common.ebGoHome')}
</Button>
<Button
className="dg-btn-primary"
onClick={this.handleRetry}
>
<RotateCcw className="h-4 w-4 mr-1.5" />
{chunkLoadError ? i18n.t('common.reloadPage') : i18n.t('common.reload')}
</Button>
<Button
variant="outline"
className="dg-btn-ghost"
onClick={this.handleReport}
>
<Bug className="h-4 w-4 mr-1.5" />
{i18n.t('common.ebCopyError')}
</Button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}