Files
carrotskin/src/components/MessageNotification.tsx

382 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
XMarkIcon,
ExclamationTriangleIcon,
CheckCircleIcon,
InformationCircleIcon,
XCircleIcon,
BellIcon
} from '@heroicons/react/24/outline';
export type MessageType = 'success' | 'error' | 'warning' | 'info' | 'loading';
export interface MessageNotificationProps {
message: string;
type?: MessageType;
duration?: number;
onClose?: () => void;
title?: string;
position?: 'top-left' | 'top-right' | 'top-center' | 'bottom-left' | 'bottom-right' | 'bottom-center';
showProgress?: boolean;
closable?: boolean;
action?: {
label: string;
onClick: () => void;
};
}
interface Message extends MessageNotificationProps {
id: string;
createdAt: number;
}
export function MessageNotification({
message,
type = 'info',
duration = 3000,
onClose,
title,
position = 'top-right',
showProgress = true,
closable = true,
action
}: MessageNotificationProps) {
const [isVisible, setIsVisible] = useState(true);
const [remainingTime, setRemainingTime] = useState(duration);
useEffect(() => {
if (duration > 0 && type !== 'loading') {
const timer = setTimeout(() => {
setIsVisible(false);
onClose?.();
}, duration);
// 进度条更新
const progressTimer = setInterval(() => {
setRemainingTime(prev => {
const newTime = prev - 100;
if (newTime <= 0) {
clearInterval(progressTimer);
}
return newTime;
});
}, 100);
return () => {
clearTimeout(timer);
clearInterval(progressTimer);
};
}
}, [duration, onClose, type]);
const handleClose = () => {
setIsVisible(false);
onClose?.();
};
const getIcon = () => {
const iconClass = "w-5 h-5";
switch (type) {
case 'error':
return <XCircleIcon className={iconClass} />;
case 'warning':
return <ExclamationTriangleIcon className={iconClass} />;
case 'success':
return <CheckCircleIcon className={iconClass} />;
case 'loading':
return (
<div className="animate-spin">
<svg className={iconClass} viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
);
case 'info':
default:
return <InformationCircleIcon className={iconClass} />;
}
};
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',
action: 'bg-red-500 hover:bg-red-600 text-white'
};
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',
action: 'bg-yellow-500 hover:bg-yellow-600 text-white'
};
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',
action: 'bg-green-500 hover:bg-green-600 text-white'
};
case 'loading':
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',
action: 'bg-blue-500 hover:bg-blue-600 text-white'
};
case 'info':
default:
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',
action: 'bg-blue-500 hover:bg-blue-600 text-white'
};
}
};
const getPositionStyles = () => {
switch (position) {
case 'top-left':
return 'top-4 left-4';
case 'top-center':
return 'top-4 left-1/2 -translate-x-1/2';
case 'top-right':
return 'top-4 right-4';
case 'bottom-left':
return 'bottom-4 left-4';
case 'bottom-center':
return 'bottom-4 left-1/2 -translate-x-1/2';
case 'bottom-right':
return 'bottom-4 right-4';
default:
return 'top-4 right-4';
}
};
const styles = getStyles();
const positionStyles = getPositionStyles();
const progressPercentage = duration > 0 ? (remainingTime / duration) * 100 : 0;
return (
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0, y: -20, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -20, scale: 0.9 }}
transition={{ duration: 0.3, ease: 'easeOut' }}
className={`fixed z-[9999] max-w-sm w-full ${positionStyles}`}
>
<div className={`${styles.bg} ${styles.border} border rounded-xl shadow-lg backdrop-blur-sm overflow-hidden`}>
<div className="flex items-start p-4">
<div className={`flex-shrink-0 ${styles.icon} mr-3 mt-0.5`}>
{getIcon()}
</div>
<div className="flex-1 min-w-0">
{title && (
<h4 className={`text-sm font-semibold ${styles.text} mb-1`}>
{title}
</h4>
)}
<p className={`text-sm ${styles.text}`}>
{message}
</p>
{action && (
<button
onClick={action.onClick}
className={`mt-2 px-3 py-1 text-xs font-medium rounded-md transition-colors ${styles.action}`}
>
{action.label}
</button>
)}
</div>
{closable && (
<button
onClick={handleClose}
className={`flex-shrink-0 ml-3 ${styles.close} transition-colors`}
>
<XMarkIcon className="w-4 h-4" />
</button>
)}
</div>
{showProgress && duration > 0 && type !== 'loading' && (
<div className="h-1 bg-gray-200 dark:bg-gray-700 overflow-hidden">
<motion.div
className={`h-full ${styles.progress}`}
initial={{ width: '100%' }}
animate={{ width: `${progressPercentage}%` }}
transition={{ duration: 0.1, ease: 'linear' }}
/>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
);
}
// 全局消息管理器
class MessageManager {
private static instance: MessageManager;
private listeners: Array<(message: Message) => void> = [];
static getInstance(): MessageManager {
if (!MessageManager.instance) {
MessageManager.instance = new MessageManager();
}
return MessageManager.instance;
}
// 基础消息方法
show(message: string, options?: Omit<MessageNotificationProps, 'message'>) {
return this.addMessage(message, 'info', options);
}
success(message: string, options?: Omit<MessageNotificationProps, 'message'>) {
return this.addMessage(message, 'success', options);
}
error(message: string, options?: Omit<MessageNotificationProps, 'message'>) {
return this.addMessage(message, 'error', options);
}
warning(message: string, options?: Omit<MessageNotificationProps, 'message'>) {
return this.addMessage(message, 'warning', options);
}
info(message: string, options?: Omit<MessageNotificationProps, 'message'>) {
return this.addMessage(message, 'info', options);
}
loading(message: string, options?: Omit<MessageNotificationProps, 'message'>) {
return this.addMessage(message, 'loading', { ...options, duration: 0 });
}
// 隐藏加载消息
hideLoading(id?: string) {
if (id) {
this.listeners.forEach(listener => listener({ id, message: '', type: 'loading', createdAt: Date.now() } as Message));
}
}
private addMessage(message: string, type: MessageType, options?: Omit<MessageNotificationProps, 'message'>) {
const id = Math.random().toString(36).substr(2, 9);
const notification: Message = {
id,
message,
type,
createdAt: Date.now(),
...options
};
this.listeners.forEach(listener => listener(notification));
return id;
}
subscribe(listener: (message: Message) => void) {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter(l => l !== listener);
};
}
}
export const messageManager = MessageManager.getInstance();
// 消息提示容器组件
export function MessageNotificationContainer() {
const [messages, setMessages] = useState<Message[]>([]);
useEffect(() => {
const unsubscribe = messageManager.subscribe((message) => {
if (message.type === 'loading' && message.message === '') {
// 隐藏加载消息
setMessages(prev => prev.filter(m => m.id !== message.id));
} else {
setMessages(prev => [...prev, message]);
}
});
return unsubscribe;
}, []);
const removeMessage = (id: string) => {
setMessages(prev => prev.filter(m => m.id !== id));
};
// 按位置分组消息
const messagesByPosition = messages.reduce((acc, message) => {
const position = message.position || 'top-right';
if (!acc[position]) {
acc[position] = [];
}
acc[position].push(message);
return acc;
}, {} as Record<string, Message[]>);
return (
<>
{Object.entries(messagesByPosition).map(([position, positionMessages]) => (
<div key={position} className="fixed inset-0 pointer-events-none z-[9999]">
<div className={`absolute ${getPositionContainerStyles(position)} space-y-2 pointer-events-auto`}>
{positionMessages.map((message) => (
<MessageNotification
key={message.id}
{...message}
onClose={() => removeMessage(message.id)}
/>
))}
</div>
</div>
))}
</>
);
}
function getPositionContainerStyles(position: string) {
switch (position) {
case 'top-left':
return 'top-4 left-4';
case 'top-center':
return 'top-4 left-1/2 -translate-x-1/2';
case 'top-right':
return 'top-4 right-4';
case 'bottom-left':
return 'bottom-4 left-4';
case 'bottom-center':
return 'bottom-4 left-1/2 -translate-x-1/2';
case 'bottom-right':
return 'bottom-4 right-4';
default:
return 'top-4 right-4';
}
}
// 兼容层保持与ErrorNotification的兼容性
export { errorManager } from './ErrorNotification';
export type { ErrorType } from './ErrorNotification';