152 lines
5.6 KiB
TypeScript
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;
|
|
}
|
|
}
|