interface NotificationOptions {
title?: string;
content?: string;
bars?: number[];
autoClose?: number;
type?: ToastType;
}
interface DialogCreateOptions {
showHeader?: boolean;
showFooter?: boolean;
}
class UiFeedbackService {
private readonly _notificationInstances: Set<HTMLElement> = new Set();
private _tooltipFrameId: number | null = null;
private _tooltipTarget: HTMLElement | null = null;
private _tooltipElement: HTMLElement | null = null;
private _tooltipLastTop: number | null = null;
private _tooltipLastLeft: number | null = null;
private _tooltipLastBottomState: boolean | null = null;
private readonly _tooltipRepositionHandler = (): void => {
this.scheduleTooltipPositionUpdate();
};
private static readonly DEFAULT_NOTIFICATION_GAP = 15;
/**
* 创建通用的遮罩层和对话框结构
* @param {string} title - 标题
* @param {string} content - 内容
* @param {DialogType} type - 类型:DialogType.Alert | DialogType.Confirm | DialogType.Prompt
* @returns {{ overlay: HTMLElement; dialog: HTMLElement }} 包含 overlay 和 dialog 的对象
*/
public createDialog(
title: string,
content: string | HTMLElement,
type: DialogType = DialogType.Alert,
options: DialogCreateOptions = {}
): { overlay: HTMLElement; dialog: HTMLElement } {
const overlay = document.createElement('div');
overlay.className = 'dialog-overlay';
const dialog = document.createElement('div');
dialog.className = 'dialog';
const showHeader = options.showHeader !== false;
const defaultShowFooter =
type === DialogType.Alert || type === DialogType.Confirm || type === DialogType.Prompt;
const showFooter = options.showFooter ?? defaultShowFooter;
if (showHeader) {
const header = document.createElement('div');
header.className = 'dialog__header';
header.innerHTML = `
<span>${title || '提示'}</span>
<button type="button" class="dialog__close" aria-label="关闭">
<i class="fa fa-times"></i>
</button>
`;
dialog.appendChild(header);
}
const body = document.createElement('div');
body.className = 'dialog__body';
if (typeof content === 'string') {
body.innerHTML = `<div class="dialog__message">${content}</div>`;
} else {
body.appendChild(content);
}
if (type === DialogType.Prompt) {
const inputWrapper = document.createElement('div');
inputWrapper.className = 'dialog__input-wrapper';
inputWrapper.innerHTML = `<input type="text" class="dialog__input" autocomplete="off">`;
body.appendChild(inputWrapper);
}
dialog.appendChild(body);
if (showFooter) {
const footer = document.createElement('div');
footer.className = 'dialog__footer';
if (type === DialogType.Alert) {
footer.innerHTML = `
<button
type="button"
class="dialog__btn dialog__btn--primary"
data-action="confirm"
>确定</button>
`;
} else if (type === DialogType.Confirm || type === DialogType.Prompt) {
footer.innerHTML = `
<button
type="button"
class="dialog__btn dialog__btn--default"
data-action="cancel"
>取消</button>
<button
type="button"
class="dialog__btn dialog__btn--primary"
data-action="confirm"
>确定</button>
`;
}
dialog.appendChild(footer);
}
overlay.appendChild(dialog);
document.body.appendChild(overlay);
// 强制重排以触发动画
overlay.offsetHeight;
overlay.classList.add('dialog-overlay--visible');
return { overlay, dialog };
}
/**
* 关闭对话框并移除 DOM 元素
* @param {HTMLElement} overlay - 遮罩层元素
*/
public closeDialog(overlay: HTMLElement): void {
overlay.classList.remove('dialog-overlay--visible');
overlay.addEventListener(
'transitionend',
() => {
if (overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
}
},
{ once: true }
);
}
/**
* 显示模态提示框
* @param {string} message - 消息内容
* @param {string} title - 对话框标题,默认为 '提示'
* @returns {Promise<void>} 当用户点击确定或关闭对话框时,Promise 解析
*/
public alert(message: string, title: string = '提示'): Promise<void> {
return new Promise((resolve) => {
const { overlay, dialog } = this.createDialog(title, message, DialogType.Alert);
const handleClose = () => {
this.closeDialog(overlay);
resolve();
};
dialog.querySelector('[data-action="confirm"]').addEventListener('click', handleClose);
dialog.querySelector('.dialog__close').addEventListener('click', handleClose);
// 聚焦主要按钮
(dialog.querySelector('.dialog__btn--primary') as HTMLElement)?.focus();
});
}
/**
* 显示模态确认框
* @param {string} message - 消息内容
* @param {string} title - 对话框标题,默认为 '确认'
* @returns {Promise<boolean>} 用户点击确定返回 true,取消返回 false
*/
public confirm(message: string, title: string = '确认'): Promise<boolean> {
return new Promise((resolve) => {
const { overlay, dialog } = this.createDialog(title, message, DialogType.Confirm);
const handleConfirm = () => {
this.closeDialog(overlay);
resolve(true);
};
const handleCancel = () => {
this.closeDialog(overlay);
resolve(false);
};
dialog
.querySelector('[data-action="confirm"]')
.addEventListener('click', handleConfirm);
dialog.querySelector('[data-action="cancel"]').addEventListener('click', handleCancel);
dialog.querySelector('.dialog__close').addEventListener('click', handleCancel);
(dialog.querySelector('.dialog__btn--primary') as HTMLElement)?.focus();
});
}
/**
* 显示模态输入框
* @param {string} message - 提示消息
* @param {string} title - 对话框标题,默认为 '输入'
* @param {string} defaultValue - 输入框默认值
* @returns {Promise<string | null>} 用户点击确定返回输入值,取消返回 null
*/
public prompt(
message: string,
title: string = '输入',
defaultValue: string = ''
): Promise<string | null> {
return new Promise((resolve) => {
const { overlay, dialog } = this.createDialog(title, message, DialogType.Prompt);
const input = dialog.querySelector('.dialog__input') as HTMLInputElement;
if (!input) {
console.error('Prompt dialog input element not found');
resolve(null);
return;
}
input.value = defaultValue;
const handleConfirm = () => {
const val = input.value;
this.closeDialog(overlay);
resolve(val);
};
const handleCancel = () => {
this.closeDialog(overlay);
resolve(null);
};
dialog
.querySelector('[data-action="confirm"]')
.addEventListener('click', handleConfirm);
dialog.querySelector('[data-action="cancel"]').addEventListener('click', handleCancel);
dialog.querySelector('.dialog__close').addEventListener('click', handleCancel);
// 处理输入框的 Enter 和 Escape 键
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
handleConfirm();
}
if (e.key === 'Escape') {
handleCancel();
}
});
setTimeout(() => input.focus(), 50);
});
}
/**
* 显示 Toast 消息
* @param {string} message - 消息内容
* @param {ToastType} type - 消息类型,默认为 Info
* @param {number} duration - 显示持续时间,单位毫秒,默认为 3000ms
* @param {ToastPosition} position - 显示位置,默认为 TopCenter
*/
public toast(
message: string,
type: ToastType = ToastType.Info,
duration: number = 3000,
position: ToastPosition = ToastPosition.TopCenter
): void {
const container = this.getToastContainer(position);
const toast = document.createElement('div');
toast.className = `toast toast--${type}`;
let iconClass = 'fa-info-circle';
if (type === ToastType.Success) {
iconClass = 'fa-check-circle';
}
if (type === ToastType.Warning) {
iconClass = 'fa-exclamation-triangle';
}
if (type === ToastType.Error) {
iconClass = 'fa-times-circle';
}
toast.innerHTML = `
<i class="toast__icon fa ${iconClass}"></i>
<span class="toast__content">${message}</span>
`;
container.appendChild(toast);
// 计时器管理
let timeoutId: number | null = null;
let remainingTime: number = duration;
let startTime: number = Date.now();
const closeToast = () => {
toast.classList.add('is-leaving');
toast.addEventListener(
'animationend',
() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
},
{ once: true }
);
};
const startTimer = () => {
startTime = Date.now();
timeoutId = setTimeout(closeToast, remainingTime);
};
const pauseTimer = () => {
if (timeoutId !== null) {
clearTimeout(timeoutId);
timeoutId = null;
remainingTime -= Date.now() - startTime;
// 确保剩余时间不为负数
if (remainingTime < 0) {
remainingTime = 0;
}
}
};
const resumeTimer = () => {
if (remainingTime > 0) {
startTimer();
} else {
closeToast();
}
};
// 鼠标悬停时暂停,移开时继续
toast.addEventListener('mouseenter', pauseTimer);
toast.addEventListener('mouseleave', resumeTimer);
// 移动端触摸支持
toast.addEventListener('touchstart', pauseTimer, { passive: true });
toast.addEventListener('touchend', resumeTimer, { passive: true });
// 启动初始计时器
startTimer();
}
/**
* 获取或创建指定位置的 Toast 容器
* @param {ToastPosition} position - 位置枚举值
* @returns {HTMLElement} Toast 容器元素
*/
private getToastContainer(position: ToastPosition): HTMLElement {
// 使用 data-position 属性查找容器
let container = document.querySelector(
`.toast-container[data-position="${position}"]`
) as HTMLElement | null;
if (!container) {
container = document.createElement('div');
container.className = `toast-container toast-container--${position}`;
container.dataset.position = position;
document.body.appendChild(container);
}
return container;
}
/**
* 显示工具提示。
* @param {HTMLElement} element 目标元素。
* @param {string} text 提示内容。
*/
public showTooltip(element: HTMLElement, text: string): void {
if (!text) {
return;
}
let tooltip = document.getElementById('dialog-tooltip') as HTMLElement | null;
if (!tooltip) {
tooltip = document.createElement('div');
tooltip.id = 'dialog-tooltip';
tooltip.className = 'dialog-tooltip';
document.body.appendChild(tooltip);
}
this._tooltipTarget = element;
this._tooltipElement = tooltip;
this._tooltipLastTop = null;
this._tooltipLastLeft = null;
this._tooltipLastBottomState = null;
tooltip.textContent = text;
tooltip.classList.remove('is-bottom');
tooltip.classList.add('is-visible');
this.bindTooltipPositionEvents();
this.scheduleTooltipPositionUpdate();
}
/**
* 隐藏工具提示。
*/
public hideTooltip(): void {
const tooltip = this._tooltipElement ?? document.getElementById('dialog-tooltip');
if (tooltip) {
tooltip.classList.remove('is-visible');
}
this.unbindTooltipPositionEvents();
this.cancelTooltipPositionUpdate();
this._tooltipTarget = null;
this._tooltipElement = null;
this._tooltipLastTop = null;
this._tooltipLastLeft = null;
this._tooltipLastBottomState = null;
}
private bindTooltipPositionEvents(): void {
window.addEventListener('scroll', this._tooltipRepositionHandler, {
passive: true,
capture: true,
});
window.addEventListener('resize', this._tooltipRepositionHandler, { passive: true });
}
private unbindTooltipPositionEvents(): void {
window.removeEventListener('scroll', this._tooltipRepositionHandler, true);
window.removeEventListener('resize', this._tooltipRepositionHandler);
}
private scheduleTooltipPositionUpdate(): void {
if (this._tooltipFrameId !== null) {
return;
}
this._tooltipFrameId = window.requestAnimationFrame(() => {
this._tooltipFrameId = null;
this.updateTooltipPosition();
});
}
private cancelTooltipPositionUpdate(): void {
if (this._tooltipFrameId === null) {
return;
}
window.cancelAnimationFrame(this._tooltipFrameId);
this._tooltipFrameId = null;
}
private updateTooltipPosition(): void {
const element = this._tooltipTarget;
const tooltip = this._tooltipElement;
if (element === null || tooltip === null) {
return;
}
if (!document.body.contains(element)) {
this.hideTooltip();
return;
}
const rect = element.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) {
this.hideTooltip();
return;
}
const tooltipRect = tooltip.getBoundingClientRect();
let top = rect.top - tooltipRect.height - 8;
let left = rect.left + (rect.width - tooltipRect.width) / 2;
const isBottom = top < 0;
if (isBottom) {
top = rect.bottom + 8;
}
if (left < 0) {
left = 4;
} else if (left + tooltipRect.width > window.innerWidth) {
left = window.innerWidth - tooltipRect.width - 4;
}
const roundedTop = Math.round(top);
const roundedLeft = Math.round(left);
if (this._tooltipLastBottomState !== isBottom) {
tooltip.classList.toggle('is-bottom', isBottom);
this._tooltipLastBottomState = isBottom;
}
if (this._tooltipLastTop !== roundedTop) {
tooltip.style.top = `${roundedTop}px`;
this._tooltipLastTop = roundedTop;
}
if (this._tooltipLastLeft !== roundedLeft) {
tooltip.style.left = `${roundedLeft}px`;
this._tooltipLastLeft = roundedLeft;
}
}
/**
* 显示通知框。
* @param {NotificationOptions} option 通知配置项。
* @returns {HTMLElement} 通知框元素。
*/
public showNotification(option: NotificationOptions = {}): HTMLElement {
const setting: Required<NotificationOptions> = {
title: '',
content: '',
bars: [],
autoClose: 0,
type: ToastType.Info,
...option,
};
const top = this.calculateNotificationTopPosition();
const box = this.createNotificationBox(setting);
box.style.top = `${top}px`;
document.body.appendChild(box);
this._notificationInstances.add(box);
if (setting.autoClose > 0) {
window.setTimeout(() => {
this.closeNotification(box);
}, setting.autoClose);
}
requestAnimationFrame(() => {
box.classList.add('notification-box--enter');
});
return box;
}
/**
* 设置通知框内容。
* @param {HTMLElement} box 通知框元素。
* @param {string} content 新内容。
*/
public setNotificationContent(box: HTMLElement, content: string): void {
if (!this.validateNotificationBox(box)) {
return;
}
const contentEl = box.querySelector('.notification-box__content') as HTMLElement | null;
if (contentEl) {
contentEl.textContent = String(content || '');
}
}
/**
* 设置通知框标题。
* @param {HTMLElement} box 通知框元素。
* @param {string} title 新标题。
*/
public setNotificationTitle(box: HTMLElement, title: string): void {
if (!this.validateNotificationBox(box)) {
return;
}
const titleEl = box.querySelector('.notification-box__title') as HTMLElement | null;
if (titleEl) {
titleEl.textContent = String(title || '');
}
}
/**
* 设置通知框进度。
* @param {HTMLElement} box 通知框元素。
* @param {number[]} values 进度值数组。
*/
public setNotificationProgress(box: HTMLElement, values: number[]): void {
if (!this.validateNotificationBox(box) || !Array.isArray(values)) {
return;
}
const progressContainers = box.querySelectorAll('.notification-box__progress');
values.forEach((value, index) => {
if (typeof value === 'number' && index < progressContainers.length) {
const clampedValue = Math.max(0, Math.min(100, value));
const progress = progressContainers[index] as HTMLElement;
const track = progress.querySelector(
'.notification-box__progress-track'
) as HTMLElement | null;
const bar = progress.querySelector(
'.notification-box__progress-bar'
) as HTMLElement | null;
const textValue = `${clampedValue.toFixed(1)}%`;
if (bar) {
if (clampedValue > 0) {
bar.style.width = `${clampedValue}%`;
bar.style.display = '';
} else {
bar.style.width = '0%';
bar.style.display = 'none';
}
}
const textInternal = track
? (track.querySelector(
'.notification-box__progress-text'
) as HTMLElement | null)
: null;
const textExternal = progress.querySelector(
'.notification-box__progress-text--external'
) as HTMLElement | null;
if (textInternal) {
textInternal.textContent = textValue;
} else if (textExternal) {
textExternal.remove();
const newText = document.createElement('span');
newText.className = 'notification-box__progress-text';
newText.textContent = textValue;
if (track) {
track.appendChild(newText);
}
} else if (track) {
const newText = document.createElement('span');
newText.className = 'notification-box__progress-text';
newText.textContent = textValue;
track.appendChild(newText);
}
}
});
}
/**
* 关闭通知框。
* @param {HTMLElement} box 要关闭的通知框。
*/
public closeNotification(box: HTMLElement): void {
if (!this.validateNotificationBox(box)) {
return;
}
box.classList.add('notification-box--exit');
window.setTimeout(() => {
if (box.parentNode) {
this._notificationInstances.delete(box);
box.parentNode.removeChild(box);
this.recalculateNotificationPositions();
}
}, 300);
}
/**
* 关闭所有通知框。
*/
public closeAllNotifications(): void {
const boxes = Array.from(this._notificationInstances);
boxes.forEach((box) => {
this.closeNotification(box);
});
}
/**
* 获取活跃通知数量。
* @returns {number} 当前通知数量。
*/
public getNotificationCount(): number {
return this._notificationInstances.size;
}
private validateNotificationBox(box: HTMLElement): boolean {
return Boolean(box && box.parentNode && this._notificationInstances.has(box));
}
private calculateNotificationTopPosition(): number {
let top = UiFeedbackService.DEFAULT_NOTIFICATION_GAP;
this._notificationInstances.forEach((box) => {
if (box.offsetParent !== null) {
top += box.offsetHeight + UiFeedbackService.DEFAULT_NOTIFICATION_GAP;
}
});
return top;
}
private recalculateNotificationPositions(): void {
let currentTop = UiFeedbackService.DEFAULT_NOTIFICATION_GAP;
this._notificationInstances.forEach((box) => {
if (box.offsetParent !== null) {
box.style.top = `${currentTop}px`;
currentTop += box.offsetHeight + UiFeedbackService.DEFAULT_NOTIFICATION_GAP;
}
});
}
private createNotificationBox(setting: Required<NotificationOptions>): HTMLElement {
const box = document.createElement('div');
box.className = `notification-box notification-box--${setting.type}`;
if (setting.title) {
const title = document.createElement('div');
title.className = 'notification-box__title';
title.textContent = setting.title;
box.appendChild(title);
}
const container = document.createElement('div');
container.className = 'notification-box__content-container';
const content = document.createElement('div');
content.className = 'notification-box__content';
content.textContent = setting.content;
container.appendChild(content);
this.createNotificationProgressBars(container, setting.bars);
box.appendChild(container);
this.addNotificationCloseButton(box);
return box;
}
private createNotificationProgressBars(container: HTMLElement, bars: number[]): void {
if (!Array.isArray(bars) || bars.length === 0) {
return;
}
bars.forEach((value) => {
if (typeof value === 'number') {
const clampedValue = Math.max(0, Math.min(100, value));
const progress = document.createElement('div');
progress.className = 'notification-box__progress';
const track = document.createElement('div');
track.className = 'notification-box__progress-track';
const bar = document.createElement('div');
bar.className = 'notification-box__progress-bar';
if (clampedValue > 0) {
bar.style.width = `${clampedValue}%`;
bar.style.display = '';
} else {
bar.style.width = '0%';
bar.style.display = 'none';
}
const textValue = `${clampedValue.toFixed(1)}%`;
const text = document.createElement('span');
text.className = 'notification-box__progress-text';
text.textContent = textValue;
track.appendChild(bar);
track.appendChild(text);
progress.appendChild(track);
container.appendChild(progress);
}
});
}
private addNotificationCloseButton(box: HTMLElement): void {
const closeBtn = document.createElement('button');
closeBtn.className = 'notification-box__close';
closeBtn.innerHTML = '×';
closeBtn.setAttribute('aria-label', '关闭通知');
closeBtn.addEventListener('click', (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
this.closeNotification(box);
});
box.appendChild(closeBtn);
}
}
export enum DialogType {
Alert = 'alert',
Prompt = 'prompt',
Confirm = 'confirm',
Custom = 'custom',
}
export enum ToastType {
Success = 'success',
Error = 'error',
Info = 'info',
Warning = 'warning',
}
export enum ToastPosition {
TopLeft = 'top-left',
TopRight = 'top-right',
TopCenter = 'top-center',
BottomLeft = 'bottom-left',
BottomRight = 'bottom-right',
BottomCenter = 'bottom-center',
}
export const uiFeedbackService = new UiFeedbackService();
⚠⚠ 以下内容为AI分析的结果,请根据实际情况进行判断。
这是一个完整的 UI 反馈服务类,提供了现代 Web 应用中常见的用户交互反馈功能。让我详细解释一下各个部分:
核心功能概览
这个 UiFeedbackService 类实现了以下 UI 反馈组件:
- 对话框 (Dialog): Alert、Confirm、Prompt
- Toast 通知 (Toast): 轻量级消息提示
- 工具提示 (Tooltip): 鼠标悬停提示
- 通知框 (Notification): 持久化通知消息
接口定义
interface NotificationOptions {
title?: string; // 通知标题
content?: string; // 通知内容
bars?: number[]; // 进度条数组
autoClose?: number; // 自动关闭时间(ms)
type?: ToastType; // 通知类型
}
interface DialogCreateOptions {
showHeader?: boolean; // 是否显示头部
showFooter?: boolean; // 是否显示底部
}
主要功能模块
1. 对话框系统 (Dialog)
核心方法 createDialog():
- 创建模态对话框的 DOM 结构
- 支持自定义标题、内容和类型
- 自动处理显示/隐藏动画
三种对话框类型:
// 提示框 - 只有确认按钮
alert(message: string, title: string = '提示'): Promise<void>
// 确认框 - 确认/取消按钮
confirm(message: string, title: string = '确认'): Promise<boolean>
// 输入框 - 带输入框的确认框
prompt(message: string, title: string = '输入', defaultValue: string = ''): Promise<string | null>
2. Toast 通知系统
特点:
- 支持 4 种类型:Success、Error、Info、Warning
- 6 种位置:上中下 × 左中右
- 鼠标悬停暂停自动关闭
- 移动端触摸支持
toast(
message: string,
type: ToastType = ToastType.Info,
duration: number = 3000,
position: ToastPosition = ToastPosition.TopCenter
): void
3. 工具提示系统 (Tooltip)
智能定位:
- 自动检测屏幕边界,避免溢出
- 响应滚动和窗口大小变化
- 使用
requestAnimationFrame优化性能 - 自动处理目标元素移除情况
关键特性:
showTooltip(element: HTMLElement, text: string): void
hideTooltip(): void
// 私有方法处理定位逻辑
private updateTooltipPosition(): void // 核心定位算法
private scheduleTooltipPositionUpdate(): void // 性能优化
4. 通知框系统 (Notification)
功能丰富:
- 支持标题、内容、多进度条
- 自动堆叠排列,动态重新计算位置
- 可手动或自动关闭
- 实时更新内容和进度
主要 API:
showNotification(option: NotificationOptions): HTMLElement
setNotificationContent(box: HTMLElement, content: string): void
setNotificationProgress(box: HTMLElement, values: number[]): void
closeNotification(box: HTMLElement): void
设计亮点
1. 性能优化
- 使用
requestAnimationFrame优化 tooltip 重定位 - 事件委托和及时清理事件监听器
- DOM 操作批量处理
2. 用户体验
- 平滑的 CSS 动画过渡
- 键盘支持 (Enter/Escape)
- 移动端触摸支持
- 无障碍访问 (aria-label)
3. 架构设计
- 单例模式 (
export const uiFeedbackService) - Promise 基础的异步 API
- 类型安全的 TypeScript 实现
- 模块化的方法组织
4. 容错处理
- 输入验证和边界检查
- DOM 元素存在性验证
- 优雅的错误恢复
使用示例
// 对话框
await uiFeedbackService.alert('操作成功!');
const result = await uiFeedbackService.confirm('确认删除吗?');
// Toast
uiFeedbackService.toast('保存成功', ToastType.Success);
// 通知框
const notification = uiFeedbackService.showNotification({
title: '文件上传',
content: '正在上传...',
bars: [0],
type: ToastType.Info
});
// 更新进度
uiFeedbackService.setNotificationProgress(notification, [50]);
这个服务类提供了完整的用户界面反馈解决方案,代码质量高,功能完善,可以直接用于生产环境。
AI 正在分析代码…
评论加载中...