1196 lines
35 KiB
JavaScript
1196 lines
35 KiB
JavaScript
/**
|
||
* 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 ? `<div class="met-title">${escapeHTML(this.title)}</div>` : '';
|
||
const safeMessage = this.html
|
||
? `<div class="met-message">${this.html}</div>`
|
||
: (this.message ? `<div class="met-message">${escapeHTML(this.message)}</div>` : '');
|
||
|
||
const iconHTML = showIcon
|
||
? `<div class="met-icon" style="color:${c.fg}">${this.iconHTML || ICONS[this.type]}</div>` : '';
|
||
const closeHTML = showClose
|
||
? `<button class="met-close" type="button" aria-label="${i18nT('close')}">
|
||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||
</svg>
|
||
</button>`
|
||
: '';
|
||
const progressHTML = showProgress
|
||
? (this.config.progressDirection === 'vertical'
|
||
? `<div class="met-progress-v"><div class="met-bar-v" style="background:${c.fg}"></div></div>`
|
||
: `<div class="met-progress"><div class="met-bar" style="background:${c.fg}"></div></div>`)
|
||
: '';
|
||
const sideHTML = showSide
|
||
? `<div class="met-side" style="background:${c.fg}"></div>` : '';
|
||
|
||
el.innerHTML = `
|
||
${iconHTML}
|
||
${sideHTML}
|
||
<div class="met-content">
|
||
${safeTitle}
|
||
${safeMessage}
|
||
</div>
|
||
${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 ? `<div class="met-title">${escapeHTML(this.title)}</div>` : '';
|
||
const safeMessage = this.html
|
||
? `<div class="met-message">${this.html}</div>`
|
||
: (this.message ? `<div class="met-message">${escapeHTML(this.message)}</div>` : '');
|
||
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) => `
|
||
<div style="display: flex; gap: 8px; margin-top: 12px;">
|
||
${buttons}
|
||
</div>`;
|
||
|
||
/**
|
||
* 确认对话框模板
|
||
* @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(`
|
||
<button class="met-confirm-btn" style="flex:1;padding:8px 16px;border:none;border-radius:6px;background:${confirmColor};color:white;cursor:pointer;font-size:13px">${escapeHTML(confirmText)}</button>
|
||
<button class="met-cancel-btn" style="flex:1;padding:8px 16px;border:none;border-radius:6px;background:${cancelColor};color:white;cursor:pointer;font-size:13px">${escapeHTML(cancelText)}</button>
|
||
`);
|
||
};
|
||
|
||
/**
|
||
* 输入对话框模板
|
||
* @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 `
|
||
<input type="${opts.inputType || 'text'}"
|
||
class="met-input"
|
||
placeholder="${escapeHTML(opts.placeholder || '')}"
|
||
value="${escapeHTML(opts.defaultValue || '')}"
|
||
style="width:100%;padding:8px 12px;border:1px solid rgba(0,0,0,0.1);border-radius:6px;font-size:13px;margin-top:8px;background:transparent;color:inherit;box-sizing:border-box">
|
||
${_btnRow(`
|
||
<button class="met-submit-btn" style="flex:1;padding:8px 16px;border:none;border-radius:6px;background:${submitColor};color:white;cursor:pointer;font-size:13px">${escapeHTML(submitText)}</button>
|
||
<button class="met-cancel-btn" style="flex:1;padding:8px 16px;border:none;border-radius:6px;background:${cancelColor};color:white;cursor:pointer;font-size:13px">${escapeHTML(cancelText)}</button>
|
||
`)}
|
||
`;
|
||
};
|
||
|
||
/**
|
||
* 进度条模板
|
||
* @param {Object} opts - 选项
|
||
* @returns {string} HTML
|
||
*/
|
||
const _progressHTML = (opts) => {
|
||
const color = opts.progressColor || '#3b82f6';
|
||
return `
|
||
<div class="met-progress-bar" style="width:100%;height:8px;background:rgba(0,0,0,0.1);border-radius:4px;margin-top:12px;overflow:hidden">
|
||
<div class="met-progress-fill" style="width:0%;height:100%;background:${color};border-radius:4px;transition:width 0.3s ease"></div>
|
||
</div>
|
||
<div class="met-progress-text" style="font-size:12px;opacity:0.7;margin-top:4px;text-align:right">0%</div>
|
||
`;
|
||
};
|
||
|
||
/**
|
||
* 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 `<button class="${cls}" style="flex:1;padding:6px 12px;border:none;border-radius:6px;background:${bg};color:white;cursor:pointer;font-size:12px;font-weight:500">${escapeHTML(a.text || '')}</button>`;
|
||
}).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<boolean>}
|
||
*/
|
||
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<string|null>}
|
||
*/
|
||
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 };
|