/** * 轻量级顶部通知组件 * * 在页面顶部显示简单的提示信息,带有图标,自动换行,有最大宽度和高度限制 * 支持自动关闭、点击关闭 * 能根据文字长度自动调整宽度,超出最大宽度则自动换行 */ import { useState, useEffect, useCallback } from 'react'; import { createPortal } from 'react-dom'; import toastStyles from '~/styles/components/toast.css?url'; // 通知类型 export type ToastType = 'success' | 'error' | 'warning' | 'info'; // 组件属性 interface ToastProps { // 是否显示通知 isOpen: boolean; // 关闭通知的回调 onClose: () => void; // 通知内容 message: string; // 通知类型 type?: ToastType; // 是否自动关闭 autoClose?: boolean; // 自动关闭的延迟时间(毫秒) autoCloseDelay?: number; // 自定义图标 customIcon?: React.ReactNode; // 自定义CSS类名 className?: string; } // 默认自动关闭延迟 const DEFAULT_AUTO_CLOSE_DELAY = 3000; // 导出样式 export function links() { return [{ rel: 'stylesheet', href: toastStyles }]; } export function Toast({ isOpen, onClose, message, type = 'info', autoClose = true, autoCloseDelay = DEFAULT_AUTO_CLOSE_DELAY, customIcon, className = '' }: ToastProps) { const [isClosing, setIsClosing] = useState(false); const [portalElement, setPortalElement] = useState(null); const [messageLines, setMessageLines] = useState(1); const [isHovered, setIsHovered] = useState(false); // 在客户端渲染时获取 portal 容器 useEffect(() => { if (typeof document !== 'undefined') { let element = document.getElementById('toast-portal'); if (!element) { element = document.createElement('div'); element.id = 'toast-portal'; element.className = 'toast-container'; document.body.appendChild(element); } setPortalElement(element); } }, []); // 计算消息行数(用于可能的额外样式调整) useEffect(() => { if (message) { // 更好地估算中文文本行数:假设每行平均25个中文字符(或50个英文字符) const estimatedChars = message.split('').reduce((count, char) => { // 判断是否为中文字符(粗略判断) const isChinese = /[\u4e00-\u9fa5]/.test(char); return count + (isChinese ? 2 : 1); // 中文字符算2个宽度单位 }, 0); const estimatedLines = Math.ceil(estimatedChars / 50); setMessageLines(Math.max(1, Math.min(estimatedLines, 10))); // 最小1行,最大10行 } }, [message]); // 处理关闭动画 const handleClose = useCallback(() => { setIsClosing(true); setTimeout(() => { setIsClosing(false); onClose(); }, 300); // 动画持续时间 }, [onClose]); // 自动关闭 useEffect(() => { if (isOpen && autoClose && !isHovered) { // const messageLength = message.length; // const baseDelay = autoCloseDelay || DEFAULT_AUTO_CLOSE_DELAY; // // 按照文本长度比例延长显示时间 // const adjustedDelay = Math.min( // baseDelay + (messageLength > 20 ? messageLength * 30 : 0), // 15000 // 最长不超过15秒 // ); // 不再根据消息长度调整显示时间,使用固定的延迟时间 const delay = autoCloseDelay || DEFAULT_AUTO_CLOSE_DELAY; const timer = setTimeout(() => { handleClose(); // }, adjustedDelay); }, delay); return () => clearTimeout(timer); } // }, [isOpen, autoClose, autoCloseDelay, handleClose, message, isHovered]); }, [isOpen, autoClose, autoCloseDelay, handleClose, isHovered]); // 鼠标悬停处理 const handleMouseEnter = () => { setIsHovered(true); }; const handleMouseLeave = () => { setIsHovered(false); }; // 渲染图标 const renderIcon = () => { if (customIcon) { return customIcon; } switch (type) { case 'success': return ; case 'error': return ; case 'warning': return ; case 'info': default: return ; } }; // 键盘事件处理 const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Escape') { handleClose(); } }, [handleClose]); // 处理长文本 const formatMessage = (text: string) => { // 清理文本中的多余空白,但保留换行符 const cleanedText = text.replace(/[ \t]+/g, ' '); // 处理中文文本最后两个字符换行问题 const fixChineseWrapping = (content: string) => { // 如果内容长度小于3,或已有换行符,直接返回 if (content.length < 3 || content.includes('\n')) { return content; } // 检测是否是中文文本 const isChinese = /[\u4e00-\u9fa5]/.test(content); if (!isChinese) { return content; } // 在中文文本中,添加零宽空格避免最后两个字符换行 // 在文本末尾前两个字符之间添加零宽不换行空格,防止它们被分开 return content.slice(0, -2) + '\u2060' + content.slice(-2); }; // 如果文本包含换行符,按换行符分割 if (cleanedText.includes('\n')) { return cleanedText.split('\n').map((line, index) => (
{fixChineseWrapping(line)}
)); } // 如果没有换行符,直接返回整个文本,应用修复 return fixChineseWrapping(cleanedText); }; // 如果通知未打开,不渲染 if (!isOpen || !portalElement) { return null; } // 使用 Portal 渲染通知 return createPortal(
1 ? 'toast-multiline' : ''}`} role="status" aria-live="polite" onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} >
{renderIcon()}
{formatMessage(message)}
, portalElement ); } // 创建全局通知服务 type ShowToastOptions = Omit; class ToastService { private static instance: ToastService; private showToast: ((options: ShowToastOptions) => void) | null = null; private constructor() {} static getInstance(): ToastService { if (!ToastService.instance) { ToastService.instance = new ToastService(); } return ToastService.instance; } registerShowToast(showFn: (options: ShowToastOptions) => void): void { this.showToast = showFn; } success(message: string, options?: Partial): void { this.show({ ...options, message, type: 'success' }); } error(message: string, options?: Partial): void { this.show({ ...options, message, type: 'error' }); } warning(message: string, options?: Partial): void { this.show({ ...options, message, type: 'warning' }); } info(message: string, options?: Partial): void { this.show({ ...options, message, type: 'info' }); } show(options: ShowToastOptions): void { if (this.showToast) { this.showToast(options); } else { console.error('ToastService: showToast is not registered'); } } } export const toastService = ToastService.getInstance(); /** * 全局通知容器 * 用于在应用根组件中挂载通知服务 */ export function ToastProvider({ children }: { children: React.ReactNode }) { const [toastQueue, setToastQueue] = useState<(ShowToastOptions & { id: string })[]>([]); const maxToasts = 5; // 增加最大显示的通知数量 useEffect(() => { toastService.registerShowToast((options) => { const id = Math.random().toString(36).substring(2, 9); setToastQueue(prev => { // 如果已经有太多通知,移除最早的 if (prev.length >= maxToasts) { return [...prev.slice(1), { ...options, id }]; } return [...prev, { ...options, id }]; }); }); }, []); const handleCloseToast = (id: string) => { setToastQueue(prev => prev.filter(toast => toast.id !== id)); }; return ( <> {children} {toastQueue.map((toast) => ( handleCloseToast(toast.id)} message={toast.message} type={toast.type} autoClose={toast.autoClose !== undefined ? toast.autoClose : true} autoCloseDelay={toast.autoCloseDelay} customIcon={toast.customIcon} className={toast.className} /> ))} ); }