319 lines
9.2 KiB
TypeScript
319 lines
9.2 KiB
TypeScript
/**
|
|
* 轻量级顶部通知组件
|
|
*
|
|
* 在页面顶部显示简单的提示信息,带有图标,自动换行,有最大宽度和高度限制
|
|
* 支持自动关闭、点击关闭
|
|
* 能根据文字长度自动调整宽度,超出最大宽度则自动换行
|
|
*/
|
|
|
|
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<HTMLElement | null>(null);
|
|
const [messageLines, setMessageLines] = useState<number>(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 <i className="ri-check-line toast-icon success"></i>;
|
|
case 'error':
|
|
return <i className="ri-close-circle-line toast-icon error"></i>;
|
|
case 'warning':
|
|
return <i className="ri-alert-line toast-icon warning"></i>;
|
|
case 'info':
|
|
default:
|
|
return <i className="ri-information-line toast-icon info"></i>;
|
|
}
|
|
};
|
|
|
|
// 键盘事件处理
|
|
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) => (
|
|
<div key={index} className="toast-message-line">
|
|
{fixChineseWrapping(line)}
|
|
</div>
|
|
));
|
|
}
|
|
|
|
// 如果没有换行符,直接返回整个文本,应用修复
|
|
return fixChineseWrapping(cleanedText);
|
|
};
|
|
|
|
// 如果通知未打开,不渲染
|
|
if (!isOpen || !portalElement) {
|
|
return null;
|
|
}
|
|
|
|
// 使用 Portal 渲染通知
|
|
return createPortal(
|
|
<div
|
|
className={`toast toast-${type} ${isClosing ? 'closing' : ''} ${className} ${messageLines > 1 ? 'toast-multiline' : ''}`}
|
|
role="status"
|
|
aria-live="polite"
|
|
onMouseEnter={handleMouseEnter}
|
|
onMouseLeave={handleMouseLeave}
|
|
style={{ zIndex: 999999, position: 'relative' }}
|
|
>
|
|
<div className="toast-content">
|
|
<div className="toast-icon-wrapper">
|
|
{renderIcon()}
|
|
</div>
|
|
<div className="toast-message">
|
|
{formatMessage(message)}
|
|
</div>
|
|
</div>
|
|
<button
|
|
className="toast-close"
|
|
onClick={handleClose}
|
|
aria-label="关闭"
|
|
onKeyDown={handleKeyDown}
|
|
>
|
|
<i className="ri-close-line"></i>
|
|
</button>
|
|
</div>,
|
|
portalElement
|
|
);
|
|
}
|
|
|
|
// 创建全局通知服务
|
|
type ShowToastOptions = Omit<ToastProps, 'isOpen' | 'onClose'>;
|
|
|
|
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<ShowToastOptions>): void {
|
|
this.show({ ...options, message, type: 'success' });
|
|
}
|
|
|
|
error(message: string, options?: Partial<ShowToastOptions>): void {
|
|
this.show({ ...options, message, type: 'error' });
|
|
}
|
|
|
|
warning(message: string, options?: Partial<ShowToastOptions>): void {
|
|
this.show({ ...options, message, type: 'warning' });
|
|
}
|
|
|
|
info(message: string, options?: Partial<ShowToastOptions>): 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) => (
|
|
<Toast
|
|
key={toast.id}
|
|
isOpen={true}
|
|
onClose={() => 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}
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
}
|