Some checks are pending
Docker Image CI / build-and-push-image (push) Waiting to run
554 lines
15 KiB
JavaScript
554 lines
15 KiB
JavaScript
/**
|
||
* 通用工具库
|
||
* 包含 Toast 通知、主题切换、工具函数等
|
||
*/
|
||
|
||
// ============================================
|
||
// Toast 通知系统
|
||
// ============================================
|
||
|
||
class ToastManager {
|
||
constructor() {
|
||
this.container = null;
|
||
this.init();
|
||
}
|
||
|
||
init() {
|
||
this.container = document.createElement('div');
|
||
this.container.className = 'toast-container';
|
||
document.body.appendChild(this.container);
|
||
}
|
||
|
||
show(message, type = 'info', duration = 4000) {
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast ${type}`;
|
||
|
||
const icon = this.getIcon(type);
|
||
toast.innerHTML = `
|
||
<span class="toast-icon">${icon}</span>
|
||
<span class="toast-message">${this.escapeHtml(message)}</span>
|
||
<button class="toast-close" onclick="this.parentElement.remove()">×</button>
|
||
`;
|
||
|
||
this.container.appendChild(toast);
|
||
|
||
// 自动移除
|
||
setTimeout(() => {
|
||
toast.style.animation = 'slideOut 0.3s ease forwards';
|
||
setTimeout(() => toast.remove(), 300);
|
||
}, duration);
|
||
|
||
return toast;
|
||
}
|
||
|
||
getIcon(type) {
|
||
const icons = {
|
||
success: '✓',
|
||
error: '✕',
|
||
warning: '⚠',
|
||
info: 'ℹ'
|
||
};
|
||
return icons[type] || icons.info;
|
||
}
|
||
|
||
escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
success(message, duration) {
|
||
return this.show(message, 'success', duration);
|
||
}
|
||
|
||
error(message, duration) {
|
||
return this.show(message, 'error', duration);
|
||
}
|
||
|
||
warning(message, duration) {
|
||
return this.show(message, 'warning', duration);
|
||
}
|
||
|
||
info(message, duration) {
|
||
return this.show(message, 'info', duration);
|
||
}
|
||
}
|
||
|
||
// 全局 Toast 实例
|
||
const toast = new ToastManager();
|
||
|
||
// ============================================
|
||
// 主题管理
|
||
// ============================================
|
||
|
||
class ThemeManager {
|
||
constructor() {
|
||
this.theme = this.loadTheme();
|
||
this.applyTheme();
|
||
}
|
||
|
||
loadTheme() {
|
||
return localStorage.getItem('theme') || 'light';
|
||
}
|
||
|
||
saveTheme(theme) {
|
||
localStorage.setItem('theme', theme);
|
||
}
|
||
|
||
applyTheme() {
|
||
document.documentElement.setAttribute('data-theme', this.theme);
|
||
this.updateToggleButtons();
|
||
}
|
||
|
||
toggle() {
|
||
this.theme = this.theme === 'light' ? 'dark' : 'light';
|
||
this.saveTheme(this.theme);
|
||
this.applyTheme();
|
||
}
|
||
|
||
setTheme(theme) {
|
||
this.theme = theme;
|
||
this.saveTheme(theme);
|
||
this.applyTheme();
|
||
}
|
||
|
||
updateToggleButtons() {
|
||
const buttons = document.querySelectorAll('.theme-toggle');
|
||
buttons.forEach(btn => {
|
||
btn.innerHTML = this.theme === 'light' ? '🌙' : '☀️';
|
||
btn.title = this.theme === 'light' ? '切换到暗色模式' : '切换到亮色模式';
|
||
});
|
||
}
|
||
}
|
||
|
||
// 全局主题实例
|
||
const theme = new ThemeManager();
|
||
|
||
// ============================================
|
||
// 加载状态管理
|
||
// ============================================
|
||
|
||
class LoadingManager {
|
||
constructor() {
|
||
this.activeLoaders = new Set();
|
||
}
|
||
|
||
show(element, text = '加载中...') {
|
||
if (typeof element === 'string') {
|
||
element = document.getElementById(element);
|
||
}
|
||
if (!element) return;
|
||
|
||
element.classList.add('loading');
|
||
element.dataset.originalText = element.innerHTML;
|
||
element.innerHTML = `<span class="loading-spinner"></span> ${text}`;
|
||
element.disabled = true;
|
||
this.activeLoaders.add(element);
|
||
}
|
||
|
||
hide(element) {
|
||
if (typeof element === 'string') {
|
||
element = document.getElementById(element);
|
||
}
|
||
if (!element) return;
|
||
|
||
element.classList.remove('loading');
|
||
if (element.dataset.originalText) {
|
||
element.innerHTML = element.dataset.originalText;
|
||
delete element.dataset.originalText;
|
||
}
|
||
element.disabled = false;
|
||
this.activeLoaders.delete(element);
|
||
}
|
||
|
||
hideAll() {
|
||
this.activeLoaders.forEach(element => this.hide(element));
|
||
}
|
||
}
|
||
|
||
const loading = new LoadingManager();
|
||
|
||
// ============================================
|
||
// API 请求封装
|
||
// ============================================
|
||
|
||
class ApiClient {
|
||
constructor(baseUrl = '/api') {
|
||
this.baseUrl = baseUrl;
|
||
}
|
||
|
||
async request(path, options = {}) {
|
||
const url = `${this.baseUrl}${path}`;
|
||
|
||
const defaultOptions = {
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
};
|
||
|
||
const finalOptions = { ...defaultOptions, ...options };
|
||
const timeoutMs = Number(finalOptions.timeoutMs || 0);
|
||
delete finalOptions.timeoutMs;
|
||
|
||
if (finalOptions.body && typeof finalOptions.body === 'object') {
|
||
finalOptions.body = JSON.stringify(finalOptions.body);
|
||
}
|
||
|
||
let timeoutId = null;
|
||
try {
|
||
if (timeoutMs > 0) {
|
||
const controller = new AbortController();
|
||
finalOptions.signal = controller.signal;
|
||
timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||
}
|
||
|
||
const response = await fetch(url, finalOptions);
|
||
const data = await response.json().catch(() => ({}));
|
||
|
||
if (!response.ok) {
|
||
const error = new Error(data.detail || `HTTP ${response.status}`);
|
||
error.response = response;
|
||
error.data = data;
|
||
throw error;
|
||
}
|
||
|
||
return data;
|
||
} catch (error) {
|
||
if (error.name === 'AbortError') {
|
||
const timeoutError = new Error('请求超时,请稍后重试');
|
||
throw timeoutError;
|
||
}
|
||
// 网络错误处理
|
||
if (!error.response) {
|
||
toast.error('网络连接失败,请检查网络');
|
||
}
|
||
throw error;
|
||
} finally {
|
||
if (timeoutId) {
|
||
clearTimeout(timeoutId);
|
||
}
|
||
}
|
||
}
|
||
|
||
get(path, options = {}) {
|
||
return this.request(path, { ...options, method: 'GET' });
|
||
}
|
||
|
||
post(path, body, options = {}) {
|
||
return this.request(path, { ...options, method: 'POST', body });
|
||
}
|
||
|
||
put(path, body, options = {}) {
|
||
return this.request(path, { ...options, method: 'PUT', body });
|
||
}
|
||
|
||
patch(path, body, options = {}) {
|
||
return this.request(path, { ...options, method: 'PATCH', body });
|
||
}
|
||
|
||
delete(path, options = {}) {
|
||
return this.request(path, { ...options, method: 'DELETE' });
|
||
}
|
||
}
|
||
|
||
const api = new ApiClient();
|
||
|
||
// ============================================
|
||
// 事件委托助手
|
||
// ============================================
|
||
|
||
function delegate(element, eventType, selector, handler) {
|
||
element.addEventListener(eventType, (e) => {
|
||
const target = e.target.closest(selector);
|
||
if (target && element.contains(target)) {
|
||
handler.call(target, e, target);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ============================================
|
||
// 防抖和节流
|
||
// ============================================
|
||
|
||
function debounce(func, wait) {
|
||
let timeout;
|
||
return function executedFunction(...args) {
|
||
const later = () => {
|
||
clearTimeout(timeout);
|
||
func(...args);
|
||
};
|
||
clearTimeout(timeout);
|
||
timeout = setTimeout(later, wait);
|
||
};
|
||
}
|
||
|
||
function throttle(func, limit) {
|
||
let inThrottle;
|
||
return function executedFunction(...args) {
|
||
if (!inThrottle) {
|
||
func(...args);
|
||
inThrottle = true;
|
||
setTimeout(() => inThrottle = false, limit);
|
||
}
|
||
};
|
||
}
|
||
|
||
// ============================================
|
||
// 格式化工具
|
||
// ============================================
|
||
|
||
const format = {
|
||
date(dateStr) {
|
||
if (!dateStr) return '-';
|
||
const date = new Date(dateStr);
|
||
return date.toLocaleString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
},
|
||
|
||
dateShort(dateStr) {
|
||
if (!dateStr) return '-';
|
||
const date = new Date(dateStr);
|
||
return date.toLocaleDateString('zh-CN');
|
||
},
|
||
|
||
relativeTime(dateStr) {
|
||
if (!dateStr) return '-';
|
||
const date = new Date(dateStr);
|
||
const now = new Date();
|
||
const diff = now - date;
|
||
const seconds = Math.floor(diff / 1000);
|
||
const minutes = Math.floor(seconds / 60);
|
||
const hours = Math.floor(minutes / 60);
|
||
const days = Math.floor(hours / 24);
|
||
|
||
if (seconds < 60) return '刚刚';
|
||
if (minutes < 60) return `${minutes} 分钟前`;
|
||
if (hours < 24) return `${hours} 小时前`;
|
||
if (days < 7) return `${days} 天前`;
|
||
return this.dateShort(dateStr);
|
||
},
|
||
|
||
bytes(bytes) {
|
||
if (bytes === 0) return '0 B';
|
||
const k = 1024;
|
||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||
},
|
||
|
||
number(num) {
|
||
if (num === null || num === undefined) return '-';
|
||
return num.toLocaleString('zh-CN');
|
||
}
|
||
};
|
||
|
||
// ============================================
|
||
// 状态映射
|
||
// ============================================
|
||
|
||
const statusMap = {
|
||
account: {
|
||
active: { text: '活跃', class: 'active' },
|
||
expired: { text: '过期', class: 'expired' },
|
||
banned: { text: '封禁', class: 'banned' },
|
||
failed: { text: '失败', class: 'failed' }
|
||
},
|
||
task: {
|
||
pending: { text: '等待中', class: 'pending' },
|
||
running: { text: '运行中', class: 'running' },
|
||
completed: { text: '已完成', class: 'completed' },
|
||
failed: { text: '失败', class: 'failed' },
|
||
cancelled: { text: '已取消', class: 'disabled' }
|
||
},
|
||
service: {
|
||
tempmail: 'Tempmail.lol',
|
||
outlook: 'Outlook',
|
||
moe_mail: 'MoeMail',
|
||
temp_mail: 'Temp-Mail(自部署)',
|
||
duck_mail: 'DuckMail',
|
||
freemail: 'Freemail',
|
||
imap_mail: 'IMAP 邮箱'
|
||
}
|
||
};
|
||
|
||
function getStatusText(type, status) {
|
||
return statusMap[type]?.[status]?.text || status;
|
||
}
|
||
|
||
function getStatusClass(type, status) {
|
||
return statusMap[type]?.[status]?.class || '';
|
||
}
|
||
|
||
function getServiceTypeText(type) {
|
||
return statusMap.service[type] || type;
|
||
}
|
||
|
||
const accountStatusIconMap = {
|
||
active: { icon: '🟢', title: '活跃' },
|
||
expired: { icon: '🟡', title: '过期' },
|
||
banned: { icon: '🔴', title: '封禁' },
|
||
failed: { icon: '❌', title: '失败' },
|
||
};
|
||
|
||
function getStatusIcon(status) {
|
||
const s = accountStatusIconMap[status];
|
||
if (!s) return `<span title="${status}">⚪</span>`;
|
||
return `<span title="${s.title}">${s.icon}</span>`;
|
||
}
|
||
|
||
// ============================================
|
||
// 确认对话框
|
||
// ============================================
|
||
|
||
function confirm(message, title = '确认操作') {
|
||
return new Promise((resolve) => {
|
||
const modal = document.createElement('div');
|
||
modal.className = 'modal active';
|
||
modal.innerHTML = `
|
||
<div class="modal-content" style="max-width: 400px;">
|
||
<div class="modal-header">
|
||
<h3>${title}</h3>
|
||
</div>
|
||
<div class="modal-body">
|
||
<p style="margin-bottom: var(--spacing-lg);">${message}</p>
|
||
<div class="form-actions" style="margin-top: 0; padding-top: 0; border-top: none;">
|
||
<button class="btn btn-secondary" id="confirm-cancel">取消</button>
|
||
<button class="btn btn-danger" id="confirm-ok">确认</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(modal);
|
||
|
||
const cancelBtn = modal.querySelector('#confirm-cancel');
|
||
const okBtn = modal.querySelector('#confirm-ok');
|
||
|
||
cancelBtn.onclick = () => {
|
||
modal.remove();
|
||
resolve(false);
|
||
};
|
||
|
||
okBtn.onclick = () => {
|
||
modal.remove();
|
||
resolve(true);
|
||
};
|
||
|
||
modal.onclick = (e) => {
|
||
if (e.target === modal) {
|
||
modal.remove();
|
||
resolve(false);
|
||
}
|
||
};
|
||
});
|
||
}
|
||
|
||
// ============================================
|
||
// 复制到剪贴板
|
||
// ============================================
|
||
|
||
async function copyToClipboard(text) {
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
toast.success('已复制到剪贴板');
|
||
return true;
|
||
} catch (err) {
|
||
// 降级到 execCommand
|
||
}
|
||
}
|
||
try {
|
||
const ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none;';
|
||
document.body.appendChild(ta);
|
||
ta.focus();
|
||
ta.select();
|
||
const ok = document.execCommand('copy');
|
||
document.body.removeChild(ta);
|
||
if (ok) {
|
||
toast.success('已复制到剪贴板');
|
||
return true;
|
||
}
|
||
throw new Error('execCommand failed');
|
||
} catch (err) {
|
||
toast.error('复制失败');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// 本地存储助手
|
||
// ============================================
|
||
|
||
const storage = {
|
||
get(key, defaultValue = null) {
|
||
try {
|
||
const value = localStorage.getItem(key);
|
||
return value ? JSON.parse(value) : defaultValue;
|
||
} catch {
|
||
return defaultValue;
|
||
}
|
||
},
|
||
|
||
set(key, value) {
|
||
try {
|
||
localStorage.setItem(key, JSON.stringify(value));
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
},
|
||
|
||
remove(key) {
|
||
localStorage.removeItem(key);
|
||
}
|
||
};
|
||
|
||
// ============================================
|
||
// 页面初始化
|
||
// ============================================
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
// 初始化主题
|
||
theme.applyTheme();
|
||
|
||
// 全局键盘快捷键
|
||
document.addEventListener('keydown', (e) => {
|
||
// Ctrl/Cmd + K: 聚焦搜索
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||
e.preventDefault();
|
||
const searchInput = document.querySelector('#search-input, [type="search"]');
|
||
if (searchInput) searchInput.focus();
|
||
}
|
||
|
||
// Escape: 关闭模态框
|
||
if (e.key === 'Escape') {
|
||
const activeModal = document.querySelector('.modal.active');
|
||
if (activeModal) activeModal.classList.remove('active');
|
||
}
|
||
});
|
||
});
|
||
|
||
// 导出全局对象
|
||
window.toast = toast;
|
||
window.theme = theme;
|
||
window.loading = loading;
|
||
window.api = api;
|
||
window.format = format;
|
||
window.confirm = confirm;
|
||
window.copyToClipboard = copyToClipboard;
|
||
window.storage = storage;
|
||
window.delegate = delegate;
|
||
window.debounce = debounce;
|
||
window.throttle = throttle;
|
||
window.getStatusText = getStatusText;
|
||
window.getStatusClass = getStatusClass;
|
||
window.getServiceTypeText = getServiceTypeText;
|
||
window.getStatusIcon = getStatusIcon;
|