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';
|