feat: codex-register with Sub2API增强 + Playwright引擎
Some checks are pending
Docker Image CI / build-and-push-image (push) Waiting to run
Some checks are pending
Docker Image CI / build-and-push-image (push) Waiting to run
This commit is contained in:
553
static/js/utils.js
Normal file
553
static/js/utils.js
Normal file
@@ -0,0 +1,553 @@
|
||||
/**
|
||||
* 通用工具库
|
||||
* 包含 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;
|
||||
Reference in New Issue
Block a user