/** * MetonaToast Core - 核心Toast逻辑 * @module core * @version 2.0.0 * @description 重构后的核心模块,包含Toast类的优化实现 */ import { generateId, escapeHTML } from './utils.js'; import { DEFAULTS, ICONS, TYPE_COLORS, THEMES } from './constants.js'; import { injectStyles } from './styles.js'; import { getTheme, applyTheme } from './themes.js'; import { t as i18nT, setCurrentLocale, getLocaleDirection, getCurrentLocale } from './i18n.js'; // 模块级共享容器缓存,所有 Toast 实例共用 // 使用 Map 而非 WeakMap:document.body 在页面存活期间永不被 GC, // 容器需要显式通过 destroy() 清理,WeakMap 在此场景无实际收益。 const _containerCache = new Map(); /** * 参数标准化工具 * 支持多种调用方式: * - success('message') * - success({ message: 'text' }) * - success({ title: 'title', message: 'text' }) * - success('message', { duration: 5000 }) */ const normalizeArgs = (args, defaultType = 'default') => { const [first, second] = args; // 情况1: success('message') if (typeof first === 'string') { return { ...second, type: second?.type || defaultType, message: first, }; } // 情况2: success({ message: 'text' }) 或 success({ title: 't', message: 'm' }) if (first && typeof first === 'object') { return { ...first, type: first.type || defaultType, }; } // 情况3: success() 无参数 return { type: defaultType, message: '', }; }; /** * Toast类 - 核心通知组件 */ class Toast { // 静态钩子系统 static _hooks = new Map(); static on(name, fn) { if (!this._hooks.has(name)) this._hooks.set(name, []); this._hooks.get(name).push(fn); return () => this.off(name, fn); } static off(name, fn) { const list = this._hooks.get(name); if (list) this._hooks.set(name, list.filter(f => f !== fn)); } static trigger(name, toast) { const list = this._hooks.get(name); if (list) list.forEach(fn => { try { fn(toast); } catch (e) { console.error('Hook error:', name, e); } }); } constructor(opts) { this.id = opts.id || generateId(); this.type = opts.type || 'default'; this.title = opts.title || ''; this.message = opts.message ?? opts.content ?? ''; this.html = opts.html || ''; this.iconHTML = opts.iconHTML || ''; this.config = { ...DEFAULTS, ...opts }; // 防止嵌套对象被多个 toast 实例共享引用 if (opts.style && typeof opts.style === 'object') { this.config.style = { ...opts.style }; } this.el = null; this.barEl = null; this.rafId = null; this.remaining = this.config.duration; this.startedAt = 0; this.paused = false; this.closing = false; this._cleanups = []; this.group = opts.group || null; } _palette() { const theme = getTheme(this.config.theme); const c = TYPE_COLORS[this.type] || TYPE_COLORS.default; const tc = THEMES[theme] || THEMES.light; const t = { bg: tc.bg, border: tc.border, shadow: tc.shadow }; return { theme, c, t }; } create() { if (typeof window === 'undefined' || typeof document === 'undefined') return this; Toast.trigger('beforeShow', this); injectStyles(); const container = this._getContainer(); const { theme, c, t } = this._palette(); this._limitToasts(container); const el = document.createElement('div'); el.className = this._buildClassName(theme); el.setAttribute('role', (this.type === 'error' || this.type === 'warning') ? 'alert' : 'status'); el.setAttribute('aria-live', this.type === 'error' ? 'assertive' : 'polite'); el.dataset.id = this.id; this._applyStyles(el, theme, t); this._buildContent(el, c); this.el = el; // Chrome bug: column-reverse + appendChild 会导致重叠。改用 column + insertBefore const pos = this.config.position || 'top-right'; if (pos.startsWith('top')) { container.insertBefore(el, container.firstChild); } else { container.appendChild(el); } this._bindEvents(el); this._startTimer(); // 进入动画 requestAnimationFrame(() => { requestAnimationFrame(() => { el.classList.add('met-show'); }); }); if (typeof this.config.onShow === 'function') { try { this.config.onShow(this); } catch (e) { console.error('onShow callback error:', e); } } Toast.trigger('afterShow', this); return this; } _getContainer() { const position = this.config.position || 'top-right'; const zIndex = this.config.zIndex || 9999; if (_containerCache.has(document.body)) { const containers = _containerCache.get(document.body); if (containers.has(position)) { return containers.get(position); } } else { _containerCache.set(document.body, new Map()); } const el = document.createElement('div'); el.className = `met-container ${position}`; el.setAttribute('aria-label', 'Notifications'); el.setAttribute('role', 'region'); // cssText 完全接管布局,不依赖 CSS class const posStyles = { 'top-left': 'top:0;left:0;align-items:flex-start', 'top-center': 'top:0;left:0;right:0;align-items:center', 'top-right': 'top:0;right:0;align-items:flex-end', 'bottom-left': 'bottom:0;left:0;align-items:flex-start', 'bottom-center':'bottom:0;left:0;right:0;align-items:center', 'bottom-right': 'bottom:0;right:0;align-items:flex-end', }; el.style.cssText = ` display:flex; flex-direction:column; gap:12px; box-sizing:border-box; padding:24px; max-width:100vw; position:fixed; z-index:${zIndex}; pointer-events:none; ${posStyles[position] || 'top:0;right:0;align-items:flex-end'} `; document.body.appendChild(el); _containerCache.get(document.body).set(position, el); return el; } _limitToasts(container) { const max = this.config.max || 6; const list = Array.from(container.querySelectorAll('.met-toast')); if (list.length >= max) { const first = list[0]; const id = first && first.dataset.id; if (id) { const old = meToast._toasts.get(id); if (old) old.close(true); } } } _buildClassName(theme) { // CSS 支持的动画类型,未列出的 fallback 到 slide const CSS_ANIMS = ['slide','fade','scale','bounce','flip','rotate','zoom', 'slideUp','slideDown','slideLeft','slideRight']; const anim = CSS_ANIMS.includes(this.config.animation) ? this.config.animation : 'slide'; return [ 'met-toast', `met-${this.type}`, `met-theme-${theme}`, `met-anim-${anim}`, (this.config.closeOnClick || this.config.draggable) ? 'met-clickable' : '', this.config.className || '', ].filter(Boolean).join(' '); } _applyStyles(el, theme, t) { const widthValue = typeof this.config.width === 'number' ? `${this.config.width}px` : (typeof this.config.width === 'string' ? this.config.width : '360px'); // 只强制核心布局属性,动画/滤镜/颜色由 CSS class 控制 const set = (p, v) => el.style.setProperty(p, v, 'important'); set('position', 'relative'); set('flex-shrink', '0'); set('min-width', '240px'); set('max-width', 'calc(100vw - 48px)'); set('width', widthValue); // 用户自定义样式最后应用 if (this.config.style) { Object.entries(this.config.style).forEach(([k, v]) => { el.style[k] = v; }); } } _buildContent(el, c) { // 自定义渲染函数 — 完全接管 DOM 构建 if (typeof this.config.render === 'function') { el.innerHTML = this.config.render(this); this.barEl = el.querySelector('.met-bar, .met-bar-v'); return; } const showIcon = this.config.icon !== false && (this.iconHTML || ICONS[this.type]); const showClose = this.config.closeButton !== false; const showProgress = this.config.showProgress !== false && this.config.duration > 0; const showSide = (!showIcon && this.type !== 'default'); const safeTitle = this.title ? `
${escapeHTML(this.title)}
` : ''; const safeMessage = this.html ? `
${this.html}
` : (this.message ? `
${escapeHTML(this.message)}
` : ''); const iconHTML = showIcon ? `
${this.iconHTML || ICONS[this.type]}
` : ''; const closeHTML = showClose ? `` : ''; const progressHTML = showProgress ? (this.config.progressDirection === 'vertical' ? `
` : `
`) : ''; const sideHTML = showSide ? `
` : ''; el.innerHTML = ` ${iconHTML} ${sideHTML}
${safeTitle} ${safeMessage}
${closeHTML} ${progressHTML} `; this.barEl = el.querySelector('.met-bar, .met-bar-v'); } _bindEvents(el) { const eventHandler = (e) => { const target = e.target; if (target.closest('.met-close')) { e.stopPropagation(); this.close(); return; } if (this.config.closeOnClick && !target.closest('.met-close')) { if (typeof this.config.onClick === 'function') { try { this.config.onClick(this); } catch (err) { console.error('onClick callback error:', err); } } this.close(); return; } }; el.addEventListener('click', eventHandler); this._cleanups.push(() => el.removeEventListener('click', eventHandler)); if (this.config.pauseOnHover && this.config.duration > 0) { const mouseEnter = () => this._pause(); const mouseLeave = () => this._resume(); el.addEventListener('mouseenter', mouseEnter); el.addEventListener('mouseleave', mouseLeave); this._cleanups.push(() => { el.removeEventListener('mouseenter', mouseEnter); el.removeEventListener('mouseleave', mouseLeave); }); } if (this.config.draggable) { this._bindDrag(el); } } _bindDrag(el) { let sx = 0, sy = 0, dx = 0, dy = 0, dragging = false; const down = (e) => { if (e.target.closest('.met-close')) return; dragging = true; sx = e.clientX; sy = e.clientY; el.setPointerCapture(e.pointerId); el.style.transition = 'none'; this._pause(); }; const move = (e) => { if (!dragging) return; dx = e.clientX - sx; dy = e.clientY - sy; el.style.transform = `translate(${dx}px, ${dy}px) rotate(${dx * 0.1}deg)`; el.style.opacity = String(Math.max(0, 1 - Math.abs(dx) / 200)); }; const up = (e) => { if (!dragging) return; dragging = false; el.releasePointerCapture(e.pointerId); el.style.transition = ''; if (Math.abs(dx) > 120) { el.style.transform = `translate(${dx * 2}px, ${dy}px) rotate(${dx * 0.2}deg)`; el.style.opacity = '0'; setTimeout(() => this.close(true), 250); } else { el.style.transform = ''; el.style.opacity = ''; this._resume(); } dx = dy = 0; }; el.addEventListener('pointerdown', down); el.addEventListener('pointermove', move); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); this._cleanups.push(() => { el.removeEventListener('pointerdown', down); el.removeEventListener('pointermove', move); el.removeEventListener('pointerup', up); el.removeEventListener('pointercancel', up); }); } _startTimer() { if (this.config.duration <= 0) return; this.startedAt = Date.now(); this.remaining = this.config.duration; const tick = () => { if (this.paused || this.closing) return; const elapsed = Date.now() - this.startedAt; this.remaining = Math.max(0, this.config.duration - elapsed); if (this.barEl) { const ratio = this.remaining / this.config.duration; const t = this.config.progressDirection === 'vertical' ? `scaleY(${ratio})` : `scaleX(${ratio})`; this.barEl.style.transform = t; } if (this.remaining <= 0) { this.close(); return; } this.rafId = requestAnimationFrame(tick); }; this.rafId = requestAnimationFrame(tick); } _pause() { if (this.paused || this.config.duration <= 0) return; this.paused = true; cancelAnimationFrame(this.rafId); this.remaining = Math.max(0, this.config.duration - (Date.now() - this.startedAt)); } _resume() { if (!this.paused) return; this.paused = false; this.startedAt = Date.now() - (this.config.duration - this.remaining); this._startTimer(); } update(partial) { Toast.trigger('beforeUpdate', this); if (partial.type) this.type = partial.type; if (partial.title !== undefined) this.title = partial.title; if (partial.message !== undefined) this.message = partial.message; if (partial.html !== undefined) this.html = partial.html; if (!this.el) { Toast.trigger('afterUpdate', this); return this; } const content = this.el.querySelector('.met-content'); if (content) { const safeTitle = this.title ? `
${escapeHTML(this.title)}
` : ''; const safeMessage = this.html ? `
${this.html}
` : (this.message ? `
${escapeHTML(this.message)}
` : ''); content.innerHTML = `${safeTitle}${safeMessage}`; } // resetTimerOnUpdate: 更新内容后重置计时器 if (this.config.resetTimerOnUpdate && this.config.duration > 0) { cancelAnimationFrame(this.rafId); this.startedAt = Date.now(); this.remaining = this.config.duration; this._startTimer(); } if (typeof this.config.onUpdate === 'function') { try { this.config.onUpdate(this); } catch (e) { console.error('onUpdate callback error:', e); } } Toast.trigger('afterUpdate', this); return this; } close(immediate = false) { if (this.closing) return; this.closing = true; Toast.trigger('beforeClose', this); cancelAnimationFrame(this.rafId); this._cleanups.forEach(fn => { try { fn(); } catch (_) {} }); this._cleanups = []; const el = this.el; if (!el) { meToast._remove(this.id); return; } // 立即脱离文档流:记录当前位置,然后设为 absolute,让下方 toast 自然上移 const container = el.parentNode; if (container && !immediate) { const elRect = el.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); el.style.position = 'absolute'; el.style.top = (elRect.top - containerRect.top) + 'px'; el.style.left = (elRect.left - containerRect.left) + 'px'; el.style.width = elRect.width + 'px'; el.style.margin = '0'; } el.classList.add('met-leaving'); el.classList.remove('met-show'); const pos = this.config.position || 'top-right'; const isRTL = getLocaleDirection(getCurrentLocale()) === 'rtl'; let transform = 'scale(.96)'; if (pos.includes('right')) transform = isRTL ? 'translateX(-120%)' : 'translateX(120%)'; else if (pos.includes('left')) transform = isRTL ? 'translateX(120%)' : 'translateX(-120%)'; else if (pos.startsWith('top')) transform = 'translateY(-20px)'; else transform = 'translateY(20px)'; el.style.transform = transform; el.style.opacity = '0'; setTimeout(() => this._destroy(), immediate ? 0 : 300); } _destroy() { if (this.el && this.el.parentNode) { this.el.parentNode.removeChild(this.el); } this.el = null; if (typeof this.config.onClose === 'function') { try { this.config.onClose(this); } catch (e) { console.error('onClose callback error:', e); } } Toast.trigger('afterClose', this); meToast._remove(this.id); } } // ========== HTML模板辅助函数 ========== /** * 按钮行模板 — confirm/prompt 共用 * @param {string} buttons - 按钮HTML * @returns {string} 按钮行HTML */ const _btnRow = (buttons) => `
${buttons}
`; /** * 确认对话框模板 * @param {Object} opts - 选项 * @returns {string} HTML */ const _confirmHTML = (opts) => { const confirmText = opts.confirmText || i18nT('confirm'); const cancelText = opts.cancelText || i18nT('cancel'); const confirmColor = opts.confirmColor || '#10b981'; const cancelColor = opts.cancelColor || '#6b7280'; return _btnRow(` `); }; /** * 输入对话框模板 * @param {Object} opts - 选项 * @returns {string} HTML */ const _promptHTML = (opts) => { const submitText = opts.submitText || i18nT('confirm'); const cancelText = opts.cancelText || i18nT('cancel'); const submitColor = opts.submitColor || '#3b82f6'; const cancelColor = opts.cancelColor || '#6b7280'; return ` ${_btnRow(` `)} `; }; /** * 进度条模板 * @param {Object} opts - 选项 * @returns {string} HTML */ const _progressHTML = (opts) => { const color = opts.progressColor || '#3b82f6'; return `
0%
`; }; /** * Action Toast 模板 * @param {Array} actions - 操作按钮数组 [{ text, onClick, style }] * @returns {string} HTML */ const _actionHTML = (actions) => { if (!Array.isArray(actions) || actions.length === 0) return ''; const buttons = actions.map((a, i) => { const bg = a.style?.background || a.color || '#6366f1'; const cls = `met-action-btn-${i}`; return ``; }).join(''); return _btnRow(buttons); }; /** * 主对象 */ const meToast = { _toasts: new Map(), _config: { ...DEFAULTS }, version: '2.0.0', configure(opts) { if (!opts || typeof opts !== 'object') return this; this._config = { ...this._config, ...opts }; if (opts.theme) { applyTheme(opts.theme); } if (opts.locale) { setCurrentLocale(opts.locale); } return this; }, _emit(opts) { const merged = { ...this._config, ...opts }; if (merged.content && !merged.message) merged.message = merged.content; const t = new Toast(merged); t.create(); this._toasts.set(t.id, t); return t; }, _remove(id) { this._toasts.delete(id); }, find(id) { if (!id) return undefined; return this._toasts.get(id); }, /** * 显示默认Toast * @param {string|Object} messageOrOpts - 消息字符串或选项对象 * @param {Object} [opts] - 选项对象(当第一个参数是字符串时) */ show(messageOrOpts, opts = {}) { const normalized = normalizeArgs([messageOrOpts, opts], 'default'); return this._emit(normalized); }, /** * 显示成功Toast * @param {string|Object} messageOrOpts - 消息字符串或选项对象 * @param {Object} [opts] - 选项对象 */ success(messageOrOpts, opts = {}) { const normalized = normalizeArgs([messageOrOpts, opts], 'success'); return this._emit(normalized); }, /** * 显示错误Toast * @param {string|Object} messageOrOpts - 消息字符串或选项对象 * @param {Object} [opts] - 选项对象 */ error(messageOrOpts, opts = {}) { const normalized = normalizeArgs([messageOrOpts, opts], 'error'); return this._emit(normalized); }, /** * 显示警告Toast * @param {string|Object} messageOrOpts - 消息字符串或选项对象 * @param {Object} [opts] - 选项对象 */ warning(messageOrOpts, opts = {}) { const normalized = normalizeArgs([messageOrOpts, opts], 'warning'); return this._emit(normalized); }, /** * 显示信息Toast * @param {string|Object} messageOrOpts - 消息字符串或选项对象 * @param {Object} [opts] - 选项对象 */ info(messageOrOpts, opts = {}) { const normalized = normalizeArgs([messageOrOpts, opts], 'info'); return this._emit(normalized); }, /** * 显示加载Toast * @param {string|Object} messageOrOpts - 消息字符串或选项对象 * @param {Object} [opts] - 选项对象 */ loading(messageOrOpts, opts = {}) { const normalized = normalizeArgs([messageOrOpts, opts], 'loading'); normalized.duration = 0; normalized.closeButton = false; normalized.showProgress = false; const t = this._emit(normalized); return { id: t.id, success: (msg, o = {}) => this._resolve(t, 'success', msg, o), error: (msg, o = {}) => this._resolve(t, 'error', msg, o), info: (msg, o = {}) => this._resolve(t, 'info', msg, o), warning: (msg, o = {}) => this._resolve(t, 'warning', msg, o), update: (p) => { t.update(p); return this; }, dismiss: () => t.close(), }; }, promise(promise, opts = {}) { if (!promise || typeof promise.then !== 'function') { console.error('MeToast.promise: first argument must be a Promise'); return Promise.reject(new Error('Invalid promise')); } const loadingMsg = opts.loading || i18nT('loading'); const successMsg = opts.success || i18nT('success'); const errorMsg = opts.error || i18nT('error'); const ctrl = this.loading(loadingMsg); return Promise.resolve(promise) .then((data) => { ctrl.success(successMsg); return data; }) .catch((err) => { ctrl.error(errorMsg); throw err; }); }, _resolve(loadingToast, type, message, opts) { const id = loadingToast.id; const old = this._toasts.get(id); if (!old) return null; const position = old.config.position; old.close(); return this._emit({ ...opts, type, message, position }); }, /** * 确认对话框 * @param {string} message - 消息内容 * @param {Object} [opts] - 选项 * @returns {Promise} */ confirm(message, opts = {}) { if (typeof message !== 'string') { console.error('MeToast.confirm: message must be a string'); return Promise.resolve(false); } return new Promise((resolve) => { let resolved = false; const safeResolve = (val) => { if (!resolved) { resolved = true; clearTimeout(safetyTimeout); resolve(val); } }; const safetyTimeout = setTimeout(() => safeResolve(false), 10000); const t = this._emit({ ...opts, type: opts.type || 'warning', message, duration: 0, closeButton: false, closeOnClick: false, draggable: false, html: _confirmHTML(opts), }); setTimeout(() => { const confirmBtn = t.el?.querySelector('.met-confirm-btn'); const cancelBtn = t.el?.querySelector('.met-cancel-btn'); if (confirmBtn) { confirmBtn.addEventListener('click', () => { t.close(); safeResolve(true); }); } if (cancelBtn) { cancelBtn.addEventListener('click', () => { t.close(); safeResolve(false); }); } }, 0); }); }, /** * 输入对话框 * @param {string} message - 消息内容 * @param {Object} [opts] - 选项 * @returns {Promise} */ prompt(message, opts = {}) { if (typeof message !== 'string') { console.error('MeToast.prompt: message must be a string'); return Promise.resolve(null); } return new Promise((resolve) => { let resolved = false; const safeResolve = (val) => { if (!resolved) { resolved = true; clearTimeout(safetyTimeout); resolve(val); } }; const safetyTimeout = setTimeout(() => safeResolve(null), 10000); const t = this._emit({ ...opts, type: opts.type || 'info', message, duration: 0, closeButton: false, closeOnClick: false, draggable: false, html: _promptHTML(opts), }); setTimeout(() => { const input = t.el?.querySelector('.met-input'); const submitBtn = t.el?.querySelector('.met-submit-btn'); const cancelBtn = t.el?.querySelector('.met-cancel-btn'); if (input) { input.focus(); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { t.close(); safeResolve(input.value); } }); } if (submitBtn) { submitBtn.addEventListener('click', () => { t.close(); safeResolve(input?.value || null); }); } if (cancelBtn) { cancelBtn.addEventListener('click', () => { t.close(); safeResolve(null); }); } }, 0); }); }, /** * 进度Toast * @param {string|Object} messageOrOpts - 消息或选项 * @param {Object} [opts] - 选项 * @returns {Object} 控制对象 */ progress(messageOrOpts, opts = {}) { const normalized = normalizeArgs([messageOrOpts, opts], 'info'); normalized.duration = 0; normalized.closeButton = false; normalized.showProgress = false; const t = this._emit({ ...normalized, html: _progressHTML(opts), }); return { id: t.id, setProgress(percent) { const fill = t.el?.querySelector('.met-progress-fill'); const text = t.el?.querySelector('.met-progress-text'); if (fill) { fill.style.width = `${Math.min(100, Math.max(0, percent))}%`; } if (text) { text.textContent = `${Math.round(percent)}%`; } }, complete(message) { this.setProgress(100); setTimeout(() => { t.update({ type: 'success', message: message || i18nT('success'), html: '', }); setTimeout(() => t.close(), 1000); }, 300); }, error(message) { t.update({ type: 'error', message: message || i18nT('error'), html: '', }); setTimeout(() => t.close(), 2000); }, dismiss() { t.close(); }, }; }, /** * 倒计时Toast * @param {string} message - 消息内容 * @param {number} seconds - 秒数 * @param {Object} [opts] - 选项 * @returns {Object} 控制对象 */ countdown(message, seconds = 10, opts = {}) { if (typeof message !== 'string') { console.error('MeToast.countdown: message must be a string'); return { cancel: () => {}, pause: () => {}, resume: () => {} }; } let remaining = Math.max(1, parseInt(seconds) || 10); let timer = null; const t = this._emit({ ...opts, type: opts.type || 'warning', message: message.replace(/\{seconds\}/g, remaining), duration: 0, closeButton: true, showProgress: false, }); const tick = () => { remaining--; if (remaining <= 0) { clearInterval(timer); t.close(); if (typeof opts.onComplete === 'function') { try { opts.onComplete(); } catch (e) { console.error('countdown onComplete error:', e); } } return; } t.update({ message: message.replace(/\{seconds\}/g, remaining), }); }; timer = setInterval(tick, 1000); return { id: t.id, cancel() { clearInterval(timer); t.close(); }, pause() { clearInterval(timer); }, resume() { clearInterval(timer); timer = setInterval(tick, 1000); }, }; }, /** * Action Toast — 带操作按钮的通知 * @param {string|Object} messageOrOpts - 消息 * @param {Array} actions - 操作按钮 [{ text, onClick, color }] * @param {Object} [opts] - 选项 * @returns {Object} toast实例 + dismiss */ action(messageOrOpts, actions = [], opts = {}) { const normalized = normalizeArgs([messageOrOpts, opts], 'info'); normalized.duration = opts.duration ?? 0; normalized.closeButton = opts.closeButton ?? true; const t = this._emit({ ...normalized, html: _actionHTML(actions) + (normalized.html || ''), }); if (Array.isArray(actions)) { setTimeout(() => { actions.forEach((a, i) => { const btn = t.el?.querySelector(`.met-action-btn-${i}`); if (btn && typeof a.onClick === 'function') { btn.addEventListener('click', () => { try { a.onClick(t); } catch (e) { console.error('Action onClick error:', e); } if (a.close !== false) t.close(); }); } }); }, 0); } return { id: t.id, toast: t, dismiss: () => t.close() }; }, /** * 队列Toast * @param {Array} messages - 消息数组 * @param {Object} [opts] - 选项 * @returns {Promise} */ queue(messages, opts = {}) { if (!Array.isArray(messages)) { console.error('MeToast.queue: messages must be an array'); return { then: (fn) => Promise.resolve().then(fn), cancel: () => {} }; } let cancelled = false; const promise = new Promise((resolve) => { let index = 0; const delay = opts.delay || 1000; const userOnClose = opts.onClose; const showNext = () => { if (cancelled || index >= messages.length) { resolve(); return; } const message = messages[index]; index++; const msg = typeof message === 'string' ? message : (message?.message || ''); const msgObj = (typeof message === 'object' && message !== null) ? message : {}; const msgOnClose = msgObj.onClose; this._emit({ ...opts, ...msgObj, message: msg, duration: msgObj.duration || opts.duration || 3000, onClose: (toast) => { if (typeof msgOnClose === 'function') { try { msgOnClose(toast); } catch (_) {} } if (typeof userOnClose === 'function') { try { userOnClose(toast); } catch (_) {} } setTimeout(showNext, delay); }, }); }; showNext(); }); return { then: (fn, rj) => promise.then(fn, rj), cancel: () => { cancelled = true; }, }; }, /** * 堆叠Toast * @param {Array} messages - 消息数组 * @param {Object} [opts] - 选项 */ stack(messages, opts = {}) { if (!Array.isArray(messages)) { console.error('MeToast.stack: messages must be an array'); return; } messages.forEach((message, index) => { setTimeout(() => { const msg = typeof message === 'string' ? message : (message?.message || ''); this._emit({ ...opts, ...((typeof message === 'object' && message !== null) ? message : {}), message: msg, }); }, index * (opts.stagger || 100)); }); }, dismiss(id) { if (id) { const t = this._toasts.get(id); if (t) t.close(); return; } this._toasts.forEach(t => t.close()); }, clear(position) { this._toasts.forEach(t => { if (!position || t.config.position === position) t.close(); }); }, destroy() { this.dismiss(); if (typeof document !== 'undefined') { const containers = document.querySelectorAll('.met-container'); containers.forEach(c => c.parentNode && c.parentNode.removeChild(c)); const style = document.getElementById('metona-toast-styles'); if (style) style.parentNode.removeChild(style); } this._toasts.clear(); _containerCache.clear(); }, getAll() { return new Map(this._toasts); }, count() { return this._toasts.size; }, /** * 创建Toast分组 — 返回一个自动添加 group 参数的子对象 * @param {string} name - 分组名称 * @returns {Object} */ group(name) { const self = this; const methods = ['show','success','error','warning','info','loading','action']; const g = { _group: name }; methods.forEach(m => { g[m] = (...args) => { const last = typeof args[args.length - 1] === 'object' && args[args.length - 1] !== null && !Array.isArray(args[args.length - 1]) ? args.pop() : {}; return self[m](...args, { ...last, group: name }); }; }); g.dismiss = () => self.dismissGroup(name); g.count = () => self._groupCount(name); return g; }, /** * 按组关闭Toast * @param {string} name - 分组名称 */ dismissGroup(name) { this._toasts.forEach(t => { if (t.group === name) t.close(); }); }, _groupCount(name) { let c = 0; this._toasts.forEach(t => { if (t.group === name) c++; }); return c; }, }; export { meToast as default, meToast, Toast };