235 lines
8.2 KiB
TypeScript
235 lines
8.2 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
import { AlertTriangle, X, Send, Bug, CheckCircle } from 'lucide-react';
|
||
import { supabase } from '../supabase/client';
|
||
|
||
interface CrashReport {
|
||
error: Error;
|
||
errorInfo: React.ErrorInfo;
|
||
timestamp: string;
|
||
userAgent: string;
|
||
url: string;
|
||
}
|
||
|
||
export function CrashReporter() {
|
||
const [crash, setCrash] = useState<CrashReport | null>(null);
|
||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||
const [userDescription, setUserDescription] = useState('');
|
||
|
||
useEffect(() => {
|
||
// 监听全局错误
|
||
const handleError = (event: ErrorEvent) => {
|
||
const crashData: CrashReport = {
|
||
error: event.error || new Error(event.message),
|
||
errorInfo: { componentStack: '' },
|
||
timestamp: new Date().toISOString(),
|
||
userAgent: navigator.userAgent,
|
||
url: window.location.href,
|
||
};
|
||
setCrash(crashData);
|
||
};
|
||
|
||
window.addEventListener('error', handleError);
|
||
return () => window.removeEventListener('error', handleError);
|
||
}, []);
|
||
|
||
const handleSubmit = async () => {
|
||
if (!crash) return;
|
||
|
||
setIsSubmitting(true);
|
||
|
||
try {
|
||
// 获取当前用户信息
|
||
const { data: { user } } = await supabase.auth.getUser();
|
||
|
||
// 提交崩溃报告
|
||
await supabase.from('crash_reports').insert({
|
||
error_message: crash.error.message,
|
||
error_stack: crash.error.stack,
|
||
component_stack: crash.errorInfo.componentStack,
|
||
user_description: userDescription.trim() || null,
|
||
user_agent: crash.userAgent,
|
||
url: crash.url,
|
||
user_id: user?.id || null,
|
||
status: 'pending',
|
||
});
|
||
|
||
setIsSubmitted(true);
|
||
setTimeout(() => {
|
||
setCrash(null);
|
||
setIsSubmitted(false);
|
||
setUserDescription('');
|
||
}, 3000);
|
||
} catch (error) {
|
||
console.error('提交崩溃报告失败:', error);
|
||
// 即使失败也关闭弹窗
|
||
setIsSubmitted(true);
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleClose = () => {
|
||
setCrash(null);
|
||
setUserDescription('');
|
||
};
|
||
|
||
if (!crash) return null;
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[100] p-4">
|
||
<motion.div
|
||
initial={{ opacity: 0, scale: 0.95 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
exit={{ opacity: 0, scale: 0.95 }}
|
||
className="bg-white rounded-2xl w-full max-w-md shadow-2xl overflow-hidden"
|
||
>
|
||
{/* 头部 */}
|
||
<div className="bg-red-500 p-4 text-white">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center">
|
||
<AlertTriangle className="w-5 h-5" />
|
||
</div>
|
||
<div>
|
||
<h2 className="text-lg font-semibold">程序异常</h2>
|
||
<p className="text-xs text-white/80">发生了一个意外错误</p>
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={handleClose}
|
||
className="p-2 hover:bg-white/20 rounded-lg transition-colors"
|
||
>
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 内容 */}
|
||
<div className="p-4 space-y-4">
|
||
{isSubmitted ? (
|
||
<div className="text-center py-8">
|
||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||
<CheckCircle className="w-8 h-8 text-green-600" />
|
||
</div>
|
||
<h3 className="text-lg font-semibold text-gray-900 mb-2">报告已发送</h3>
|
||
<p className="text-sm text-gray-500">感谢您的反馈,我们会尽快修复</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* 错误信息 */}
|
||
<div className="bg-red-50 border border-red-100 rounded-xl p-3">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<Bug className="w-4 h-4 text-red-500" />
|
||
<span className="text-sm font-medium text-red-700">错误信息</span>
|
||
</div>
|
||
<p className="text-xs text-red-600 font-mono break-all">
|
||
{crash.error.message}
|
||
</p>
|
||
</div>
|
||
|
||
{/* 用户描述 */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
请描述您刚才的操作(选填)
|
||
</label>
|
||
<textarea
|
||
value={userDescription}
|
||
onChange={(e) => setUserDescription(e.target.value)}
|
||
placeholder="例如:我正在创建计划,点击保存后出现这个错误..."
|
||
rows={3}
|
||
className="w-full px-3 py-2 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-red-500/20 focus:border-red-500 resize-none"
|
||
/>
|
||
</div>
|
||
|
||
{/* 隐私说明 */}
|
||
<p className="text-xs text-gray-500">
|
||
崩溃报告将自动收集错误信息、浏览器版本和页面地址,帮助我们定位问题。不会收集您的业务数据。
|
||
</p>
|
||
|
||
{/* 按钮 */}
|
||
<div className="flex gap-3">
|
||
<button
|
||
onClick={handleClose}
|
||
className="flex-1 py-2.5 border border-gray-300 rounded-xl text-gray-700 hover:bg-gray-50 transition-colors text-sm font-medium"
|
||
>
|
||
关闭
|
||
</button>
|
||
<button
|
||
onClick={handleSubmit}
|
||
disabled={isSubmitting}
|
||
className="flex-1 py-2.5 bg-red-500 hover:bg-red-600 text-white rounded-xl transition-colors text-sm font-medium flex items-center justify-center gap-2"
|
||
>
|
||
{isSubmitting ? (
|
||
<>
|
||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||
发送中...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Send className="w-4 h-4" />
|
||
发送报告
|
||
</>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</motion.div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 错误边界组件
|
||
export class ErrorBoundary extends React.Component<
|
||
{ children: React.ReactNode },
|
||
{ hasError: boolean; error: Error | null; errorInfo: React.ErrorInfo | null }
|
||
> {
|
||
constructor(props: { children: React.ReactNode }) {
|
||
super(props);
|
||
this.state = { hasError: false, error: null, errorInfo: null };
|
||
}
|
||
|
||
static getDerivedStateFromError(error: Error) {
|
||
return { hasError: true, error };
|
||
}
|
||
|
||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||
this.setState({ error, errorInfo });
|
||
console.error('Error caught by boundary:', error, errorInfo);
|
||
|
||
// 触发崩溃报告
|
||
window.dispatchEvent(new CustomEvent('app-crash', {
|
||
detail: { error, errorInfo }
|
||
}));
|
||
}
|
||
|
||
render() {
|
||
if (this.state.hasError) {
|
||
return (
|
||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||
<div className="bg-white rounded-2xl p-8 max-w-md w-full text-center shadow-xl">
|
||
<div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||
</div>
|
||
<h2 className="text-xl font-bold text-gray-900 mb-2">出错了</h2>
|
||
<p className="text-sm text-gray-500 mb-6">
|
||
应用程序遇到了问题,请刷新页面重试
|
||
</p>
|
||
<button
|
||
onClick={() => window.location.reload()}
|
||
className="w-full py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-xl transition-colors font-medium"
|
||
>
|
||
刷新页面
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return this.props.children;
|
||
}
|
||
}
|