forked from CarrotSkin/carrotskin
317 lines
9.8 KiB
TypeScript
317 lines
9.8 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { XMarkIcon, ExclamationTriangleIcon, CheckCircleIcon, InformationCircleIcon } from '@heroicons/react/24/outline';
|
|
|
|
export type ErrorType = 'error' | 'warning' | 'success' | 'info';
|
|
|
|
interface ErrorNotificationProps {
|
|
message: string;
|
|
type?: ErrorType;
|
|
duration?: number;
|
|
onClose?: () => void;
|
|
}
|
|
|
|
export function ErrorNotification({ message, type = 'error', duration = 5000, onClose }: ErrorNotificationProps) {
|
|
const [isVisible, setIsVisible] = useState(true);
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
const [progress, setProgress] = useState(100);
|
|
|
|
useEffect(() => {
|
|
if (duration > 0 && !isHovered) {
|
|
const startTime = Date.now();
|
|
const timer = setInterval(() => {
|
|
const elapsed = Date.now() - startTime;
|
|
const remaining = Math.max(0, duration - elapsed);
|
|
setProgress((remaining / duration) * 100);
|
|
|
|
if (remaining === 0) {
|
|
setIsVisible(false);
|
|
onClose?.();
|
|
}
|
|
}, 50);
|
|
|
|
return () => clearInterval(timer);
|
|
}
|
|
}, [duration, onClose, isHovered]);
|
|
|
|
const handleClose = () => {
|
|
setIsVisible(false);
|
|
onClose?.();
|
|
};
|
|
|
|
const getIcon = () => {
|
|
switch (type) {
|
|
case 'error':
|
|
return <ExclamationTriangleIcon className="w-5 h-5" />;
|
|
case 'warning':
|
|
return <ExclamationTriangleIcon className="w-5 h-5" />;
|
|
case 'success':
|
|
return <CheckCircleIcon className="w-5 h-5" />;
|
|
case 'info':
|
|
return <InformationCircleIcon className="w-5 h-5" />;
|
|
}
|
|
};
|
|
|
|
const getStyles = () => {
|
|
switch (type) {
|
|
case 'error':
|
|
return {
|
|
bg: 'bg-red-50 dark:bg-red-900/20',
|
|
border: 'border-red-200 dark:border-red-800',
|
|
text: 'text-red-800 dark:text-red-200',
|
|
icon: 'text-red-500',
|
|
close: 'text-red-400 hover:text-red-600 dark:text-red-300 dark:hover:text-red-100',
|
|
progress: 'bg-red-500'
|
|
};
|
|
case 'warning':
|
|
return {
|
|
bg: 'bg-yellow-50 dark:bg-yellow-900/20',
|
|
border: 'border-yellow-200 dark:border-yellow-800',
|
|
text: 'text-yellow-800 dark:text-yellow-200',
|
|
icon: 'text-yellow-500',
|
|
close: 'text-yellow-400 hover:text-yellow-600 dark:text-yellow-300 dark:hover:text-yellow-100',
|
|
progress: 'bg-yellow-500'
|
|
};
|
|
case 'success':
|
|
return {
|
|
bg: 'bg-green-50 dark:bg-green-900/20',
|
|
border: 'border-green-200 dark:border-green-800',
|
|
text: 'text-green-800 dark:text-green-200',
|
|
icon: 'text-green-500',
|
|
close: 'text-green-400 hover:text-green-600 dark:text-green-300 dark:hover:text-green-100',
|
|
progress: 'bg-green-500'
|
|
};
|
|
case 'info':
|
|
return {
|
|
bg: 'bg-blue-50 dark:bg-blue-900/20',
|
|
border: 'border-blue-200 dark:border-blue-800',
|
|
text: 'text-blue-800 dark:text-blue-200',
|
|
icon: 'text-blue-500',
|
|
close: 'text-blue-400 hover:text-blue-600 dark:text-blue-300 dark:hover:text-blue-100',
|
|
progress: 'bg-blue-500'
|
|
};
|
|
}
|
|
};
|
|
|
|
const styles = getStyles();
|
|
|
|
const getAnimationVariants = () => {
|
|
switch (type) {
|
|
case 'error':
|
|
return {
|
|
initial: { opacity: 0, x: 100, scale: 0.8, rotate: -5 },
|
|
animate: { opacity: 1, x: 0, scale: 1, rotate: 0 },
|
|
exit: { opacity: 0, x: 100, scale: 0.8, rotate: 5 },
|
|
};
|
|
case 'warning':
|
|
return {
|
|
initial: { opacity: 0, y: -20, scale: 0.9 },
|
|
animate: { opacity: 1, y: 0, scale: 1 },
|
|
exit: { opacity: 0, y: -20, scale: 0.9 },
|
|
};
|
|
case 'success':
|
|
return {
|
|
initial: { opacity: 0, scale: 0.5 },
|
|
animate: { opacity: 1, scale: 1 },
|
|
exit: { opacity: 0, scale: 0.5 },
|
|
};
|
|
case 'info':
|
|
return {
|
|
initial: { opacity: 0, x: -20, scale: 0.9 },
|
|
animate: { opacity: 1, x: 0, scale: 1 },
|
|
exit: { opacity: 0, x: -20, scale: 0.9 },
|
|
};
|
|
}
|
|
};
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{isVisible && (
|
|
<motion.div
|
|
{...getAnimationVariants()}
|
|
transition={{
|
|
duration: 0.4,
|
|
ease: 'easeOut',
|
|
type: 'spring',
|
|
stiffness: 300,
|
|
damping: 20
|
|
}}
|
|
className={`fixed top-4 right-4 z-[9999] max-w-lg w-full ${styles.bg} ${styles.border} border rounded-xl shadow-2xl backdrop-blur-lg overflow-hidden`}
|
|
onMouseEnter={() => setIsHovered(true)}
|
|
onMouseLeave={() => setIsHovered(false)}
|
|
whileHover={{ scale: 1.02, y: -2 }}
|
|
>
|
|
{/* Progress bar */}
|
|
<div className="absolute bottom-0 left-0 h-1 bg-gray-200/50 dark:bg-gray-700/50 w-full">
|
|
<motion.div
|
|
className={`h-full ${styles.progress}`}
|
|
initial={{ width: '100%' }}
|
|
animate={{ width: `${progress}%` }}
|
|
transition={{ duration: 0.1, ease: 'linear' }}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-start p-4">
|
|
<motion.div
|
|
className={`flex-shrink-0 ${styles.icon} mr-3 mt-0.5`}
|
|
animate={{
|
|
scale: type === 'error' ? [1, 1.1, 1] : 1,
|
|
rotate: type === 'warning' ? [0, -5, 5, 0] : 0
|
|
}}
|
|
transition={{
|
|
duration: type === 'error' ? 0.5 : 0.3,
|
|
repeat: type === 'error' ? Infinity : 0,
|
|
repeatDelay: 2
|
|
}}
|
|
>
|
|
{getIcon()}
|
|
</motion.div>
|
|
<div className="flex-1">
|
|
<p className={`text-sm font-medium ${styles.text} leading-relaxed`}>
|
|
{message}
|
|
</p>
|
|
</div>
|
|
<motion.button
|
|
onClick={handleClose}
|
|
className={`flex-shrink-0 ml-3 ${styles.close} transition-all duration-200 p-1 rounded-full hover:bg-white/20 dark:hover:bg-black/20`}
|
|
whileHover={{ scale: 1.1, rotate: 90 }}
|
|
whileTap={{ scale: 0.9 }}
|
|
>
|
|
<XMarkIcon className="w-5 h-5" />
|
|
</motion.button>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
}
|
|
|
|
// 增强的全局错误管理器
|
|
class ErrorManager {
|
|
private static instance: ErrorManager;
|
|
private listeners: Array<(notification: ErrorNotificationProps & { id: string }) => void> = [];
|
|
private soundEnabled: boolean = true;
|
|
|
|
static getInstance(): ErrorManager {
|
|
if (!ErrorManager.instance) {
|
|
ErrorManager.instance = new ErrorManager();
|
|
}
|
|
return ErrorManager.instance;
|
|
}
|
|
|
|
private playSound(type: ErrorType) {
|
|
if (!this.soundEnabled) return;
|
|
|
|
// 创建音频反馈
|
|
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
|
const oscillator = audioContext.createOscillator();
|
|
const gainNode = audioContext.createGain();
|
|
|
|
oscillator.connect(gainNode);
|
|
gainNode.connect(audioContext.destination);
|
|
|
|
const frequencies = {
|
|
error: 300,
|
|
warning: 400,
|
|
success: 600,
|
|
info: 500
|
|
};
|
|
|
|
oscillator.frequency.setValueAtTime(frequencies[type], audioContext.currentTime);
|
|
oscillator.type = type === 'error' ? 'sawtooth' : 'sine';
|
|
|
|
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime);
|
|
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
|
|
|
|
oscillator.start(audioContext.currentTime);
|
|
oscillator.stop(audioContext.currentTime + 0.3);
|
|
}
|
|
|
|
showError(message: string, duration?: number) {
|
|
this.playSound('error');
|
|
this.showNotification(message, 'error', duration);
|
|
}
|
|
|
|
showWarning(message: string, duration?: number) {
|
|
this.playSound('warning');
|
|
this.showNotification(message, 'warning', duration);
|
|
}
|
|
|
|
showSuccess(message: string, duration?: number) {
|
|
this.playSound('success');
|
|
this.showNotification(message, 'success', duration);
|
|
}
|
|
|
|
showInfo(message: string, duration?: number) {
|
|
this.playSound('info');
|
|
this.showNotification(message, 'info', duration);
|
|
}
|
|
|
|
private showNotification(message: string, type: ErrorType, duration?: number) {
|
|
const notification = {
|
|
id: Math.random().toString(36).substr(2, 9),
|
|
message,
|
|
type,
|
|
duration: duration ?? (type === 'error' ? 5000 : 3000)
|
|
};
|
|
|
|
this.listeners.forEach(listener => listener(notification));
|
|
}
|
|
|
|
subscribe(listener: (notification: ErrorNotificationProps & { id: string }) => void) {
|
|
this.listeners.push(listener);
|
|
return () => {
|
|
this.listeners = this.listeners.filter(l => l !== listener);
|
|
};
|
|
}
|
|
|
|
setSoundEnabled(enabled: boolean) {
|
|
this.soundEnabled = enabled;
|
|
}
|
|
}
|
|
|
|
export const errorManager = ErrorManager.getInstance();
|
|
|
|
// 增强的错误提示容器组件
|
|
export function ErrorNotificationContainer() {
|
|
const [notifications, setNotifications] = useState<Array<ErrorNotificationProps & { id: string }>>([]);
|
|
const [soundEnabled, setSoundEnabled] = useState(true);
|
|
|
|
useEffect(() => {
|
|
const unsubscribe = errorManager.subscribe((notification) => {
|
|
setNotifications(prev => [...prev, notification]);
|
|
});
|
|
|
|
return unsubscribe;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
errorManager.setSoundEnabled(soundEnabled);
|
|
}, [soundEnabled]);
|
|
|
|
const removeNotification = (id: string) => {
|
|
setNotifications(prev => prev.filter(n => n.id !== id));
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{notifications.map((notification, index) => (
|
|
<motion.div
|
|
key={notification.id}
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: index * 0.1 }}
|
|
>
|
|
<ErrorNotification
|
|
{...notification}
|
|
onClose={() => removeNotification(notification.id)}
|
|
/>
|
|
</motion.div>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|