382 lines
12 KiB
TypeScript
382 lines
12 KiB
TypeScript
'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';
|