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:
1366
static/css/style.css
Normal file
1366
static/css/style.css
Normal file
File diff suppressed because it is too large
Load Diff
1268
static/js/accounts.js
Normal file
1268
static/js/accounts.js
Normal file
File diff suppressed because it is too large
Load Diff
1673
static/js/app.js
Normal file
1673
static/js/app.js
Normal file
File diff suppressed because it is too large
Load Diff
789
static/js/email_services.js
Normal file
789
static/js/email_services.js
Normal file
@@ -0,0 +1,789 @@
|
||||
/**
|
||||
* 邮箱服务页面 JavaScript
|
||||
*/
|
||||
|
||||
// 状态
|
||||
let outlookServices = [];
|
||||
let customServices = []; // 合并 moe_mail + temp_mail + duck_mail + freemail + imap_mail
|
||||
let selectedOutlook = new Set();
|
||||
let selectedCustom = new Set();
|
||||
|
||||
// DOM 元素
|
||||
const elements = {
|
||||
// 统计
|
||||
outlookCount: document.getElementById('outlook-count'),
|
||||
customCount: document.getElementById('custom-count'),
|
||||
tempmailStatus: document.getElementById('tempmail-status'),
|
||||
totalEnabled: document.getElementById('total-enabled'),
|
||||
|
||||
// Outlook 导入
|
||||
toggleOutlookImport: document.getElementById('toggle-outlook-import'),
|
||||
outlookImportBody: document.getElementById('outlook-import-body'),
|
||||
outlookImportData: document.getElementById('outlook-import-data'),
|
||||
outlookImportEnabled: document.getElementById('outlook-import-enabled'),
|
||||
outlookImportPriority: document.getElementById('outlook-import-priority'),
|
||||
outlookImportBtn: document.getElementById('outlook-import-btn'),
|
||||
clearImportBtn: document.getElementById('clear-import-btn'),
|
||||
importResult: document.getElementById('import-result'),
|
||||
|
||||
// Outlook 列表
|
||||
outlookTable: document.getElementById('outlook-accounts-table'),
|
||||
selectAllOutlook: document.getElementById('select-all-outlook'),
|
||||
batchDeleteOutlookBtn: document.getElementById('batch-delete-outlook-btn'),
|
||||
|
||||
// 自定义域名(合并)
|
||||
customTable: document.getElementById('custom-services-table'),
|
||||
addCustomBtn: document.getElementById('add-custom-btn'),
|
||||
selectAllCustom: document.getElementById('select-all-custom'),
|
||||
|
||||
// 临时邮箱
|
||||
tempmailForm: document.getElementById('tempmail-form'),
|
||||
tempmailApi: document.getElementById('tempmail-api'),
|
||||
tempmailEnabled: document.getElementById('tempmail-enabled'),
|
||||
testTempmailBtn: document.getElementById('test-tempmail-btn'),
|
||||
|
||||
// 添加自定义域名模态框
|
||||
addCustomModal: document.getElementById('add-custom-modal'),
|
||||
addCustomForm: document.getElementById('add-custom-form'),
|
||||
closeCustomModal: document.getElementById('close-custom-modal'),
|
||||
cancelAddCustom: document.getElementById('cancel-add-custom'),
|
||||
customSubType: document.getElementById('custom-sub-type'),
|
||||
addMoemailFields: document.getElementById('add-moemail-fields'),
|
||||
addTempmailFields: document.getElementById('add-tempmail-fields'),
|
||||
addDuckmailFields: document.getElementById('add-duckmail-fields'),
|
||||
addFreemailFields: document.getElementById('add-freemail-fields'),
|
||||
addImapFields: document.getElementById('add-imap-fields'),
|
||||
|
||||
// 编辑自定义域名模态框
|
||||
editCustomModal: document.getElementById('edit-custom-modal'),
|
||||
editCustomForm: document.getElementById('edit-custom-form'),
|
||||
closeEditCustomModal: document.getElementById('close-edit-custom-modal'),
|
||||
cancelEditCustom: document.getElementById('cancel-edit-custom'),
|
||||
editMoemailFields: document.getElementById('edit-moemail-fields'),
|
||||
editTempmailFields: document.getElementById('edit-tempmail-fields'),
|
||||
editDuckmailFields: document.getElementById('edit-duckmail-fields'),
|
||||
editFreemailFields: document.getElementById('edit-freemail-fields'),
|
||||
editImapFields: document.getElementById('edit-imap-fields'),
|
||||
editCustomTypeBadge: document.getElementById('edit-custom-type-badge'),
|
||||
editCustomSubTypeHidden: document.getElementById('edit-custom-sub-type-hidden'),
|
||||
|
||||
// 编辑 Outlook 模态框
|
||||
editOutlookModal: document.getElementById('edit-outlook-modal'),
|
||||
editOutlookForm: document.getElementById('edit-outlook-form'),
|
||||
closeEditOutlookModal: document.getElementById('close-edit-outlook-modal'),
|
||||
cancelEditOutlook: document.getElementById('cancel-edit-outlook'),
|
||||
};
|
||||
|
||||
const CUSTOM_SUBTYPE_LABELS = {
|
||||
moemail: '🔗 MoeMail(自定义域名 API)',
|
||||
tempmail: '📮 TempMail(自部署 Cloudflare Worker)',
|
||||
duckmail: '🦆 DuckMail(DuckMail API)',
|
||||
freemail: 'Freemail(自部署 Cloudflare Worker)',
|
||||
imap: '📧 IMAP 邮箱(Gmail/QQ/163等)'
|
||||
};
|
||||
|
||||
// 初始化
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadStats();
|
||||
loadOutlookServices();
|
||||
loadCustomServices();
|
||||
loadTempmailConfig();
|
||||
initEventListeners();
|
||||
});
|
||||
|
||||
// 事件监听
|
||||
function initEventListeners() {
|
||||
// Outlook 导入展开/收起
|
||||
elements.toggleOutlookImport.addEventListener('click', () => {
|
||||
const isHidden = elements.outlookImportBody.style.display === 'none';
|
||||
elements.outlookImportBody.style.display = isHidden ? 'block' : 'none';
|
||||
elements.toggleOutlookImport.textContent = isHidden ? '收起' : '展开';
|
||||
});
|
||||
|
||||
// Outlook 导入
|
||||
elements.outlookImportBtn.addEventListener('click', handleOutlookImport);
|
||||
elements.clearImportBtn.addEventListener('click', () => {
|
||||
elements.outlookImportData.value = '';
|
||||
elements.importResult.style.display = 'none';
|
||||
});
|
||||
|
||||
// Outlook 全选
|
||||
elements.selectAllOutlook.addEventListener('change', (e) => {
|
||||
const checkboxes = elements.outlookTable.querySelectorAll('input[type="checkbox"][data-id]');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = e.target.checked;
|
||||
const id = parseInt(cb.dataset.id);
|
||||
if (e.target.checked) selectedOutlook.add(id);
|
||||
else selectedOutlook.delete(id);
|
||||
});
|
||||
updateBatchButtons();
|
||||
});
|
||||
|
||||
// Outlook 批量删除
|
||||
elements.batchDeleteOutlookBtn.addEventListener('click', handleBatchDeleteOutlook);
|
||||
|
||||
// 自定义域名全选
|
||||
elements.selectAllCustom.addEventListener('change', (e) => {
|
||||
const checkboxes = elements.customTable.querySelectorAll('input[type="checkbox"][data-id]');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = e.target.checked;
|
||||
const id = parseInt(cb.dataset.id);
|
||||
if (e.target.checked) selectedCustom.add(id);
|
||||
else selectedCustom.delete(id);
|
||||
});
|
||||
});
|
||||
|
||||
// 添加自定义域名
|
||||
elements.addCustomBtn.addEventListener('click', () => {
|
||||
elements.addCustomForm.reset();
|
||||
switchAddSubType('moemail');
|
||||
elements.addCustomModal.classList.add('active');
|
||||
});
|
||||
elements.closeCustomModal.addEventListener('click', () => elements.addCustomModal.classList.remove('active'));
|
||||
elements.cancelAddCustom.addEventListener('click', () => elements.addCustomModal.classList.remove('active'));
|
||||
elements.addCustomForm.addEventListener('submit', handleAddCustom);
|
||||
|
||||
// 类型切换(添加表单)
|
||||
elements.customSubType.addEventListener('change', (e) => switchAddSubType(e.target.value));
|
||||
|
||||
// 编辑自定义域名
|
||||
elements.closeEditCustomModal.addEventListener('click', () => elements.editCustomModal.classList.remove('active'));
|
||||
elements.cancelEditCustom.addEventListener('click', () => elements.editCustomModal.classList.remove('active'));
|
||||
elements.editCustomForm.addEventListener('submit', handleEditCustom);
|
||||
|
||||
// 编辑 Outlook
|
||||
elements.closeEditOutlookModal.addEventListener('click', () => elements.editOutlookModal.classList.remove('active'));
|
||||
elements.cancelEditOutlook.addEventListener('click', () => elements.editOutlookModal.classList.remove('active'));
|
||||
elements.editOutlookForm.addEventListener('submit', handleEditOutlook);
|
||||
|
||||
// 临时邮箱配置
|
||||
elements.tempmailForm.addEventListener('submit', handleSaveTempmail);
|
||||
elements.testTempmailBtn.addEventListener('click', handleTestTempmail);
|
||||
|
||||
// 点击其他地方关闭更多菜单
|
||||
document.addEventListener('click', () => {
|
||||
document.querySelectorAll('.dropdown-menu.active').forEach(m => m.classList.remove('active'));
|
||||
});
|
||||
}
|
||||
|
||||
function toggleEmailMoreMenu(btn) {
|
||||
const menu = btn.nextElementSibling;
|
||||
const isActive = menu.classList.contains('active');
|
||||
document.querySelectorAll('.dropdown-menu.active').forEach(m => m.classList.remove('active'));
|
||||
if (!isActive) menu.classList.add('active');
|
||||
}
|
||||
|
||||
function closeEmailMoreMenu(el) {
|
||||
const menu = el.closest('.dropdown-menu');
|
||||
if (menu) menu.classList.remove('active');
|
||||
}
|
||||
|
||||
// 切换添加表单子类型
|
||||
function switchAddSubType(subType) {
|
||||
elements.customSubType.value = subType;
|
||||
elements.addMoemailFields.style.display = subType === 'moemail' ? '' : 'none';
|
||||
elements.addTempmailFields.style.display = subType === 'tempmail' ? '' : 'none';
|
||||
elements.addDuckmailFields.style.display = subType === 'duckmail' ? '' : 'none';
|
||||
elements.addFreemailFields.style.display = subType === 'freemail' ? '' : 'none';
|
||||
elements.addImapFields.style.display = subType === 'imap' ? '' : 'none';
|
||||
}
|
||||
|
||||
// 切换编辑表单子类型显示
|
||||
function switchEditSubType(subType) {
|
||||
elements.editCustomSubTypeHidden.value = subType;
|
||||
elements.editMoemailFields.style.display = subType === 'moemail' ? '' : 'none';
|
||||
elements.editTempmailFields.style.display = subType === 'tempmail' ? '' : 'none';
|
||||
elements.editDuckmailFields.style.display = subType === 'duckmail' ? '' : 'none';
|
||||
elements.editFreemailFields.style.display = subType === 'freemail' ? '' : 'none';
|
||||
elements.editImapFields.style.display = subType === 'imap' ? '' : 'none';
|
||||
elements.editCustomTypeBadge.textContent = CUSTOM_SUBTYPE_LABELS[subType] || CUSTOM_SUBTYPE_LABELS.moemail;
|
||||
}
|
||||
|
||||
// 加载统计信息
|
||||
async function loadStats() {
|
||||
try {
|
||||
const data = await api.get('/email-services/stats');
|
||||
elements.outlookCount.textContent = data.outlook_count || 0;
|
||||
elements.customCount.textContent = (data.custom_count || 0) + (data.temp_mail_count || 0) + (data.duck_mail_count || 0) + (data.freemail_count || 0) + (data.imap_mail_count || 0);
|
||||
elements.tempmailStatus.textContent = data.tempmail_available ? '可用' : '不可用';
|
||||
elements.totalEnabled.textContent = data.enabled_count || 0;
|
||||
} catch (error) {
|
||||
console.error('加载统计信息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载 Outlook 服务
|
||||
async function loadOutlookServices() {
|
||||
try {
|
||||
const data = await api.get('/email-services?service_type=outlook');
|
||||
outlookServices = data.services || [];
|
||||
|
||||
if (outlookServices.length === 0) {
|
||||
elements.outlookTable.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">📭</div>
|
||||
<div class="empty-state-title">暂无 Outlook 账户</div>
|
||||
<div class="empty-state-description">请使用上方导入功能添加账户</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
elements.outlookTable.innerHTML = outlookServices.map(service => `
|
||||
<tr data-id="${service.id}">
|
||||
<td><input type="checkbox" data-id="${service.id}" ${selectedOutlook.has(service.id) ? 'checked' : ''}></td>
|
||||
<td>${escapeHtml(service.config?.email || service.name)}</td>
|
||||
<td>
|
||||
<span class="status-badge ${service.config?.has_oauth ? 'active' : 'pending'}">
|
||||
${service.config?.has_oauth ? 'OAuth' : '密码'}
|
||||
</span>
|
||||
</td>
|
||||
<td title="${service.enabled ? '已启用' : '已禁用'}">${service.enabled ? '✅' : '⭕'}</td>
|
||||
<td>${service.priority}</td>
|
||||
<td>${format.date(service.last_used)}</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:4px;align-items:center;white-space:nowrap;">
|
||||
<button class="btn btn-secondary btn-sm" onclick="editOutlookService(${service.id})">编辑</button>
|
||||
<div class="dropdown" style="position:relative;">
|
||||
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation();toggleEmailMoreMenu(this)">更多</button>
|
||||
<div class="dropdown-menu" style="min-width:80px;">
|
||||
<a href="#" class="dropdown-item" onclick="event.preventDefault();closeEmailMoreMenu(this);toggleService(${service.id}, ${!service.enabled})">${service.enabled ? '禁用' : '启用'}</a>
|
||||
<a href="#" class="dropdown-item" onclick="event.preventDefault();closeEmailMoreMenu(this);testService(${service.id})">测试</a>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-danger btn-sm" onclick="deleteService(${service.id}, '${escapeHtml(service.name)}')">删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
elements.outlookTable.querySelectorAll('input[type="checkbox"][data-id]').forEach(cb => {
|
||||
cb.addEventListener('change', (e) => {
|
||||
const id = parseInt(e.target.dataset.id);
|
||||
if (e.target.checked) selectedOutlook.add(id);
|
||||
else selectedOutlook.delete(id);
|
||||
updateBatchButtons();
|
||||
});
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载 Outlook 服务失败:', error);
|
||||
elements.outlookTable.innerHTML = `<tr><td colspan="7"><div class="empty-state"><div class="empty-state-icon">❌</div><div class="empty-state-title">加载失败</div></div></td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function getCustomServiceTypeBadge(subType) {
|
||||
if (subType === 'moemail') {
|
||||
return '<span class="status-badge info">MoeMail</span>';
|
||||
}
|
||||
if (subType === 'tempmail') {
|
||||
return '<span class="status-badge warning">TempMail</span>';
|
||||
}
|
||||
if (subType === 'duckmail') {
|
||||
return '<span class="status-badge success">DuckMail</span>';
|
||||
}
|
||||
if (subType === 'freemail') {
|
||||
return '<span class="status-badge" style="background-color:#9c27b0;color:white;">Freemail</span>';
|
||||
}
|
||||
return '<span class="status-badge" style="background-color:#0288d1;color:white;">IMAP</span>';
|
||||
}
|
||||
|
||||
function getCustomServiceAddress(service) {
|
||||
if (service._subType === 'imap') {
|
||||
const host = service.config?.host || '-';
|
||||
const emailAddr = service.config?.email || '';
|
||||
return `${escapeHtml(host)}<div style="color: var(--text-muted); margin-top: 4px;">${escapeHtml(emailAddr)}</div>`;
|
||||
}
|
||||
const baseUrl = service.config?.base_url || '-';
|
||||
const domain = service.config?.default_domain || service.config?.domain;
|
||||
if (!domain) {
|
||||
return escapeHtml(baseUrl);
|
||||
}
|
||||
return `${escapeHtml(baseUrl)}<div style="color: var(--text-muted); margin-top: 4px;">默认域名:@${escapeHtml(domain)}</div>`;
|
||||
}
|
||||
|
||||
// 加载自定义邮箱服务(moe_mail + temp_mail + duck_mail + freemail 合并)
|
||||
async function loadCustomServices() {
|
||||
try {
|
||||
const [r1, r2, r3, r4, r5] = await Promise.all([
|
||||
api.get('/email-services?service_type=moe_mail'),
|
||||
api.get('/email-services?service_type=temp_mail'),
|
||||
api.get('/email-services?service_type=duck_mail'),
|
||||
api.get('/email-services?service_type=freemail'),
|
||||
api.get('/email-services?service_type=imap_mail')
|
||||
]);
|
||||
customServices = [
|
||||
...(r1.services || []).map(s => ({ ...s, _subType: 'moemail' })),
|
||||
...(r2.services || []).map(s => ({ ...s, _subType: 'tempmail' })),
|
||||
...(r3.services || []).map(s => ({ ...s, _subType: 'duckmail' })),
|
||||
...(r4.services || []).map(s => ({ ...s, _subType: 'freemail' })),
|
||||
...(r5.services || []).map(s => ({ ...s, _subType: 'imap' }))
|
||||
];
|
||||
|
||||
if (customServices.length === 0) {
|
||||
elements.customTable.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="8">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">📭</div>
|
||||
<div class="empty-state-title">暂无自定义邮箱服务</div>
|
||||
<div class="empty-state-description">点击「添加服务」按钮创建新服务</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
elements.customTable.innerHTML = customServices.map(service => {
|
||||
return `
|
||||
<tr data-id="${service.id}">
|
||||
<td><input type="checkbox" data-id="${service.id}" ${selectedCustom.has(service.id) ? 'checked' : ''}></td>
|
||||
<td>${escapeHtml(service.name)}</td>
|
||||
<td>${getCustomServiceTypeBadge(service._subType)}</td>
|
||||
<td style="font-size: 0.75rem;">${getCustomServiceAddress(service)}</td>
|
||||
<td title="${service.enabled ? '已启用' : '已禁用'}">${service.enabled ? '✅' : '⭕'}</td>
|
||||
<td>${service.priority}</td>
|
||||
<td>${format.date(service.last_used)}</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:4px;align-items:center;white-space:nowrap;">
|
||||
<button class="btn btn-secondary btn-sm" onclick="editCustomService(${service.id}, '${service._subType}')">编辑</button>
|
||||
<div class="dropdown" style="position:relative;">
|
||||
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation();toggleEmailMoreMenu(this)">更多</button>
|
||||
<div class="dropdown-menu" style="min-width:80px;">
|
||||
<a href="#" class="dropdown-item" onclick="event.preventDefault();closeEmailMoreMenu(this);toggleService(${service.id}, ${!service.enabled})">${service.enabled ? '禁用' : '启用'}</a>
|
||||
<a href="#" class="dropdown-item" onclick="event.preventDefault();closeEmailMoreMenu(this);testService(${service.id})">测试</a>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-danger btn-sm" onclick="deleteService(${service.id}, '${escapeHtml(service.name)}')">删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
elements.customTable.querySelectorAll('input[type="checkbox"][data-id]').forEach(cb => {
|
||||
cb.addEventListener('change', (e) => {
|
||||
const id = parseInt(e.target.dataset.id);
|
||||
if (e.target.checked) selectedCustom.add(id);
|
||||
else selectedCustom.delete(id);
|
||||
});
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载自定义邮箱服务失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载临时邮箱配置
|
||||
async function loadTempmailConfig() {
|
||||
try {
|
||||
const settings = await api.get('/settings');
|
||||
if (settings.tempmail) {
|
||||
elements.tempmailApi.value = settings.tempmail.api_url || '';
|
||||
elements.tempmailEnabled.checked = settings.tempmail.enabled !== false;
|
||||
}
|
||||
} catch (error) {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
// Outlook 导入
|
||||
async function handleOutlookImport() {
|
||||
const data = elements.outlookImportData.value.trim();
|
||||
if (!data) { toast.error('请输入导入数据'); return; }
|
||||
|
||||
elements.outlookImportBtn.disabled = true;
|
||||
elements.outlookImportBtn.textContent = '导入中...';
|
||||
|
||||
try {
|
||||
const result = await api.post('/email-services/outlook/batch-import', {
|
||||
data: data,
|
||||
enabled: elements.outlookImportEnabled.checked,
|
||||
priority: parseInt(elements.outlookImportPriority.value) || 0
|
||||
});
|
||||
|
||||
elements.importResult.style.display = 'block';
|
||||
elements.importResult.innerHTML = `
|
||||
<div class="import-stats">
|
||||
<span>✅ 成功导入: <strong>${result.success || 0}</strong></span>
|
||||
<span>❌ 失败: <strong>${result.failed || 0}</strong></span>
|
||||
</div>
|
||||
${result.errors?.length ? `<div class="import-errors" style="margin-top: var(--spacing-sm);"><strong>错误详情:</strong><ul>${result.errors.map(e => `<li>${escapeHtml(e)}</li>`).join('')}</ul></div>` : ''}
|
||||
`;
|
||||
|
||||
if (result.success > 0) {
|
||||
toast.success(`成功导入 ${result.success} 个账户`);
|
||||
loadOutlookServices();
|
||||
loadStats();
|
||||
elements.outlookImportData.value = '';
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('导入失败: ' + error.message);
|
||||
} finally {
|
||||
elements.outlookImportBtn.disabled = false;
|
||||
elements.outlookImportBtn.textContent = '📥 开始导入';
|
||||
}
|
||||
}
|
||||
|
||||
// 添加自定义邮箱服务(根据子类型区分)
|
||||
async function handleAddCustom(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const subType = formData.get('sub_type');
|
||||
|
||||
let serviceType, config;
|
||||
if (subType === 'moemail') {
|
||||
serviceType = 'moe_mail';
|
||||
config = {
|
||||
base_url: formData.get('api_url'),
|
||||
api_key: formData.get('api_key'),
|
||||
default_domain: formData.get('domain')
|
||||
};
|
||||
} else if (subType === 'tempmail') {
|
||||
serviceType = 'temp_mail';
|
||||
config = {
|
||||
base_url: formData.get('tm_base_url'),
|
||||
admin_password: formData.get('tm_admin_password'),
|
||||
domain: formData.get('tm_domain'),
|
||||
enable_prefix: true
|
||||
};
|
||||
} else if (subType === 'duckmail') {
|
||||
serviceType = 'duck_mail';
|
||||
config = {
|
||||
base_url: formData.get('dm_base_url'),
|
||||
api_key: formData.get('dm_api_key'),
|
||||
default_domain: formData.get('dm_domain'),
|
||||
password_length: parseInt(formData.get('dm_password_length'), 10) || 12
|
||||
};
|
||||
} else if (subType === 'freemail') {
|
||||
serviceType = 'freemail';
|
||||
config = {
|
||||
base_url: formData.get('fm_base_url'),
|
||||
admin_token: formData.get('fm_admin_token'),
|
||||
domain: formData.get('fm_domain')
|
||||
};
|
||||
} else {
|
||||
serviceType = 'imap_mail';
|
||||
config = {
|
||||
host: formData.get('imap_host'),
|
||||
port: parseInt(formData.get('imap_port'), 10) || 993,
|
||||
use_ssl: formData.get('imap_use_ssl') !== 'false',
|
||||
email: formData.get('imap_email'),
|
||||
password: formData.get('imap_password')
|
||||
};
|
||||
}
|
||||
|
||||
const data = {
|
||||
service_type: serviceType,
|
||||
name: formData.get('name'),
|
||||
config,
|
||||
enabled: formData.get('enabled') === 'on',
|
||||
priority: parseInt(formData.get('priority')) || 0
|
||||
};
|
||||
|
||||
try {
|
||||
await api.post('/email-services', data);
|
||||
toast.success('服务添加成功');
|
||||
elements.addCustomModal.classList.remove('active');
|
||||
e.target.reset();
|
||||
loadCustomServices();
|
||||
loadStats();
|
||||
} catch (error) {
|
||||
toast.error('添加失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 切换服务状态
|
||||
async function toggleService(id, enabled) {
|
||||
try {
|
||||
await api.patch(`/email-services/${id}`, { enabled });
|
||||
toast.success(enabled ? '已启用' : '已禁用');
|
||||
loadOutlookServices();
|
||||
loadCustomServices();
|
||||
loadStats();
|
||||
} catch (error) {
|
||||
toast.error('操作失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 测试服务
|
||||
async function testService(id) {
|
||||
try {
|
||||
const result = await api.post(`/email-services/${id}/test`);
|
||||
if (result.success) toast.success('测试成功');
|
||||
else toast.error('测试失败: ' + (result.error || '未知错误'));
|
||||
} catch (error) {
|
||||
toast.error('测试失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 删除服务
|
||||
async function deleteService(id, name) {
|
||||
const confirmed = await confirm(`确定要删除 "${name}" 吗?`);
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await api.delete(`/email-services/${id}`);
|
||||
toast.success('已删除');
|
||||
selectedOutlook.delete(id);
|
||||
selectedCustom.delete(id);
|
||||
loadOutlookServices();
|
||||
loadCustomServices();
|
||||
loadStats();
|
||||
} catch (error) {
|
||||
toast.error('删除失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 批量删除 Outlook
|
||||
async function handleBatchDeleteOutlook() {
|
||||
if (selectedOutlook.size === 0) return;
|
||||
const confirmed = await confirm(`确定要删除选中的 ${selectedOutlook.size} 个账户吗?`);
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const result = await api.request('/email-services/outlook/batch', {
|
||||
method: 'DELETE',
|
||||
body: Array.from(selectedOutlook)
|
||||
});
|
||||
toast.success(`成功删除 ${result.deleted || selectedOutlook.size} 个账户`);
|
||||
selectedOutlook.clear();
|
||||
loadOutlookServices();
|
||||
loadStats();
|
||||
} catch (error) {
|
||||
toast.error('删除失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存临时邮箱配置
|
||||
async function handleSaveTempmail(e) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.post('/settings/tempmail', {
|
||||
api_url: elements.tempmailApi.value,
|
||||
enabled: elements.tempmailEnabled.checked
|
||||
});
|
||||
toast.success('配置已保存');
|
||||
} catch (error) {
|
||||
toast.error('保存失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 测试临时邮箱
|
||||
async function handleTestTempmail() {
|
||||
elements.testTempmailBtn.disabled = true;
|
||||
elements.testTempmailBtn.textContent = '测试中...';
|
||||
try {
|
||||
const result = await api.post('/email-services/test-tempmail', {
|
||||
api_url: elements.tempmailApi.value
|
||||
});
|
||||
if (result.success) toast.success('临时邮箱连接正常');
|
||||
else toast.error('连接失败: ' + (result.error || '未知错误'));
|
||||
} catch (error) {
|
||||
toast.error('测试失败: ' + error.message);
|
||||
} finally {
|
||||
elements.testTempmailBtn.disabled = false;
|
||||
elements.testTempmailBtn.textContent = '🔌 测试连接';
|
||||
}
|
||||
}
|
||||
|
||||
// 更新批量按钮
|
||||
function updateBatchButtons() {
|
||||
const count = selectedOutlook.size;
|
||||
elements.batchDeleteOutlookBtn.disabled = count === 0;
|
||||
elements.batchDeleteOutlookBtn.textContent = count > 0 ? `🗑️ 删除选中 (${count})` : '🗑️ 批量删除';
|
||||
}
|
||||
|
||||
// HTML 转义
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ============== 编辑功能 ==============
|
||||
|
||||
// 编辑自定义邮箱服务(支持 moemail / tempmail / duckmail)
|
||||
async function editCustomService(id, subType) {
|
||||
try {
|
||||
const service = await api.get(`/email-services/${id}/full`);
|
||||
const resolvedSubType = subType || (
|
||||
service.service_type === 'temp_mail'
|
||||
? 'tempmail'
|
||||
: service.service_type === 'duck_mail'
|
||||
? 'duckmail'
|
||||
: service.service_type === 'freemail'
|
||||
? 'freemail'
|
||||
: service.service_type === 'imap_mail'
|
||||
? 'imap'
|
||||
: 'moemail'
|
||||
);
|
||||
|
||||
document.getElementById('edit-custom-id').value = service.id;
|
||||
document.getElementById('edit-custom-name').value = service.name || '';
|
||||
document.getElementById('edit-custom-priority').value = service.priority || 0;
|
||||
document.getElementById('edit-custom-enabled').checked = service.enabled;
|
||||
|
||||
switchEditSubType(resolvedSubType);
|
||||
|
||||
if (resolvedSubType === 'moemail') {
|
||||
document.getElementById('edit-custom-api-url').value = service.config?.base_url || '';
|
||||
document.getElementById('edit-custom-api-key').value = '';
|
||||
document.getElementById('edit-custom-api-key').placeholder = service.config?.api_key ? '已设置,留空保持不变' : 'API Key';
|
||||
document.getElementById('edit-custom-domain').value = service.config?.default_domain || service.config?.domain || '';
|
||||
} else if (resolvedSubType === 'tempmail') {
|
||||
document.getElementById('edit-tm-base-url').value = service.config?.base_url || '';
|
||||
document.getElementById('edit-tm-admin-password').value = '';
|
||||
document.getElementById('edit-tm-admin-password').placeholder = service.config?.admin_password ? '已设置,留空保持不变' : '请输入 Admin 密码';
|
||||
document.getElementById('edit-tm-domain').value = service.config?.domain || '';
|
||||
} else if (resolvedSubType === 'duckmail') {
|
||||
document.getElementById('edit-dm-base-url').value = service.config?.base_url || '';
|
||||
document.getElementById('edit-dm-api-key').value = '';
|
||||
document.getElementById('edit-dm-api-key').placeholder = service.config?.api_key ? '已设置,留空保持不变' : '请输入 API Key(可选)';
|
||||
document.getElementById('edit-dm-domain').value = service.config?.default_domain || '';
|
||||
document.getElementById('edit-dm-password-length').value = service.config?.password_length || 12;
|
||||
} else if (resolvedSubType === 'freemail') {
|
||||
document.getElementById('edit-fm-base-url').value = service.config?.base_url || '';
|
||||
document.getElementById('edit-fm-admin-token').value = '';
|
||||
document.getElementById('edit-fm-admin-token').placeholder = service.config?.admin_token ? '已设置,留空保持不变' : '请输入 Admin Token';
|
||||
document.getElementById('edit-fm-domain').value = service.config?.domain || '';
|
||||
} else {
|
||||
document.getElementById('edit-imap-host').value = service.config?.host || '';
|
||||
document.getElementById('edit-imap-port').value = service.config?.port || 993;
|
||||
document.getElementById('edit-imap-use-ssl').value = service.config?.use_ssl !== false ? 'true' : 'false';
|
||||
document.getElementById('edit-imap-email').value = service.config?.email || '';
|
||||
document.getElementById('edit-imap-password').value = '';
|
||||
document.getElementById('edit-imap-password').placeholder = service.config?.password ? '已设置,留空保持不变' : '请输入密码/授权码';
|
||||
}
|
||||
|
||||
elements.editCustomModal.classList.add('active');
|
||||
} catch (error) {
|
||||
toast.error('获取服务信息失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存编辑自定义邮箱服务
|
||||
async function handleEditCustom(e) {
|
||||
e.preventDefault();
|
||||
const id = document.getElementById('edit-custom-id').value;
|
||||
const formData = new FormData(e.target);
|
||||
const subType = formData.get('sub_type');
|
||||
|
||||
let config;
|
||||
if (subType === 'moemail') {
|
||||
config = {
|
||||
base_url: formData.get('api_url'),
|
||||
default_domain: formData.get('domain')
|
||||
};
|
||||
const apiKey = formData.get('api_key');
|
||||
if (apiKey && apiKey.trim()) config.api_key = apiKey.trim();
|
||||
} else if (subType === 'tempmail') {
|
||||
config = {
|
||||
base_url: formData.get('tm_base_url'),
|
||||
domain: formData.get('tm_domain'),
|
||||
enable_prefix: true
|
||||
};
|
||||
const pwd = formData.get('tm_admin_password');
|
||||
if (pwd && pwd.trim()) config.admin_password = pwd.trim();
|
||||
} else if (subType === 'duckmail') {
|
||||
config = {
|
||||
base_url: formData.get('dm_base_url'),
|
||||
default_domain: formData.get('dm_domain'),
|
||||
password_length: parseInt(formData.get('dm_password_length'), 10) || 12
|
||||
};
|
||||
const apiKey = formData.get('dm_api_key');
|
||||
if (apiKey && apiKey.trim()) config.api_key = apiKey.trim();
|
||||
} else if (subType === 'freemail') {
|
||||
config = {
|
||||
base_url: formData.get('fm_base_url'),
|
||||
domain: formData.get('fm_domain')
|
||||
};
|
||||
const token = formData.get('fm_admin_token');
|
||||
if (token && token.trim()) config.admin_token = token.trim();
|
||||
} else {
|
||||
config = {
|
||||
host: formData.get('imap_host'),
|
||||
port: parseInt(formData.get('imap_port'), 10) || 993,
|
||||
use_ssl: formData.get('imap_use_ssl') !== 'false',
|
||||
email: formData.get('imap_email')
|
||||
};
|
||||
const pwd = formData.get('imap_password');
|
||||
if (pwd && pwd.trim()) config.password = pwd.trim();
|
||||
}
|
||||
|
||||
const updateData = {
|
||||
name: formData.get('name'),
|
||||
priority: parseInt(formData.get('priority')) || 0,
|
||||
enabled: formData.get('enabled') === 'on',
|
||||
config
|
||||
};
|
||||
|
||||
try {
|
||||
await api.patch(`/email-services/${id}`, updateData);
|
||||
toast.success('服务更新成功');
|
||||
elements.editCustomModal.classList.remove('active');
|
||||
loadCustomServices();
|
||||
loadStats();
|
||||
} catch (error) {
|
||||
toast.error('更新失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑 Outlook 服务
|
||||
async function editOutlookService(id) {
|
||||
try {
|
||||
const service = await api.get(`/email-services/${id}/full`);
|
||||
document.getElementById('edit-outlook-id').value = service.id;
|
||||
document.getElementById('edit-outlook-email').value = service.config?.email || service.name || '';
|
||||
document.getElementById('edit-outlook-password').value = '';
|
||||
document.getElementById('edit-outlook-password').placeholder = service.config?.password ? '已设置,留空保持不变' : '请输入密码';
|
||||
document.getElementById('edit-outlook-client-id').value = service.config?.client_id || '';
|
||||
document.getElementById('edit-outlook-refresh-token').value = '';
|
||||
document.getElementById('edit-outlook-refresh-token').placeholder = service.config?.refresh_token ? '已设置,留空保持不变' : 'OAuth Refresh Token';
|
||||
document.getElementById('edit-outlook-priority').value = service.priority || 0;
|
||||
document.getElementById('edit-outlook-enabled').checked = service.enabled;
|
||||
elements.editOutlookModal.classList.add('active');
|
||||
} catch (error) {
|
||||
toast.error('获取服务信息失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存编辑 Outlook 服务
|
||||
async function handleEditOutlook(e) {
|
||||
e.preventDefault();
|
||||
const id = document.getElementById('edit-outlook-id').value;
|
||||
const formData = new FormData(e.target);
|
||||
|
||||
let currentService;
|
||||
try {
|
||||
currentService = await api.get(`/email-services/${id}/full`);
|
||||
} catch (error) {
|
||||
toast.error('获取服务信息失败');
|
||||
return;
|
||||
}
|
||||
|
||||
const updateData = {
|
||||
name: formData.get('email'),
|
||||
priority: parseInt(formData.get('priority')) || 0,
|
||||
enabled: formData.get('enabled') === 'on',
|
||||
config: {
|
||||
email: formData.get('email'),
|
||||
password: formData.get('password')?.trim() || currentService.config?.password || '',
|
||||
client_id: formData.get('client_id')?.trim() || currentService.config?.client_id || '',
|
||||
refresh_token: formData.get('refresh_token')?.trim() || currentService.config?.refresh_token || ''
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await api.patch(`/email-services/${id}`, updateData);
|
||||
toast.success('账户更新成功');
|
||||
elements.editOutlookModal.classList.remove('active');
|
||||
loadOutlookServices();
|
||||
loadStats();
|
||||
} catch (error) {
|
||||
toast.error('更新失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
146
static/js/payment.js
Normal file
146
static/js/payment.js
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* 支付页面 JavaScript
|
||||
*/
|
||||
|
||||
const COUNTRY_CURRENCY_MAP = {
|
||||
SG: 'SGD', US: 'USD', TR: 'TRY', JP: 'JPY',
|
||||
HK: 'HKD', GB: 'GBP', EU: 'EUR', AU: 'AUD',
|
||||
CA: 'CAD', IN: 'INR', BR: 'BRL', MX: 'MXN',
|
||||
};
|
||||
|
||||
let selectedPlan = 'plus';
|
||||
let generatedLink = '';
|
||||
|
||||
// 初始化
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadAccounts();
|
||||
});
|
||||
|
||||
// 加载账号列表
|
||||
async function loadAccounts() {
|
||||
try {
|
||||
const resp = await fetch('/api/accounts?page=1&page_size=100&status=active');
|
||||
const data = await resp.json();
|
||||
const sel = document.getElementById('account-select');
|
||||
sel.innerHTML = '<option value="">-- 请选择账号 --</option>';
|
||||
(data.accounts || []).forEach(acc => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = acc.id;
|
||||
opt.textContent = acc.email;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('加载账号失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 国家切换
|
||||
function onCountryChange() {
|
||||
const country = document.getElementById('country-select').value;
|
||||
const currency = COUNTRY_CURRENCY_MAP[country] || 'USD';
|
||||
document.getElementById('currency-display').value = currency;
|
||||
}
|
||||
|
||||
// 选择套餐
|
||||
function selectPlan(plan) {
|
||||
selectedPlan = plan;
|
||||
document.getElementById('plan-plus').classList.toggle('selected', plan === 'plus');
|
||||
document.getElementById('plan-team').classList.toggle('selected', plan === 'team');
|
||||
document.getElementById('team-options').classList.toggle('show', plan === 'team');
|
||||
// 隐藏已生成的链接
|
||||
document.getElementById('link-box').classList.remove('show');
|
||||
generatedLink = '';
|
||||
}
|
||||
|
||||
// 生成支付链接
|
||||
async function generateLink() {
|
||||
const accountId = document.getElementById('account-select').value;
|
||||
if (!accountId) {
|
||||
ui.showToast('请先选择账号', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const country = document.getElementById('country-select').value || 'SG';
|
||||
|
||||
const body = {
|
||||
account_id: parseInt(accountId),
|
||||
plan_type: selectedPlan,
|
||||
country: country,
|
||||
};
|
||||
|
||||
if (selectedPlan === 'team') {
|
||||
body.workspace_name = document.getElementById('workspace-name').value || 'MyTeam';
|
||||
body.seat_quantity = parseInt(document.getElementById('seat-quantity').value) || 5;
|
||||
body.price_interval = document.getElementById('price-interval').value;
|
||||
}
|
||||
|
||||
const btn = document.querySelector('.form-actions .btn-primary');
|
||||
if (btn) { btn.disabled = true; btn.textContent = '生成中...'; }
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/payment/generate-link', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success && data.link) {
|
||||
generatedLink = data.link;
|
||||
document.getElementById('link-text').value = data.link;
|
||||
document.getElementById('link-box').classList.add('show');
|
||||
document.getElementById('open-status').textContent = '';
|
||||
ui.showToast('支付链接生成成功', 'success');
|
||||
} else {
|
||||
ui.showToast(data.detail || '生成链接失败', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
ui.showToast('请求失败: ' + e.message, 'error');
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.textContent = '生成支付链接'; }
|
||||
}
|
||||
}
|
||||
|
||||
// 复制链接
|
||||
function copyLink() {
|
||||
if (!generatedLink) return;
|
||||
navigator.clipboard.writeText(generatedLink).then(() => {
|
||||
ui.showToast('已复制到剪贴板', 'success');
|
||||
}).catch(() => {
|
||||
const ta = document.getElementById('link-text');
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
ui.showToast('已复制到剪贴板', 'success');
|
||||
});
|
||||
}
|
||||
|
||||
// 无痕打开浏览器(携带账号 cookie)
|
||||
async function openIncognito() {
|
||||
if (!generatedLink) {
|
||||
ui.showToast('请先生成链接', 'warning');
|
||||
return;
|
||||
}
|
||||
const accountId = document.getElementById('account-select').value;
|
||||
const statusEl = document.getElementById('open-status');
|
||||
statusEl.textContent = '正在打开...';
|
||||
try {
|
||||
const body = { url: generatedLink };
|
||||
if (accountId) body.account_id = parseInt(accountId);
|
||||
|
||||
const resp = await fetch('/api/payment/open-incognito', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
statusEl.textContent = '已在无痕模式打开浏览器';
|
||||
ui.showToast('无痕浏览器已打开', 'success');
|
||||
} else {
|
||||
statusEl.textContent = data.message || '未找到可用浏览器,请手动复制链接';
|
||||
ui.showToast(data.message || '未找到浏览器', 'warning');
|
||||
}
|
||||
} catch (e) {
|
||||
statusEl.textContent = '请求失败: ' + e.message;
|
||||
ui.showToast('请求失败', 'error');
|
||||
}
|
||||
}
|
||||
1548
static/js/settings.js
Normal file
1548
static/js/settings.js
Normal file
File diff suppressed because it is too large
Load Diff
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