feat: codex-register with Sub2API增强 + Playwright引擎
Some checks are pending
Docker Image CI / build-and-push-image (push) Waiting to run

This commit is contained in:
2026-03-22 00:24:16 +08:00
commit 0f9948ffc3
91 changed files with 29942 additions and 0 deletions

553
static/js/utils.js Normal file
View 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()">&times;</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;