This commit is contained in:
cnlimiter
2026-03-14 17:36:55 +08:00
parent 9d3099fcd8
commit 0688f4ca7e
8 changed files with 2066 additions and 882 deletions
+158 -17
View File
@@ -35,6 +35,7 @@ class RegistrationTaskCreate(BaseModel):
email_service_type: str = "tempmail" email_service_type: str = "tempmail"
proxy: Optional[str] = None proxy: Optional[str] = None
email_service_config: Optional[dict] = None email_service_config: Optional[dict] = None
email_service_id: Optional[int] = None # 使用数据库中已配置的邮箱服务 ID
class BatchRegistrationRequest(BaseModel): class BatchRegistrationRequest(BaseModel):
@@ -43,6 +44,7 @@ class BatchRegistrationRequest(BaseModel):
email_service_type: str = "tempmail" email_service_type: str = "tempmail"
proxy: Optional[str] = None proxy: Optional[str] = None
email_service_config: Optional[dict] = None email_service_config: Optional[dict] = None
email_service_id: Optional[int] = None # 使用数据库中已配置的邮箱服务 ID
interval_min: int = 5 # 最小间隔秒数 interval_min: int = 5 # 最小间隔秒数
interval_max: int = 30 # 最大间隔秒数 interval_max: int = 30 # 最大间隔秒数
@@ -97,7 +99,7 @@ def task_to_response(task: RegistrationTask) -> RegistrationTaskResponse:
) )
async def run_registration_task(task_uuid: str, email_service_type: str, proxy: Optional[str], email_service_config: Optional[dict]): async def run_registration_task(task_uuid: str, email_service_type: str, proxy: Optional[str], email_service_config: Optional[dict], email_service_id: Optional[int] = None):
"""异步执行注册任务""" """异步执行注册任务"""
with get_db() as db: with get_db() as db:
try: try:
@@ -116,21 +118,66 @@ async def run_registration_task(task_uuid: str, email_service_type: str, proxy:
service_type = EmailServiceType(email_service_type) service_type = EmailServiceType(email_service_type)
settings = get_settings() settings = get_settings()
if service_type == EmailServiceType.TEMPMAIL: # 优先使用数据库中配置的邮箱服务
config = { if email_service_id:
"base_url": settings.tempmail_base_url, from ...database.models import EmailService as EmailServiceModel
"timeout": settings.tempmail_timeout, db_service = db.query(EmailServiceModel).filter(
"max_retries": settings.tempmail_max_retries, EmailServiceModel.id == email_service_id,
"proxy_url": proxy, EmailServiceModel.enabled == True
} ).first()
elif service_type == EmailServiceType.CUSTOM_DOMAIN:
config = { if db_service:
"base_url": settings.custom_domain_base_url, config = db_service.config.copy() if db_service.config else {}
"api_key": settings.custom_domain_api_key.get_secret_value() if settings.custom_domain_api_key else "", # 更新任务关联的邮箱服务
"proxy_url": proxy, crud.update_registration_task(db, task_uuid, email_service_id=db_service.id)
} logger.info(f"使用数据库邮箱服务: {db_service.name} (ID: {db_service.id})")
else:
raise ValueError(f"邮箱服务不存在或已禁用: {email_service_id}")
else: else:
config = email_service_config or {} # 使用默认配置或传入的配置
if service_type == EmailServiceType.TEMPMAIL:
config = {
"base_url": settings.tempmail_base_url,
"timeout": settings.tempmail_timeout,
"max_retries": settings.tempmail_max_retries,
"proxy_url": proxy,
}
elif service_type == EmailServiceType.CUSTOM_DOMAIN:
# 检查数据库中是否有可用的自定义域名服务
from ...database.models import EmailService as EmailServiceModel
db_service = db.query(EmailServiceModel).filter(
EmailServiceModel.service_type == "custom_domain",
EmailServiceModel.enabled == True
).order_by(EmailServiceModel.priority.asc()).first()
if db_service and db_service.config:
config = db_service.config.copy()
crud.update_registration_task(db, task_uuid, email_service_id=db_service.id)
logger.info(f"使用数据库自定义域名服务: {db_service.name}")
elif settings.custom_domain_base_url and settings.custom_domain_api_key:
config = {
"base_url": settings.custom_domain_base_url,
"api_key": settings.custom_domain_api_key.get_secret_value() if settings.custom_domain_api_key else "",
"proxy_url": proxy,
}
else:
raise ValueError("没有可用的自定义域名邮箱服务,请先在设置中配置")
elif service_type == EmailServiceType.OUTLOOK:
# 检查数据库中是否有可用的 Outlook 账户
from ...database.models import EmailService as EmailServiceModel
db_service = db.query(EmailServiceModel).filter(
EmailServiceModel.service_type == "outlook",
EmailServiceModel.enabled == True
).order_by(EmailServiceModel.priority.asc()).first()
if db_service and db_service.config:
config = db_service.config.copy()
crud.update_registration_task(db, task_uuid, email_service_id=db_service.id)
logger.info(f"使用数据库 Outlook 账户: {db_service.name}")
else:
raise ValueError("没有可用的 Outlook 账户,请先在设置中导入账户")
else:
config = email_service_config or {}
email_service = EmailServiceFactory.create(service_type, config) email_service = EmailServiceFactory.create(service_type, config)
@@ -194,6 +241,7 @@ async def run_batch_registration(
email_service_type: str, email_service_type: str,
proxy: Optional[str], proxy: Optional[str],
email_service_config: Optional[dict], email_service_config: Optional[dict],
email_service_id: Optional[int],
interval_min: int, interval_min: int,
interval_max: int interval_max: int
): ):
@@ -223,7 +271,7 @@ async def run_batch_registration(
# 运行单个注册任务 # 运行单个注册任务
await run_registration_task( await run_registration_task(
task_uuid, email_service_type, proxy, email_service_config task_uuid, email_service_type, proxy, email_service_config, email_service_id
) )
# 更新统计 # 更新统计
@@ -289,7 +337,8 @@ async def start_registration(
task_uuid, task_uuid,
request.email_service_type, request.email_service_type,
request.proxy, request.proxy,
request.email_service_config request.email_service_config,
request.email_service_id
) )
return task_to_response(task) return task_to_response(task)
@@ -350,6 +399,7 @@ async def start_batch_registration(
request.email_service_type, request.email_service_type,
request.proxy, request.proxy,
request.email_service_config, request.email_service_config,
request.email_service_id,
request.interval_min, request.interval_min,
request.interval_max request.interval_max
) )
@@ -498,3 +548,94 @@ async def get_registration_stats():
"by_status": {status: count for status, count in status_stats}, "by_status": {status: count for status, count in status_stats},
"today_count": today_count "today_count": today_count
} }
@router.get("/available-services")
async def get_available_email_services():
"""
获取可用于注册的邮箱服务列表
返回所有已启用的邮箱服务,包括:
- tempmail: 临时邮箱(无需配置)
- outlook: 已导入的 Outlook 账户
- custom_domain: 已配置的自定义域名服务
"""
from ...database.models import EmailService as EmailServiceModel
from ...config.settings import get_settings
settings = get_settings()
result = {
"tempmail": {
"available": True,
"count": 1,
"services": [{
"id": None,
"name": "Tempmail.lol",
"type": "tempmail",
"description": "临时邮箱,自动创建"
}]
},
"outlook": {
"available": False,
"count": 0,
"services": []
},
"custom_domain": {
"available": False,
"count": 0,
"services": []
}
}
with get_db() as db:
# 获取 Outlook 账户
outlook_services = db.query(EmailServiceModel).filter(
EmailServiceModel.service_type == "outlook",
EmailServiceModel.enabled == True
).order_by(EmailServiceModel.priority.asc()).all()
for service in outlook_services:
config = service.config or {}
result["outlook"]["services"].append({
"id": service.id,
"name": service.name,
"type": "outlook",
"has_oauth": bool(config.get("client_id") and config.get("refresh_token")),
"priority": service.priority
})
result["outlook"]["count"] = len(outlook_services)
result["outlook"]["available"] = len(outlook_services) > 0
# 获取自定义域名服务
custom_services = db.query(EmailServiceModel).filter(
EmailServiceModel.service_type == "custom_domain",
EmailServiceModel.enabled == True
).order_by(EmailServiceModel.priority.asc()).all()
for service in custom_services:
config = service.config or {}
result["custom_domain"]["services"].append({
"id": service.id,
"name": service.name,
"type": "custom_domain",
"default_domain": config.get("default_domain"),
"priority": service.priority
})
result["custom_domain"]["count"] = len(custom_services)
result["custom_domain"]["available"] = len(custom_services) > 0
# 如果数据库中没有自定义域名服务,检查 settings
if not result["custom_domain"]["available"]:
if settings.custom_domain_base_url and settings.custom_domain_api_key:
result["custom_domain"]["available"] = True
result["custom_domain"]["count"] = 1
result["custom_domain"]["services"].append({
"id": None,
"name": "默认自定义域名服务",
"type": "custom_domain",
"from_settings": True
})
return result
+907 -180
View File
File diff suppressed because it is too large Load Diff
+226 -166
View File
@@ -1,36 +1,37 @@
/** /**
* 账号管理页面 JavaScript * 账号管理页面 JavaScript
* 使用 utils.js 中的工具库
*/ */
// API 基础路径
const API_BASE = '/api';
// 状态 // 状态
let currentPage = 1; let currentPage = 1;
let pageSize = 20; let pageSize = 20;
let totalAccounts = 0; let totalAccounts = 0;
let selectedAccounts = new Set(); let selectedAccounts = new Set();
let isLoading = false;
// DOM 元素 // DOM 元素
const accountsTable = document.getElementById('accounts-table'); const elements = {
const totalAccountsEl = document.getElementById('total-accounts'); table: document.getElementById('accounts-table'),
const activeAccountsEl = document.getElementById('active-accounts'); totalAccounts: document.getElementById('total-accounts'),
const expiredAccountsEl = document.getElementById('expired-accounts'); activeAccounts: document.getElementById('active-accounts'),
const failedAccountsEl = document.getElementById('failed-accounts'); expiredAccounts: document.getElementById('expired-accounts'),
const filterStatus = document.getElementById('filter-status'); failedAccounts: document.getElementById('failed-accounts'),
const filterService = document.getElementById('filter-service'); filterStatus: document.getElementById('filter-status'),
const searchInput = document.getElementById('search-input'); filterService: document.getElementById('filter-service'),
const refreshBtn = document.getElementById('refresh-btn'); searchInput: document.getElementById('search-input'),
const batchDeleteBtn = document.getElementById('batch-delete-btn'); refreshBtn: document.getElementById('refresh-btn'),
const exportBtn = document.getElementById('export-btn'); batchDeleteBtn: document.getElementById('batch-delete-btn'),
const exportMenu = document.getElementById('export-menu'); exportBtn: document.getElementById('export-btn'),
const selectAllCheckbox = document.getElementById('select-all'); exportMenu: document.getElementById('export-menu'),
const prevPageBtn = document.getElementById('prev-page'); selectAll: document.getElementById('select-all'),
const nextPageBtn = document.getElementById('next-page'); prevPage: document.getElementById('prev-page'),
const pageInfo = document.getElementById('page-info'); nextPage: document.getElementById('next-page'),
const detailModal = document.getElementById('detail-modal'); pageInfo: document.getElementById('page-info'),
const modalBody = document.getElementById('modal-body'); detailModal: document.getElementById('detail-modal'),
const closeModalBtn = document.getElementById('close-modal'); modalBody: document.getElementById('modal-body'),
closeModal: document.getElementById('close-modal')
};
// 初始化 // 初始化
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
@@ -42,38 +43,44 @@ document.addEventListener('DOMContentLoaded', () => {
// 事件监听 // 事件监听
function initEventListeners() { function initEventListeners() {
// 筛选 // 筛选
filterStatus.addEventListener('change', () => { elements.filterStatus.addEventListener('change', () => {
currentPage = 1; currentPage = 1;
loadAccounts(); loadAccounts();
}); });
filterService.addEventListener('change', () => { elements.filterService.addEventListener('change', () => {
currentPage = 1; currentPage = 1;
loadAccounts(); loadAccounts();
}); });
// 搜索 // 搜索(防抖)
let searchTimeout; elements.searchInput.addEventListener('input', debounce(() => {
searchInput.addEventListener('input', () => { currentPage = 1;
clearTimeout(searchTimeout); loadAccounts();
searchTimeout = setTimeout(() => { }, 300));
currentPage = 1;
// 快捷键聚焦搜索
elements.searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
elements.searchInput.blur();
elements.searchInput.value = '';
loadAccounts(); loadAccounts();
}, 300); }
}); });
// 刷新 // 刷新
refreshBtn.addEventListener('click', () => { elements.refreshBtn.addEventListener('click', () => {
loadStats(); loadStats();
loadAccounts(); loadAccounts();
toast.info('已刷新');
}); });
// 批量删除 // 批量删除
batchDeleteBtn.addEventListener('click', handleBatchDelete); elements.batchDeleteBtn.addEventListener('click', handleBatchDelete);
// 全选 // 全选
selectAllCheckbox.addEventListener('change', (e) => { elements.selectAll.addEventListener('change', (e) => {
const checkboxes = accountsTable.querySelectorAll('input[type="checkbox"]'); const checkboxes = elements.table.querySelectorAll('input[type="checkbox"][data-id]');
checkboxes.forEach(cb => { checkboxes.forEach(cb => {
cb.checked = e.target.checked; cb.checked = e.target.checked;
const id = parseInt(cb.dataset.id); const id = parseInt(cb.dataset.id);
@@ -87,124 +94,189 @@ function initEventListeners() {
}); });
// 分页 // 分页
prevPageBtn.addEventListener('click', () => { elements.prevPage.addEventListener('click', () => {
if (currentPage > 1) { if (currentPage > 1 && !isLoading) {
currentPage--; currentPage--;
loadAccounts(); loadAccounts();
} }
}); });
nextPageBtn.addEventListener('click', () => { elements.nextPage.addEventListener('click', () => {
const totalPages = Math.ceil(totalAccounts / pageSize); const totalPages = Math.ceil(totalAccounts / pageSize);
if (currentPage < totalPages) { if (currentPage < totalPages && !isLoading) {
currentPage++; currentPage++;
loadAccounts(); loadAccounts();
} }
}); });
// 导出 // 导出
exportBtn.addEventListener('click', (e) => { elements.exportBtn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
exportMenu.classList.toggle('active'); elements.exportMenu.classList.toggle('active');
}); });
document.querySelectorAll('#export-menu .dropdown-item').forEach(item => { delegate(elements.exportMenu, 'click', '.dropdown-item', (e, target) => {
item.addEventListener('click', (e) => { e.preventDefault();
e.preventDefault(); const format = target.dataset.format;
const format = e.target.dataset.format; exportAccounts(format);
exportAccounts(format); elements.exportMenu.classList.remove('active');
exportMenu.classList.remove('active');
});
}); });
// 关闭模态框 // 关闭模态框
closeModalBtn.addEventListener('click', () => { elements.closeModal.addEventListener('click', () => {
detailModal.classList.remove('active'); elements.detailModal.classList.remove('active');
}); });
detailModal.addEventListener('click', (e) => { elements.detailModal.addEventListener('click', (e) => {
if (e.target === detailModal) { if (e.target === elements.detailModal) {
detailModal.classList.remove('active'); elements.detailModal.classList.remove('active');
} }
}); });
// 点击其他地方关闭下拉菜单 // 点击其他地方关闭下拉菜单
document.addEventListener('click', () => { document.addEventListener('click', () => {
exportMenu.classList.remove('active'); elements.exportMenu.classList.remove('active');
}); });
} }
// 加载统计信息 // 加载统计信息
async function loadStats() { async function loadStats() {
try { try {
const response = await fetch(`${API_BASE}/accounts/stats/summary`); const data = await api.get('/accounts/stats/summary');
const data = await response.json();
totalAccountsEl.textContent = data.total || 0; elements.totalAccounts.textContent = format.number(data.total || 0);
activeAccountsEl.textContent = data.by_status?.active || 0; elements.activeAccounts.textContent = format.number(data.by_status?.active || 0);
expiredAccountsEl.textContent = data.by_status?.expired || 0; elements.expiredAccounts.textContent = format.number(data.by_status?.expired || 0);
failedAccountsEl.textContent = data.by_status?.failed || 0; elements.failedAccounts.textContent = format.number(data.by_status?.failed || 0);
// 添加动画效果
animateValue(elements.totalAccounts, data.total || 0);
} catch (error) { } catch (error) {
console.error('加载统计信息失败:', error); console.error('加载统计信息失败:', error);
} }
} }
// 数字动画
function animateValue(element, value) {
element.style.transition = 'transform 0.2s ease';
element.style.transform = 'scale(1.1)';
setTimeout(() => {
element.style.transform = 'scale(1)';
}, 200);
}
// 加载账号列表 // 加载账号列表
async function loadAccounts() { async function loadAccounts() {
if (isLoading) return;
isLoading = true;
// 显示加载状态
elements.table.innerHTML = `
<tr>
<td colspan="7">
<div class="empty-state">
<div class="skeleton skeleton-text" style="width: 60%;"></div>
<div class="skeleton skeleton-text" style="width: 80%;"></div>
<div class="skeleton skeleton-text" style="width: 40%;"></div>
</div>
</td>
</tr>
`;
const params = new URLSearchParams({ const params = new URLSearchParams({
page: currentPage, page: currentPage,
page_size: pageSize, page_size: pageSize,
}); });
if (filterStatus.value) { if (elements.filterStatus.value) {
params.append('status', filterStatus.value); params.append('status', elements.filterStatus.value);
} }
if (filterService.value) { if (elements.filterService.value) {
params.append('email_service', filterService.value); params.append('email_service', elements.filterService.value);
} }
if (searchInput.value.trim()) { if (elements.searchInput.value.trim()) {
params.append('search', searchInput.value.trim()); params.append('search', elements.searchInput.value.trim());
} }
try { try {
const response = await fetch(`${API_BASE}/accounts?${params}`); const data = await api.get(`/accounts?${params}`);
const data = await response.json();
totalAccounts = data.total; totalAccounts = data.total;
renderAccounts(data.accounts); renderAccounts(data.accounts);
updatePagination(); updatePagination();
} catch (error) { } catch (error) {
console.error('加载账号列表失败:', error); console.error('加载账号列表失败:', error);
accountsTable.innerHTML = '<tr><td colspan="7" style="text-align: center;">加载失败</td></tr>'; elements.table.innerHTML = `
<tr>
<td colspan="7">
<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>
`;
} finally {
isLoading = false;
} }
} }
// 渲染账号列表 // 渲染账号列表
function renderAccounts(accounts) { function renderAccounts(accounts) {
if (accounts.length === 0) { if (accounts.length === 0) {
accountsTable.innerHTML = '<tr><td colspan="7" style="text-align: center;">暂无数据</td></tr>'; elements.table.innerHTML = `
<tr>
<td colspan="7">
<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; return;
} }
accountsTable.innerHTML = accounts.map(account => ` elements.table.innerHTML = accounts.map(account => `
<tr> <tr data-id="${account.id}">
<td><input type="checkbox" data-id="${account.id}" ${selectedAccounts.has(account.id) ? 'checked' : ''}></td>
<td>${account.id}</td>
<td>${escapeHtml(account.email)}</td>
<td>${escapeHtml(account.email_service)}</td>
<td><span class="status-badge ${account.status}">${getStatusText(account.status)}</span></td>
<td>${formatDate(account.registered_at)}</td>
<td> <td>
<button class="btn btn-sm btn-secondary" onclick="viewAccount(${account.id})">查看</button> <input type="checkbox" data-id="${account.id}"
<button class="btn btn-sm btn-danger" onclick="deleteAccount(${account.id}, '${escapeHtml(account.email)}')">删除</button> ${selectedAccounts.has(account.id) ? 'checked' : ''}>
</td>
<td>${account.id}</td>
<td>
<span class="email-cell" title="${escapeHtml(account.email)}">
${escapeHtml(account.email)}
</span>
</td>
<td>${getServiceTypeText(account.email_service)}</td>
<td>
<span class="status-badge ${getStatusClass('account', account.status)}">
${getStatusText('account', account.status)}
</span>
</td>
<td>${format.date(account.registered_at)}</td>
<td>
<div class="action-buttons">
<button class="btn btn-ghost btn-sm" onclick="viewAccount(${account.id})" title="查看详情">
👁️
</button>
<button class="btn btn-ghost btn-sm" onclick="copyEmail('${escapeHtml(account.email)}')" title="复制邮箱">
📋
</button>
<button class="btn btn-ghost btn-sm" onclick="deleteAccount(${account.id}, '${escapeHtml(account.email)}')" title="删除">
🗑️
</button>
</div>
</td> </td>
</tr> </tr>
`).join(''); `).join('');
// 绑定复选框事件 // 绑定复选框事件
accountsTable.querySelectorAll('input[type="checkbox"]').forEach(cb => { elements.table.querySelectorAll('input[type="checkbox"][data-id]').forEach(cb => {
cb.addEventListener('change', (e) => { cb.addEventListener('change', (e) => {
const id = parseInt(e.target.dataset.id); const id = parseInt(e.target.dataset.id);
if (e.target.checked) { if (e.target.checked) {
@@ -219,91 +291,107 @@ function renderAccounts(accounts) {
// 更新分页 // 更新分页
function updatePagination() { function updatePagination() {
const totalPages = Math.ceil(totalAccounts / pageSize); const totalPages = Math.max(1, Math.ceil(totalAccounts / pageSize));
prevPageBtn.disabled = currentPage <= 1; elements.prevPage.disabled = currentPage <= 1;
nextPageBtn.disabled = currentPage >= totalPages; elements.nextPage.disabled = currentPage >= totalPages;
pageInfo.textContent = `${currentPage} 页 / 共 ${totalPages}`; elements.pageInfo.textContent = `${currentPage} 页 / 共 ${totalPages}`;
} }
// 更新批量操作按钮 // 更新批量操作按钮
function updateBatchButtons() { function updateBatchButtons() {
batchDeleteBtn.disabled = selectedAccounts.size === 0; const count = selectedAccounts.size;
elements.batchDeleteBtn.disabled = count === 0;
elements.batchDeleteBtn.textContent = count > 0 ? `🗑️ 删除选中 (${count})` : '🗑️ 批量删除';
} }
// 查看账号详情 // 查看账号详情
async function viewAccount(id) { async function viewAccount(id) {
try { try {
const response = await fetch(`${API_BASE}/accounts/${id}`); const account = await api.get(`/accounts/${id}`);
const account = await response.json(); const tokens = await api.get(`/accounts/${id}/tokens`);
const tokensResponse = await fetch(`${API_BASE}/accounts/${id}/tokens`); elements.modalBody.innerHTML = `
const tokens = await tokensResponse.json();
modalBody.innerHTML = `
<div class="info-grid"> <div class="info-grid">
<div class="info-item"> <div class="info-item">
<span class="label">邮箱</span> <span class="label">邮箱</span>
<span class="value">${escapeHtml(account.email)}</span> <span class="value">
${escapeHtml(account.email)}
<button class="btn btn-ghost btn-sm" onclick="copyToClipboard('${escapeHtml(account.email)}')" title="复制">
📋
</button>
</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="label">邮箱服务</span> <span class="label">邮箱服务</span>
<span class="value">${escapeHtml(account.email_service)}</span> <span class="value">${getServiceTypeText(account.email_service)}</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="label">状态</span> <span class="label">状态</span>
<span class="value">${getStatusText(account.status)}</span> <span class="value">
<span class="status-badge ${getStatusClass('account', account.status)}">
${getStatusText('account', account.status)}
</span>
</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="label">注册时间</span> <span class="label">注册时间</span>
<span class="value">${formatDate(account.registered_at)}</span> <span class="value">${format.date(account.registered_at)}</span>
</div> </div>
<div class="info-item"> <div class="info-item" style="grid-column: span 2;">
<span class="label">Account ID</span> <span class="label">Account ID</span>
<span class="value">${escapeHtml(account.account_id || '-')}</span> <span class="value" style="font-size: 0.75rem; word-break: break-all;">
${escapeHtml(account.account_id || '-')}
</span>
</div> </div>
<div class="info-item"> <div class="info-item" style="grid-column: span 2;">
<span class="label">Workspace ID</span> <span class="label">Workspace ID</span>
<span class="value">${escapeHtml(account.workspace_id || '-')}</span> <span class="value" style="font-size: 0.75rem; word-break: break-all;">
${escapeHtml(account.workspace_id || '-')}
</span>
</div> </div>
<div class="info-item"> <div class="info-item" style="grid-column: span 2;">
<span class="label">Access Token</span> <span class="label">Access Token</span>
<span class="value" style="font-size: 0.75rem; word-break: break-all;">${escapeHtml(tokens.access_token || '-')}</span> <div class="value" style="font-size: 0.7rem; word-break: break-all; font-family: var(--font-mono); background: var(--surface-hover); padding: 8px; border-radius: 4px;">
${escapeHtml(tokens.access_token || '-')}
${tokens.access_token ? `<button class="btn btn-ghost btn-sm" onclick="copyToClipboard('${escapeHtml(tokens.access_token)}')" style="margin-left: 8px;">📋</button>` : ''}
</div>
</div> </div>
<div class="info-item"> <div class="info-item" style="grid-column: span 2;">
<span class="label">Refresh Token</span> <span class="label">Refresh Token</span>
<span class="value" style="font-size: 0.75rem; word-break: break-all;">${escapeHtml(tokens.refresh_token || '-')}</span> <div class="value" style="font-size: 0.7rem; word-break: break-all; font-family: var(--font-mono); background: var(--surface-hover); padding: 8px; border-radius: 4px;">
${escapeHtml(tokens.refresh_token || '-')}
${tokens.refresh_token ? `<button class="btn btn-ghost btn-sm" onclick="copyToClipboard('${escapeHtml(tokens.refresh_token)}')" style="margin-left: 8px;">📋</button>` : ''}
</div>
</div> </div>
</div> </div>
`; `;
detailModal.classList.add('active'); elements.detailModal.classList.add('active');
} catch (error) { } catch (error) {
alert('加载账号详情失败: ' + error.message); toast.error('加载账号详情失败: ' + error.message);
} }
} }
// 复制邮箱
function copyEmail(email) {
copyToClipboard(email);
}
// 删除账号 // 删除账号
async function deleteAccount(id, email) { async function deleteAccount(id, email) {
if (!confirm(`确定要删除账号 ${email} 吗?`)) { const confirmed = await confirm(`确定要删除账号 ${email} 吗?此操作不可恢复。`);
return; if (!confirmed) return;
}
try { try {
const response = await fetch(`${API_BASE}/accounts/${id}`, { await api.delete(`/accounts/${id}`);
method: 'DELETE', toast.success('账号已删除');
}); selectedAccounts.delete(id);
loadStats();
if (response.ok) { loadAccounts();
loadStats();
loadAccounts();
} else {
const data = await response.json();
alert('删除失败: ' + (data.detail || '未知错误'));
}
} catch (error) { } catch (error) {
alert('删除失败: ' + error.message); toast.error('删除失败: ' + error.message);
} }
} }
@@ -311,33 +399,20 @@ async function deleteAccount(id, email) {
async function handleBatchDelete() { async function handleBatchDelete() {
if (selectedAccounts.size === 0) return; if (selectedAccounts.size === 0) return;
if (!confirm(`确定要删除选中的 ${selectedAccounts.size} 个账号吗?`)) { const confirmed = await confirm(`确定要删除选中的 ${selectedAccounts.size} 个账号吗?此操作不可恢复。`);
return; if (!confirmed) return;
}
try { try {
const response = await fetch(`${API_BASE}/accounts/batch-delete`, { const result = await api.post('/accounts/batch-delete', {
method: 'POST', ids: Array.from(selectedAccounts)
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
ids: Array.from(selectedAccounts),
}),
}); });
const data = await response.json(); toast.success(`成功删除 ${result.deleted_count} 个账号`);
selectedAccounts.clear();
if (response.ok) { loadStats();
alert(`成功删除 ${data.deleted_count} 个账号`); loadAccounts();
selectedAccounts.clear();
loadStats();
loadAccounts();
} else {
alert('删除失败: ' + (data.detail || '未知错误'));
}
} catch (error) { } catch (error) {
alert('删除失败: ' + error.message); toast.error('删除失败: ' + error.message);
} }
} }
@@ -345,37 +420,22 @@ async function handleBatchDelete() {
function exportAccounts(format) { function exportAccounts(format) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (filterStatus.value) { if (elements.filterStatus.value) {
params.append('status', filterStatus.value); params.append('status', elements.filterStatus.value);
} }
if (filterService.value) { if (elements.filterService.value) {
params.append('email_service', filterService.value); params.append('email_service', elements.filterService.value);
} }
window.location.href = `${API_BASE}/accounts/export/${format}?${params}`; window.location.href = `/api/accounts/export/${format}?${params}`;
toast.info('正在导出...');
} }
// 工具函数 // HTML 转义
function escapeHtml(text) { function escapeHtml(text) {
if (!text) return ''; if (!text) return '';
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = text; div.textContent = text;
return div.innerHTML; return div.innerHTML;
} }
function getStatusText(status) {
const statusMap = {
'active': '活跃',
'expired': '过期',
'banned': '封禁',
'failed': '失败',
};
return statusMap[status] || status;
}
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleString('zh-CN');
}
+365 -181
View File
@@ -1,160 +1,303 @@
/** /**
* 注册页面 JavaScript * 注册页面 JavaScript
* 使用 utils.js 中的工具库
*/ */
// API 基础路径
const API_BASE = '/api';
// 状态 // 状态
let currentTask = null; let currentTask = null;
let currentBatch = null; let currentBatch = null;
let logPollingInterval = null; let logPollingInterval = null;
let batchPollingInterval = null; let batchPollingInterval = null;
let isBatchMode = false; let isBatchMode = false;
let availableServices = {
tempmail: { available: true, services: [] },
outlook: { available: false, services: [] },
custom_domain: { available: false, services: [] }
};
// DOM 元素 // DOM 元素
const registrationForm = document.getElementById('registration-form'); const elements = {
const emailServiceSelect = document.getElementById('email-service'); form: document.getElementById('registration-form'),
const proxyInput = document.getElementById('proxy'); emailService: document.getElementById('email-service'),
const regModeSelect = document.getElementById('reg-mode'); proxy: document.getElementById('proxy'),
const batchCountGroup = document.getElementById('batch-count-group'); regMode: document.getElementById('reg-mode'),
const batchCountInput = document.getElementById('batch-count'); batchCountGroup: document.getElementById('batch-count-group'),
const batchOptions = document.getElementById('batch-options'); batchCount: document.getElementById('batch-count'),
const intervalMinInput = document.getElementById('interval-min'); batchOptions: document.getElementById('batch-options'),
const intervalMaxInput = document.getElementById('interval-max'); intervalMin: document.getElementById('interval-min'),
const startBtn = document.getElementById('start-btn'); intervalMax: document.getElementById('interval-max'),
const cancelBtn = document.getElementById('cancel-btn'); startBtn: document.getElementById('start-btn'),
const taskStatusCard = document.getElementById('task-status-card'); cancelBtn: document.getElementById('cancel-btn'),
const batchStatusCard = document.getElementById('batch-status-card'); taskStatusCard: document.getElementById('task-status-card'),
const consoleLog = document.getElementById('console-log'); batchStatusCard: document.getElementById('batch-status-card'),
const clearLogBtn = document.getElementById('clear-log-btn'); consoleLog: document.getElementById('console-log'),
clearLogBtn: document.getElementById('clear-log-btn'),
// 任务状态
taskId: document.getElementById('task-id'),
taskEmail: document.getElementById('task-email'),
taskStatus: document.getElementById('task-status'),
taskService: document.getElementById('task-service'),
taskStatusBadge: document.getElementById('task-status-badge'),
// 批量状态
batchProgress: document.getElementById('batch-progress'),
progressBar: document.getElementById('progress-bar'),
batchSuccess: document.getElementById('batch-success'),
batchFailed: document.getElementById('batch-failed'),
batchRemaining: document.getElementById('batch-remaining')
};
// 初始化 // 初始化
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initEventListeners(); initEventListeners();
loadSavedProxy();
loadAvailableServices();
}); });
// 事件监听 // 事件监听
function initEventListeners() { function initEventListeners() {
// 注册表单提交 // 注册表单提交
registrationForm.addEventListener('submit', handleStartRegistration); elements.form.addEventListener('submit', handleStartRegistration);
// 注册模式切换 // 注册模式切换
regModeSelect.addEventListener('change', handleModeChange); elements.regMode.addEventListener('change', handleModeChange);
// 邮箱服务切换
elements.emailService.addEventListener('change', handleServiceChange);
// 取消按钮 // 取消按钮
cancelBtn.addEventListener('click', handleCancelTask); elements.cancelBtn.addEventListener('click', handleCancelTask);
// 清空日志 // 清空日志
clearLogBtn.addEventListener('click', () => { elements.clearLogBtn.addEventListener('click', () => {
consoleLog.innerHTML = '<div class="log-line info">[*] 日志已清空</div>'; elements.consoleLog.innerHTML = '<div class="log-line info">[系统] 日志已清空</div>';
}); });
} }
// 加载保存的代理设置
async function loadSavedProxy() {
try {
const settings = await api.get('/settings');
if (settings.proxy?.host) {
elements.proxy.value = `${settings.proxy.type}://${settings.proxy.host}:${settings.proxy.port}`;
}
} catch (error) {
// 忽略错误
}
}
// 加载可用的邮箱服务
async function loadAvailableServices() {
try {
const data = await api.get('/registration/available-services');
availableServices = data;
// 更新邮箱服务选择框
updateEmailServiceOptions();
addLog('info', '[系统] 邮箱服务列表已加载');
} catch (error) {
console.error('加载邮箱服务列表失败:', error);
addLog('warning', '[警告] 加载邮箱服务列表失败');
}
}
// 更新邮箱服务选择框
function updateEmailServiceOptions() {
const select = elements.emailService;
select.innerHTML = '';
// Tempmail
if (availableServices.tempmail.available) {
const optgroup = document.createElement('optgroup');
optgroup.label = '🌐 临时邮箱';
availableServices.tempmail.services.forEach(service => {
const option = document.createElement('option');
option.value = `tempmail:${service.id || 'default'}`;
option.textContent = service.name;
option.dataset.type = 'tempmail';
optgroup.appendChild(option);
});
select.appendChild(optgroup);
}
// Outlook
if (availableServices.outlook.available) {
const optgroup = document.createElement('optgroup');
optgroup.label = `📧 Outlook (${availableServices.outlook.count} 个账户)`;
availableServices.outlook.services.forEach(service => {
const option = document.createElement('option');
option.value = `outlook:${service.id}`;
option.textContent = service.name + (service.has_oauth ? ' (OAuth)' : '');
option.dataset.type = 'outlook';
option.dataset.serviceId = service.id;
optgroup.appendChild(option);
});
select.appendChild(optgroup);
} else {
const optgroup = document.createElement('optgroup');
optgroup.label = '📧 Outlook (未配置)';
const option = document.createElement('option');
option.value = '';
option.textContent = '请先在设置中导入账户';
option.disabled = true;
optgroup.appendChild(option);
select.appendChild(optgroup);
}
// 自定义域名
if (availableServices.custom_domain.available) {
const optgroup = document.createElement('optgroup');
optgroup.label = `🔗 自定义域名 (${availableServices.custom_domain.count} 个服务)`;
availableServices.custom_domain.services.forEach(service => {
const option = document.createElement('option');
option.value = `custom_domain:${service.id || 'default'}`;
option.textContent = service.name;
option.dataset.type = 'custom_domain';
if (service.id) {
option.dataset.serviceId = service.id;
}
optgroup.appendChild(option);
});
select.appendChild(optgroup);
} else {
const optgroup = document.createElement('optgroup');
optgroup.label = '🔗 自定义域名 (未配置)';
const option = document.createElement('option');
option.value = '';
option.textContent = '请先在设置中添加服务';
option.disabled = true;
optgroup.appendChild(option);
select.appendChild(optgroup);
}
}
// 处理邮箱服务切换
function handleServiceChange(e) {
const value = e.target.value;
if (!value) return;
const [type, id] = value.split(':');
const selectedOption = e.target.options[e.target.selectedIndex];
// 显示服务信息
if (type === 'outlook') {
const service = availableServices.outlook.services.find(s => s.id == id);
if (service) {
addLog('info', `[系统] 已选择 Outlook 账户: ${service.name}`);
}
} else if (type === 'custom_domain') {
const service = availableServices.custom_domain.services.find(s => s.id == id);
if (service) {
addLog('info', `[系统] 已选择自定义域名服务: ${service.name}`);
}
}
}
// 模式切换 // 模式切换
function handleModeChange(e) { function handleModeChange(e) {
const mode = e.target.value; const mode = e.target.value;
isBatchMode = mode === 'batch'; isBatchMode = mode === 'batch';
batchCountGroup.style.display = isBatchMode ? 'block' : 'none'; elements.batchCountGroup.style.display = isBatchMode ? 'block' : 'none';
batchOptions.style.display = isBatchMode ? 'block' : 'none'; elements.batchOptions.style.display = isBatchMode ? 'block' : 'none';
} }
// 开始注册 // 开始注册
async function handleStartRegistration(e) { async function handleStartRegistration(e) {
e.preventDefault(); e.preventDefault();
const emailService = emailServiceSelect.value; const selectedValue = elements.emailService.value;
const proxy = proxyInput.value.trim() || null; if (!selectedValue) {
toast.error('请选择一个邮箱服务');
return;
}
const [emailServiceType, serviceId] = selectedValue.split(':');
const proxy = elements.proxy.value.trim() || null;
// 禁用开始按钮 // 禁用开始按钮
startBtn.disabled = true; elements.startBtn.disabled = true;
cancelBtn.disabled = false; elements.cancelBtn.disabled = false;
// 清空日志 // 清空日志
consoleLog.innerHTML = ''; elements.consoleLog.innerHTML = '';
// 构建请求数据
const requestData = {
email_service_type: emailServiceType,
proxy: proxy
};
// 如果选择了数据库中的服务,传递 service_id
if (serviceId && serviceId !== 'default') {
requestData.email_service_id = parseInt(serviceId);
}
if (isBatchMode) { if (isBatchMode) {
await handleBatchRegistration(emailService, proxy); await handleBatchRegistration(requestData);
} else { } else {
await handleSingleRegistration(emailService, proxy); await handleSingleRegistration(requestData);
} }
} }
// 单次注册 // 单次注册
async function handleSingleRegistration(emailService, proxy) { async function handleSingleRegistration(requestData) {
addLog('info', '[*] 正在启动注册任务...'); addLog('info', '[系统] 正在启动注册任务...');
try { try {
const response = await fetch(`${API_BASE}/registration/start`, { const data = await api.post('/registration/start', requestData);
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email_service_type: emailService,
proxy: proxy,
}),
});
const data = await response.json(); currentTask = data;
addLog('info', `[系统] 任务已创建: ${data.task_uuid}`);
showTaskStatus(data);
updateTaskStatus('running');
if (response.ok) { // 开始轮询日志
currentTask = data; startLogPolling(data.task_uuid);
addLog('info', `[*] 任务已创建: ${data.task_uuid}`);
showTaskStatus(data);
// 开始轮询日志
startLogPolling(data.task_uuid);
} else {
addLog('error', `[Error] 启动失败: ${data.detail || '未知错误'}`);
resetButtons();
}
} catch (error) { } catch (error) {
addLog('error', `[Error] 网络错误: ${error.message}`); addLog('error', `[错误] 启动失败: ${error.message}`);
toast.error(error.message);
resetButtons(); resetButtons();
} }
} }
// 批量注册 // 批量注册
async function handleBatchRegistration(emailService, proxy) { async function handleBatchRegistration(requestData) {
const count = parseInt(batchCountInput.value) || 5; const count = parseInt(elements.batchCount.value) || 5;
const intervalMin = parseInt(intervalMinInput.value) || 5; const intervalMin = parseInt(elements.intervalMin.value) || 5;
const intervalMax = parseInt(intervalMaxInput.value) || 30; const intervalMax = parseInt(elements.intervalMax.value) || 30;
addLog('info', `[*] 正在启动批量注册任务 (数量: ${count})...`); requestData.count = count;
requestData.interval_min = intervalMin;
requestData.interval_max = intervalMax;
addLog('info', `[系统] 正在启动批量注册任务 (数量: ${count})...`);
try { try {
const response = await fetch(`${API_BASE}/registration/batch`, { const data = await api.post('/registration/batch', requestData);
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
count: count,
email_service_type: emailService,
proxy: proxy,
interval_min: intervalMin,
interval_max: intervalMax,
}),
});
const data = await response.json(); currentBatch = data;
addLog('info', `[系统] 批量任务已创建: ${data.batch_id}`);
addLog('info', `[系统] 共 ${data.count} 个任务已加入队列`);
showBatchStatus(data);
if (response.ok) { // 开始轮询批量状态
currentBatch = data; startBatchPolling(data.batch_id);
addLog('info', `[*] 批量任务已创建: ${data.batch_id}`);
addLog('info', `[*] 共 ${data.count} 个任务已加入队列`);
showBatchStatus(data);
// 开始轮询批量状态
startBatchPolling(data.batch_id);
} else {
addLog('error', `[Error] 启动失败: ${data.detail || '未知错误'}`);
resetButtons();
}
} catch (error) { } catch (error) {
addLog('error', `[Error] 网络错误: ${error.message}`); addLog('error', `[错误] 启动失败: ${error.message}`);
toast.error(error.message);
resetButtons(); resetButtons();
} }
} }
@@ -163,68 +306,70 @@ async function handleBatchRegistration(emailService, proxy) {
async function handleCancelTask() { async function handleCancelTask() {
if (isBatchMode && currentBatch) { if (isBatchMode && currentBatch) {
try { try {
const response = await fetch(`${API_BASE}/registration/batch/${currentBatch.batch_id}/cancel`, { await api.post(`/registration/batch/${currentBatch.batch_id}/cancel`);
method: 'POST', addLog('warning', '[警告] 批量任务取消请求已提交');
}); toast.info('任务取消请求已提交');
stopBatchPolling();
if (response.ok) { resetButtons();
addLog('warning', '[!] 批量任务取消请求已提交');
stopBatchPolling();
resetButtons();
}
} catch (error) { } catch (error) {
addLog('error', `[Error] 取消失败: ${error.message}`); addLog('error', `[错误] 取消失败: ${error.message}`);
toast.error(error.message);
} }
} else if (currentTask) { } else if (currentTask) {
try { try {
const response = await fetch(`${API_BASE}/registration/tasks/${currentTask.task_uuid}/cancel`, { await api.post(`/registration/tasks/${currentTask.task_uuid}/cancel`);
method: 'POST', addLog('warning', '[警告] 任务已取消');
}); toast.info('任务已取消');
stopLogPolling();
if (response.ok) { resetButtons();
addLog('warning', '[!] 任务已取消');
stopLogPolling();
resetButtons();
}
} catch (error) { } catch (error) {
addLog('error', `[Error] 取消失败: ${error.message}`); addLog('error', `[错误] 取消失败: ${error.message}`);
toast.error(error.message);
} }
} }
} }
// 开始轮询日志 // 开始轮询日志
function startLogPolling(taskUuid) { function startLogPolling(taskUuid) {
let lastLogLine = ''; let lastLogIndex = 0;
logPollingInterval = setInterval(async () => { logPollingInterval = setInterval(async () => {
try { try {
const response = await fetch(`${API_BASE}/registration/tasks/${taskUuid}/logs`); const data = await api.get(`/registration/tasks/${taskUuid}/logs`);
const data = await response.json();
if (response.ok) { // 更新任务状态
// 更新任务状态 updateTaskStatus(data.status);
updateTaskStatus(data.status);
// 添加新日志 // 更新邮箱信息
const logs = data.logs || []; if (data.email) {
logs.forEach(log => { elements.taskEmail.textContent = data.email;
if (log !== lastLogLine) { }
const logType = getLogType(log); if (data.email_service) {
addLog(logType, log); elements.taskService.textContent = getServiceTypeText(data.email_service);
lastLogLine = log; }
}
});
// 检查任务是否完成 // 添加新日志
if (['completed', 'failed', 'cancelled'].includes(data.status)) { const logs = data.logs || [];
stopLogPolling(); for (let i = lastLogIndex; i < logs.length; i++) {
resetButtons(); const log = logs[i];
const logType = getLogType(log);
addLog(logType, log);
}
lastLogIndex = logs.length;
if (data.status === 'completed') { // 检查任务是否完成
addLog('success', '[*] 注册成功!'); if (['completed', 'failed', 'cancelled'].includes(data.status)) {
} else if (data.status === 'failed') { stopLogPolling();
addLog('error', '[Error] 注册失败'); resetButtons();
}
if (data.status === 'completed') {
addLog('success', '[成功] 注册成功!');
toast.success('注册成功!');
} else if (data.status === 'failed') {
addLog('error', '[错误] 注册失败');
toast.error('注册失败');
} else if (data.status === 'cancelled') {
addLog('warning', '[警告] 任务已取消');
} }
} }
} catch (error) { } catch (error) {
@@ -245,18 +390,19 @@ function stopLogPolling() {
function startBatchPolling(batchId) { function startBatchPolling(batchId) {
batchPollingInterval = setInterval(async () => { batchPollingInterval = setInterval(async () => {
try { try {
const response = await fetch(`${API_BASE}/registration/batch/${batchId}`); const data = await api.get(`/registration/batch/${batchId}`);
const data = await response.json(); updateBatchProgress(data);
if (response.ok) { // 检查是否完成
updateBatchProgress(data); if (data.finished) {
stopBatchPolling();
resetButtons();
// 检查是否完成 addLog('info', `[完成] 批量任务完成!成功: ${data.success}, 失败: ${data.failed}`);
if (data.finished) { if (data.success > 0) {
stopBatchPolling(); toast.success(`批量注册完成,成功 ${data.success}`);
resetButtons(); } else {
toast.warning('批量注册完成,但没有成功注册任何账号');
addLog('info', `[*] 批量任务完成!成功: ${data.success}, 失败: ${data.failed}`);
} }
} }
} catch (error) { } catch (error) {
@@ -275,54 +421,67 @@ function stopBatchPolling() {
// 显示任务状态 // 显示任务状态
function showTaskStatus(task) { function showTaskStatus(task) {
taskStatusCard.style.display = 'block'; elements.taskStatusCard.style.display = 'block';
batchStatusCard.style.display = 'none'; elements.batchStatusCard.style.display = 'none';
document.getElementById('task-id').textContent = task.task_uuid; elements.taskId.textContent = task.task_uuid;
updateTaskStatus(task.status); elements.taskEmail.textContent = '-';
elements.taskService.textContent = '-';
} }
// 更新任务状态 // 更新任务状态
function updateTaskStatus(status) { function updateTaskStatus(status) {
const statusBadge = document.getElementById('task-status-badge'); const statusInfo = {
const statusText = document.getElementById('task-status'); pending: { text: '等待中', class: 'pending' },
running: { text: '运行中', class: 'running' },
const statusMap = { completed: { text: '已完成', class: 'completed' },
'pending': { text: '等待中', class: '' }, failed: { text: '失败', class: 'failed' },
'running': { text: '运行中', class: 'running' }, cancelled: { text: '已取消', class: 'disabled' }
'completed': { text: '已完成', class: 'completed' },
'failed': { text: '失败', class: 'failed' },
'cancelled': { text: '已取消', class: '' },
}; };
const info = statusMap[status] || { text: status, class: '' }; const info = statusInfo[status] || { text: status, class: '' };
statusBadge.textContent = info.text; elements.taskStatusBadge.textContent = info.text;
statusBadge.className = 'status-badge ' + info.class; elements.taskStatusBadge.className = `status-badge ${info.class}`;
statusText.textContent = info.text; elements.taskStatus.textContent = info.text;
} }
// 显示批量状态 // 显示批量状态
function showBatchStatus(batch) { function showBatchStatus(batch) {
batchStatusCard.style.display = 'block'; elements.batchStatusCard.style.display = 'block';
taskStatusCard.style.display = 'none'; elements.taskStatusCard.style.display = 'none';
document.getElementById('batch-progress').textContent = `0/${batch.count}`; elements.batchProgress.textContent = `0/${batch.count}`;
document.getElementById('progress-bar').style.width = '0%'; elements.progressBar.style.width = '0%';
document.getElementById('batch-success').textContent = '0'; elements.batchSuccess.textContent = '0';
document.getElementById('batch-failed').textContent = '0'; elements.batchFailed.textContent = '0';
document.getElementById('batch-remaining').textContent = batch.count; elements.batchRemaining.textContent = batch.count;
// 重置计数器
elements.batchSuccess.dataset.last = '0';
elements.batchFailed.dataset.last = '0';
} }
// 更新批量进度 // 更新批量进度
function updateBatchProgress(data) { function updateBatchProgress(data) {
const progress = data.completed / data.total * 100; const progress = (data.completed / data.total * 100).toFixed(0);
document.getElementById('batch-progress').textContent = data.progress; elements.batchProgress.textContent = data.progress || `${data.completed}/${data.total}`;
document.getElementById('progress-bar').style.width = `${progress}%`; elements.progressBar.style.width = `${progress}%`;
document.getElementById('batch-success').textContent = data.success; elements.batchSuccess.textContent = data.success;
document.getElementById('batch-failed').textContent = data.failed; elements.batchFailed.textContent = data.failed;
document.getElementById('batch-remaining').textContent = data.total - data.completed; elements.batchRemaining.textContent = data.total - data.completed;
// 记录日志 // 记录日志(避免重复)
if (data.completed > 0) { if (data.completed > 0) {
addLog('info', `[*] 进度: ${data.progress}, 成功: ${data.success}, 失败: ${data.failed}`); const lastSuccess = parseInt(elements.batchSuccess.dataset.last || '0');
const lastFailed = parseInt(elements.batchFailed.dataset.last || '0');
if (data.success > lastSuccess) {
addLog('success', `[成功] 第 ${data.success} 个账号注册成功`);
}
if (data.failed > lastFailed) {
addLog('error', `[失败] 第 ${data.failed} 个账号注册失败`);
}
elements.batchSuccess.dataset.last = data.success;
elements.batchFailed.dataset.last = data.failed;
} }
} }
@@ -330,22 +489,39 @@ function updateBatchProgress(data) {
function addLog(type, message) { function addLog(type, message) {
const line = document.createElement('div'); const line = document.createElement('div');
line.className = `log-line ${type}`; line.className = `log-line ${type}`;
line.textContent = message;
consoleLog.appendChild(line); // 添加时间戳
const timestamp = new Date().toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
line.innerHTML = `<span class="timestamp">[${timestamp}]</span>${escapeHtml(message)}`;
elements.consoleLog.appendChild(line);
// 自动滚动到底部 // 自动滚动到底部
consoleLog.scrollTop = consoleLog.scrollHeight; elements.consoleLog.scrollTop = elements.consoleLog.scrollHeight;
// 限制日志行数
const lines = elements.consoleLog.querySelectorAll('.log-line');
if (lines.length > 500) {
lines[0].remove();
}
} }
// 获取日志类型 // 获取日志类型
function getLogType(log) { function getLogType(log) {
if (log.includes('[Error]') || log.includes('失败') || log.includes('错误')) { if (typeof log !== 'string') return 'info';
const lowerLog = log.toLowerCase();
if (lowerLog.includes('error') || lowerLog.includes('失败') || lowerLog.includes('错误')) {
return 'error'; return 'error';
} }
if (log.includes('[!]') || log.includes('警告')) { if (lowerLog.includes('warning') || lowerLog.includes('警告')) {
return 'warning'; return 'warning';
} }
if (log.includes('成功') || log.includes('完成')) { if (lowerLog.includes('success') || lowerLog.includes('成功') || lowerLog.includes('完成')) {
return 'success'; return 'success';
} }
return 'info'; return 'info';
@@ -353,8 +529,16 @@ function getLogType(log) {
// 重置按钮状态 // 重置按钮状态
function resetButtons() { function resetButtons() {
startBtn.disabled = false; elements.startBtn.disabled = false;
cancelBtn.disabled = true; elements.cancelBtn.disabled = true;
currentTask = null; currentTask = null;
currentBatch = null; currentBatch = null;
} }
// HTML 转义
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
+275 -243
View File
@@ -1,41 +1,38 @@
/** /**
* 设置页面 JavaScript * 设置页面 JavaScript
* 使用 utils.js 中的工具库
*/ */
// API 基础路径
const API_BASE = '/api';
// DOM 元素 // DOM 元素
const tabBtns = document.querySelectorAll('.tab-btn'); const elements = {
const tabContents = document.querySelectorAll('.tab-content'); tabs: document.querySelectorAll('.tab-btn'),
const proxyForm = document.getElementById('proxy-form'); tabContents: document.querySelectorAll('.tab-content'),
const registrationForm = document.getElementById('registration-form'); proxyForm: document.getElementById('proxy-form'),
const testProxyBtn = document.getElementById('test-proxy-btn'); registrationForm: document.getElementById('registration-settings-form'),
const backupBtn = document.getElementById('backup-btn'); testProxyBtn: document.getElementById('test-proxy-btn'),
const cleanupBtn = document.getElementById('cleanup-btn'); backupBtn: document.getElementById('backup-btn'),
const addEmailServiceBtn = document.getElementById('add-email-service-btn'); cleanupBtn: document.getElementById('cleanup-btn'),
const addServiceModal = document.getElementById('add-service-modal'); addEmailServiceBtn: document.getElementById('add-email-service-btn'),
const addServiceForm = document.getElementById('add-service-form'); addServiceModal: document.getElementById('add-service-modal'),
const closeServiceModalBtn = document.getElementById('close-service-modal'); addServiceForm: document.getElementById('add-service-form'),
const cancelAddServiceBtn = document.getElementById('cancel-add-service'); closeServiceModal: document.getElementById('close-service-modal'),
const serviceTypeSelect = document.getElementById('service-type'); cancelAddService: document.getElementById('cancel-add-service'),
const serviceConfigFields = document.getElementById('service-config-fields'); serviceType: document.getElementById('service-type'),
const emailServicesTable = document.getElementById('email-services-table'); serviceConfigFields: document.getElementById('service-config-fields'),
emailServicesTable: document.getElementById('email-services-table'),
// Outlook 批量导入相关 // Outlook 导入
const toggleImportBtn = document.getElementById('toggle-import-btn'); toggleImportBtn: document.getElementById('toggle-import-btn'),
const outlookImportBody = document.getElementById('outlook-import-body'); outlookImportBody: document.getElementById('outlook-import-body'),
const outlookImportBtn = document.getElementById('outlook-import-btn'); outlookImportBtn: document.getElementById('outlook-import-btn'),
const clearImportBtn = document.getElementById('clear-import-btn'); clearImportBtn: document.getElementById('clear-import-btn'),
const outlookImportData = document.getElementById('outlook-import-data'); outlookImportData: document.getElementById('outlook-import-data'),
const importResult = document.getElementById('import-result'); importResult: document.getElementById('import-result'),
// 批量操作
// 批量操作 selectAllServices: document.getElementById('select-all-services')
const batchDeleteBtn = document.getElementById('batch-delete-btn'); };
const selectAllCheckbox = document.getElementById('select-all-services');
// 选中的服务 ID // 选中的服务 ID
let selectedServiceIds = []; let selectedServiceIds = new Set();
// 初始化 // 初始化
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
@@ -48,12 +45,12 @@ document.addEventListener('DOMContentLoaded', () => {
// 初始化标签页 // 初始化标签页
function initTabs() { function initTabs() {
tabBtns.forEach(btn => { elements.tabs.forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const tab = btn.dataset.tab; const tab = btn.dataset.tab;
tabBtns.forEach(b => b.classList.remove('active')); elements.tabs.forEach(b => b.classList.remove('active'));
tabContents.forEach(c => c.classList.remove('active')); elements.tabContents.forEach(c => c.classList.remove('active'));
btn.classList.add('active'); btn.classList.add('active');
document.getElementById(`${tab}-tab`).classList.add('active'); document.getElementById(`${tab}-tab`).classList.add('active');
@@ -64,90 +61,84 @@ function initTabs() {
// 事件监听 // 事件监听
function initEventListeners() { function initEventListeners() {
// 代理表单 // 代理表单
proxyForm.addEventListener('submit', handleSaveProxy); elements.proxyForm.addEventListener('submit', handleSaveProxy);
// 测试代理 // 测试代理
testProxyBtn.addEventListener('click', handleTestProxy); elements.testProxyBtn.addEventListener('click', handleTestProxy);
// 注册配置表单 // 注册配置表单
registrationForm.addEventListener('submit', handleSaveRegistration); elements.registrationForm.addEventListener('submit', handleSaveRegistration);
// 备份数据库 // 备份数据库
backupBtn.addEventListener('click', handleBackup); elements.backupBtn.addEventListener('click', handleBackup);
// 清理数据 // 清理数据
cleanupBtn.addEventListener('click', handleCleanup); elements.cleanupBtn.addEventListener('click', handleCleanup);
// 添加邮箱服务 // 添加邮箱服务
addEmailServiceBtn.addEventListener('click', () => { elements.addEmailServiceBtn.addEventListener('click', () => {
addServiceModal.classList.add('active'); elements.addServiceModal.classList.add('active');
loadServiceConfigFields(serviceTypeSelect.value); loadServiceConfigFields(elements.serviceType.value);
}); });
closeServiceModalBtn.addEventListener('click', () => { elements.closeServiceModal.addEventListener('click', () => {
addServiceModal.classList.remove('active'); elements.addServiceModal.classList.remove('active');
}); });
cancelAddServiceBtn.addEventListener('click', () => { elements.cancelAddService.addEventListener('click', () => {
addServiceModal.classList.remove('active'); elements.addServiceModal.classList.remove('active');
}); });
addServiceModal.addEventListener('click', (e) => { elements.addServiceModal.addEventListener('click', (e) => {
if (e.target === addServiceModal) { if (e.target === elements.addServiceModal) {
addServiceModal.classList.remove('active'); elements.addServiceModal.classList.remove('active');
} }
}); });
// 服务类型切换 // 服务类型切换
serviceTypeSelect.addEventListener('change', (e) => { elements.serviceType.addEventListener('change', (e) => {
loadServiceConfigFields(e.target.value); loadServiceConfigFields(e.target.value);
}); });
// 添加服务表单 // 添加服务表单
addServiceForm.addEventListener('submit', handleAddService); elements.addServiceForm.addEventListener('submit', handleAddService);
// Outlook 批量导入展开/折叠 // Outlook 批量导入展开/折叠
if (toggleImportBtn) { if (elements.toggleImportBtn) {
toggleImportBtn.addEventListener('click', () => { elements.toggleImportBtn.addEventListener('click', () => {
const isHidden = outlookImportBody.style.display === 'none'; const isHidden = elements.outlookImportBody.style.display === 'none';
outlookImportBody.style.display = isHidden ? 'block' : 'none'; elements.outlookImportBody.style.display = isHidden ? 'block' : 'none';
toggleImportBtn.textContent = isHidden ? '收起' : '展开'; elements.toggleImportBtn.textContent = isHidden ? '收起' : '展开';
}); });
} }
// Outlook 批量导入 // Outlook 批量导入
if (outlookImportBtn) { if (elements.outlookImportBtn) {
outlookImportBtn.addEventListener('click', handleOutlookBatchImport); elements.outlookImportBtn.addEventListener('click', handleOutlookBatchImport);
} }
// 清空导入数据 // 清空导入数据
if (clearImportBtn) { if (elements.clearImportBtn) {
clearImportBtn.addEventListener('click', () => { elements.clearImportBtn.addEventListener('click', () => {
outlookImportData.value = ''; elements.outlookImportData.value = '';
importResult.style.display = 'none'; elements.importResult.style.display = 'none';
}); });
} }
// 全选/取消全选 // 全选/取消全选
if (selectAllCheckbox) { if (elements.selectAllServices) {
selectAllCheckbox.addEventListener('change', (e) => { elements.selectAllServices.addEventListener('change', (e) => {
const checkboxes = document.querySelectorAll('.service-checkbox'); const checkboxes = document.querySelectorAll('.service-checkbox');
checkboxes.forEach(cb => cb.checked = e.target.checked); checkboxes.forEach(cb => cb.checked = e.target.checked);
updateSelectedServices(); updateSelectedServices();
}); });
} }
// 批量删除
if (batchDeleteBtn) {
batchDeleteBtn.addEventListener('click', handleBatchDelete);
}
} }
// 加载设置 // 加载设置
async function loadSettings() { async function loadSettings() {
try { try {
const response = await fetch(`${API_BASE}/settings`); const data = await api.get('/settings');
const data = await response.json();
// 代理设置 // 代理设置
document.getElementById('proxy-enabled').checked = data.proxy?.enabled || false; document.getElementById('proxy-enabled').checked = data.proxy?.enabled || false;
@@ -165,61 +156,88 @@ async function loadSettings() {
} catch (error) { } catch (error) {
console.error('加载设置失败:', error); console.error('加载设置失败:', error);
toast.error('加载设置失败');
} }
} }
// 加载邮箱服务 // 加载邮箱服务
async function loadEmailServices() { async function loadEmailServices() {
try { try {
const response = await fetch(`${API_BASE}/email-services`); const data = await api.get('/email-services');
const data = await response.json();
renderEmailServices(data.services); renderEmailServices(data.services);
} catch (error) { } catch (error) {
console.error('加载邮箱服务失败:', error); console.error('加载邮箱服务失败:', error);
elements.emailServicesTable.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 renderEmailServices(services) { function renderEmailServices(services) {
if (services.length === 0) { if (services.length === 0) {
emailServicesTable.innerHTML = '<tr><td colspan="7" style="text-align: center;">暂无配置</td></tr>'; elements.emailServicesTable.innerHTML = `
batchDeleteBtn.style.display = 'none'; <tr>
<td colspan="7">
<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; return;
} }
emailServicesTable.innerHTML = services.map(service => ` elements.emailServicesTable.innerHTML = services.map(service => `
<tr data-service-id="${service.id}"> <tr data-service-id="${service.id}">
<td><input type="checkbox" class="service-checkbox" data-id="${service.id}" onchange="updateSelectedServices()"></td> <td>
<input type="checkbox" class="service-checkbox" data-id="${service.id}"
onchange="updateSelectedServices()">
</td>
<td>${escapeHtml(service.name)}</td> <td>${escapeHtml(service.name)}</td>
<td>${getServiceTypeText(service.service_type)}</td> <td>${getServiceTypeText(service.service_type)}</td>
<td><span class="status-badge ${service.enabled ? 'completed' : ''}">${service.enabled ? '已启用' : '已禁用'}</span></td>
<td>${service.priority}</td>
<td>${formatDate(service.last_used)}</td>
<td> <td>
<button class="btn btn-sm btn-secondary" onclick="testService(${service.id})">测试</button> <span class="status-badge ${service.enabled ? 'active' : 'disabled'}">
<button class="btn btn-sm ${service.enabled ? 'btn-warning' : 'btn-primary'}" onclick="toggleService(${service.id}, ${!service.enabled})"> ${service.enabled ? '已启用' : '已禁用'}
${service.enabled ? '禁用' : '启用'} </span>
</button> </td>
<button class="btn btn-sm btn-danger" onclick="deleteService(${service.id})">删除</button> <td>${service.priority}</td>
<td>${format.date(service.last_used)}</td>
<td>
<div class="action-buttons">
<button class="btn btn-ghost btn-sm" onclick="testService(${service.id})" title="测试">
🔌
</button>
<button class="btn btn-ghost btn-sm" onclick="toggleService(${service.id}, ${!service.enabled})" title="${service.enabled ? '禁用' : '启用'}">
${service.enabled ? '🔒' : '🔓'}
</button>
<button class="btn btn-ghost btn-sm" onclick="deleteService(${service.id})" title="删除">
🗑️
</button>
</div>
</td> </td>
</tr> </tr>
`).join(''); `).join('');
// 更新批量删除按钮状态
updateSelectedServices();
} }
// 加载数据库信息 // 加载数据库信息
async function loadDatabaseInfo() { async function loadDatabaseInfo() {
try { try {
const response = await fetch(`${API_BASE}/settings/database`); const data = await api.get('/settings/database');
const data = await response.json();
document.getElementById('db-size').textContent = `${data.database_size_mb} MB`; document.getElementById('db-size').textContent = `${data.database_size_mb} MB`;
document.getElementById('db-accounts').textContent = data.accounts_count; document.getElementById('db-accounts').textContent = format.number(data.accounts_count);
document.getElementById('db-services').textContent = data.email_services_count; document.getElementById('db-services').textContent = format.number(data.email_services_count);
document.getElementById('db-tasks').textContent = data.tasks_count; document.getElementById('db-tasks').textContent = format.number(data.tasks_count);
} catch (error) { } catch (error) {
console.error('加载数据库信息失败:', error); console.error('加载数据库信息失败:', error);
@@ -240,38 +258,25 @@ async function handleSaveProxy(e) {
}; };
try { try {
const response = await fetch(`${API_BASE}/settings/proxy`, { await api.post('/settings/proxy', data);
method: 'POST', toast.success('代理设置已保存');
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (response.ok) {
alert('代理设置已保存');
} else {
const result = await response.json();
alert('保存失败: ' + (result.detail || '未知错误'));
}
} catch (error) { } catch (error) {
alert('保存失败: ' + error.message); toast.error('保存失败: ' + error.message);
} }
} }
// 测试代理 // 测试代理
async function handleTestProxy() { async function handleTestProxy() {
testProxyBtn.disabled = true; elements.testProxyBtn.disabled = true;
testProxyBtn.textContent = '测试中...'; elements.testProxyBtn.innerHTML = '<span class="loading-spinner"></span> 测试中...';
try { try {
// 这里应该调用一个测试代理的 API // TODO: 实现代理测试 API
// 暂时模拟 await new Promise(resolve => setTimeout(resolve, 1500));
await new Promise(resolve => setTimeout(resolve, 1000)); toast.info('代理测试功能待实现');
alert('代理测试功能待实现');
} finally { } finally {
testProxyBtn.disabled = false; elements.testProxyBtn.disabled = false;
testProxyBtn.textContent = '测试连接'; elements.testProxyBtn.textContent = '🔌 测试连接';
} }
} }
@@ -288,96 +293,68 @@ async function handleSaveRegistration(e) {
}; };
try { try {
const response = await fetch(`${API_BASE}/settings/registration`, { await api.post('/settings/registration', data);
method: 'POST', toast.success('注册配置已保存');
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (response.ok) {
alert('注册配置已保存');
} else {
const result = await response.json();
alert('保存失败: ' + (result.detail || '未知错误'));
}
} catch (error) { } catch (error) {
alert('保存失败: ' + error.message); toast.error('保存失败: ' + error.message);
} }
} }
// 备份数据库 // 备份数据库
async function handleBackup() { async function handleBackup() {
backupBtn.disabled = true; elements.backupBtn.disabled = true;
backupBtn.textContent = '备份中...'; elements.backupBtn.innerHTML = '<span class="loading-spinner"></span> 备份中...';
try { try {
const response = await fetch(`${API_BASE}/settings/database/backup`, { const data = await api.post('/settings/database/backup');
method: 'POST', toast.success(`备份成功: ${data.backup_path}`);
});
const data = await response.json();
if (response.ok) {
alert(`备份成功: ${data.backup_path}`);
} else {
alert('备份失败: ' + (data.detail || '未知错误'));
}
} catch (error) { } catch (error) {
alert('备份失败: ' + error.message); toast.error('备份失败: ' + error.message);
} finally { } finally {
backupBtn.disabled = false; elements.backupBtn.disabled = false;
backupBtn.textContent = '备份数据库'; elements.backupBtn.textContent = '💾 备份数据库';
} }
} }
// 清理数据 // 清理数据
async function handleCleanup() { async function handleCleanup() {
if (!confirm('确定要清理过期数据吗?此操作不可恢复。')) { const confirmed = await confirm('确定要清理过期数据吗?此操作不可恢复。');
return; if (!confirmed) return;
}
cleanupBtn.disabled = true; elements.cleanupBtn.disabled = true;
cleanupBtn.textContent = '清理中...'; elements.cleanupBtn.innerHTML = '<span class="loading-spinner"></span> 清理中...';
try { try {
const response = await fetch(`${API_BASE}/settings/database/cleanup?days=30`, { const data = await api.post('/settings/database/cleanup?days=30');
method: 'POST', toast.success(data.message);
}); loadDatabaseInfo();
const data = await response.json();
if (response.ok) {
alert(data.message);
loadDatabaseInfo();
} else {
alert('清理失败: ' + (data.detail || '未知错误'));
}
} catch (error) { } catch (error) {
alert('清理失败: ' + error.message); toast.error('清理失败: ' + error.message);
} finally { } finally {
cleanupBtn.disabled = false; elements.cleanupBtn.disabled = false;
cleanupBtn.textContent = '清理过期数据'; elements.cleanupBtn.textContent = '🧹 清理过期数据';
} }
} }
// 加载服务配置字段 // 加载服务配置字段
async function loadServiceConfigFields(serviceType) { async function loadServiceConfigFields(serviceType) {
try { try {
const response = await fetch(`${API_BASE}/email-services/types`); const data = await api.get('/email-services/types');
const data = await response.json();
const typeInfo = data.types.find(t => t.value === serviceType); const typeInfo = data.types.find(t => t.value === serviceType);
if (!typeInfo) return;
serviceConfigFields.innerHTML = typeInfo.config_fields.map(field => ` if (!typeInfo) {
elements.serviceConfigFields.innerHTML = '';
return;
}
elements.serviceConfigFields.innerHTML = typeInfo.config_fields.map(field => `
<div class="form-group"> <div class="form-group">
<label for="config-${field.name}">${field.label}</label> <label for="config-${field.name}">${field.label}</label>
<input type="${field.name.includes('password') || field.name.includes('token') ? 'password' : 'text'}" <input type="${field.name.includes('password') || field.name.includes('token') ? 'password' : 'text'}"
id="config-${field.name}" id="config-${field.name}"
name="${field.name}" name="${field.name}"
value="${field.default || ''}" value="${field.default || ''}"
placeholder="${field.label}"
${field.required ? 'required' : ''}> ${field.required ? 'required' : ''}>
</div> </div>
`).join(''); `).join('');
@@ -391,10 +368,10 @@ async function loadServiceConfigFields(serviceType) {
async function handleAddService(e) { async function handleAddService(e) {
e.preventDefault(); e.preventDefault();
const formData = new FormData(addServiceForm); const formData = new FormData(elements.addServiceForm);
const config = {}; const config = {};
serviceConfigFields.querySelectorAll('input').forEach(input => { elements.serviceConfigFields.querySelectorAll('input').forEach(input => {
config[input.name] = input.value; config[input.name] = input.value;
}); });
@@ -407,44 +384,27 @@ async function handleAddService(e) {
}; };
try { try {
const response = await fetch(`${API_BASE}/email-services`, { await api.post('/email-services', data);
method: 'POST', toast.success('邮箱服务已添加');
headers: { elements.addServiceModal.classList.remove('active');
'Content-Type': 'application/json', elements.addServiceForm.reset();
}, loadEmailServices();
body: JSON.stringify(data),
});
if (response.ok) {
addServiceModal.classList.remove('active');
addServiceForm.reset();
loadEmailServices();
alert('邮箱服务已添加');
} else {
const result = await response.json();
alert('添加失败: ' + (result.detail || '未知错误'));
}
} catch (error) { } catch (error) {
alert('添加失败: ' + error.message); toast.error('添加失败: ' + error.message);
} }
} }
// 测试服务 // 测试服务
async function testService(id) { async function testService(id) {
try { try {
const response = await fetch(`${API_BASE}/email-services/${id}/test`, { const data = await api.post(`/email-services/${id}/test`);
method: 'POST',
});
const data = await response.json();
if (data.success) { if (data.success) {
alert('服务连接正常'); toast.success('服务连接正常');
} else { } else {
alert('服务连接失败: ' + data.message); toast.warning('服务连接失败: ' + data.message);
} }
} catch (error) { } catch (error) {
alert('测试失败: ' + error.message); toast.error('测试失败: ' + error.message);
} }
} }
@@ -452,62 +412,134 @@ async function testService(id) {
async function toggleService(id, enabled) { async function toggleService(id, enabled) {
try { try {
const endpoint = enabled ? 'enable' : 'disable'; const endpoint = enabled ? 'enable' : 'disable';
const response = await fetch(`${API_BASE}/email-services/${id}/${endpoint}`, { await api.post(`/email-services/${id}/${endpoint}`);
method: 'POST', toast.success(enabled ? '服务已启用' : '服务已禁用');
}); loadEmailServices();
if (response.ok) {
loadEmailServices();
} else {
const data = await response.json();
alert('操作失败: ' + (data.detail || '未知错误'));
}
} catch (error) { } catch (error) {
alert('操作失败: ' + error.message); toast.error('操作失败: ' + error.message);
} }
} }
// 删除服务 // 删除服务
async function deleteService(id) { async function deleteService(id) {
if (!confirm('确定要删除此邮箱服务配置吗?')) { const confirmed = await confirm('确定要删除此邮箱服务配置吗?');
return; if (!confirmed) return;
}
try { try {
const response = await fetch(`${API_BASE}/email-services/${id}`, { await api.delete(`/email-services/${id}`);
method: 'DELETE', toast.success('服务已删除');
}); loadEmailServices();
if (response.ok) {
loadEmailServices();
} else {
const data = await response.json();
alert('删除失败: ' + (data.detail || '未知错误'));
}
} catch (error) { } catch (error) {
alert('删除失败: ' + error.message); toast.error('删除失败: ' + error.message);
} }
} }
// 工具函数 // 更新选中的服务
function updateSelectedServices() {
selectedServiceIds.clear();
document.querySelectorAll('.service-checkbox:checked').forEach(cb => {
selectedServiceIds.add(parseInt(cb.dataset.id));
});
}
// Outlook 批量导入
async function handleOutlookBatchImport() {
const data = elements.outlookImportData.value.trim();
if (!data) {
toast.warning('请输入要导入的数据');
return;
}
const enabled = document.getElementById('outlook-import-enabled').checked;
const priority = parseInt(document.getElementById('outlook-import-priority').value) || 0;
// 解析数据
const lines = data.split('\n').filter(line => line.trim() && !line.trim().startsWith('#'));
const accounts = [];
const errors = [];
lines.forEach((line, index) => {
const parts = line.split('----').map(p => p.trim());
if (parts.length < 2) {
errors.push(`${index + 1} 行格式错误`);
return;
}
const account = {
email: parts[0],
password: parts[1],
client_id: parts[2] || null,
refresh_token: parts[3] || null,
enabled: enabled,
priority: priority
};
if (!account.email.includes('@')) {
errors.push(`${index + 1} 行邮箱格式错误: ${account.email}`);
return;
}
accounts.push(account);
});
if (errors.length > 0) {
elements.importResult.style.display = 'block';
elements.importResult.innerHTML = `
<div class="import-errors">${errors.map(e => `<div>${e}</div>`).join('')}</div>
`;
return;
}
elements.outlookImportBtn.disabled = true;
elements.outlookImportBtn.innerHTML = '<span class="loading-spinner"></span> 导入中...';
let successCount = 0;
let failCount = 0;
try {
for (const account of accounts) {
try {
await api.post('/email-services', {
service_type: 'outlook',
name: account.email,
config: {
email: account.email,
password: account.password,
client_id: account.client_id,
refresh_token: account.refresh_token
},
enabled: account.enabled,
priority: account.priority
});
successCount++;
} catch {
failCount++;
}
}
elements.importResult.style.display = 'block';
elements.importResult.innerHTML = `
<div class="import-stats">
<span>✅ 成功: ${successCount}</span>
<span>❌ 失败: ${failCount}</span>
</div>
`;
toast.success(`导入完成,成功 ${successCount}`);
loadEmailServices();
} catch (error) {
toast.error('导入失败: ' + error.message);
} finally {
elements.outlookImportBtn.disabled = false;
elements.outlookImportBtn.textContent = '📥 开始导入';
}
}
// HTML 转义
function escapeHtml(text) { function escapeHtml(text) {
if (!text) return ''; if (!text) return '';
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = text; div.textContent = text;
return div.innerHTML; return div.innerHTML;
} }
function getServiceTypeText(type) {
const typeMap = {
'tempmail': 'Tempmail.lol',
'outlook': 'Outlook',
'custom_domain': '自定义域名',
};
return typeMap[type] || type;
}
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleString('zh-CN');
}
+49 -26
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>账号管理 - OpenAI 注册系统</title> <title>账号管理 - OpenAI 注册系统</title>
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📋</text></svg>">
</head> </head>
<body> <body>
<div class="container"> <div class="container">
@@ -18,6 +19,9 @@
<a href="/accounts" class="nav-link active">账号管理</a> <a href="/accounts" class="nav-link active">账号管理</a>
<a href="/settings" class="nav-link">设置</a> <a href="/settings" class="nav-link">设置</a>
</div> </div>
<button class="theme-toggle" onclick="theme.toggle()" title="切换主题">
🌙
</button>
</nav> </nav>
<!-- 主内容区 --> <!-- 主内容区 -->
@@ -66,15 +70,19 @@
<option value="custom_domain">自定义域名</option> <option value="custom_domain">自定义域名</option>
</select> </select>
<input type="text" id="search-input" class="form-input" placeholder="搜索邮箱..."> <input type="text" id="search-input" class="form-input" placeholder="🔍 搜索邮箱..." style="min-width: 200px;">
</div> </div>
<div class="toolbar-right"> <div class="toolbar-right">
<button class="btn btn-secondary" id="refresh-btn">刷新</button> <button class="btn btn-ghost" id="refresh-btn" title="刷新">
<button class="btn btn-danger" id="batch-delete-btn" disabled>批量删除</button> 🔄 刷新
</button>
<button class="btn btn-danger" id="batch-delete-btn" disabled>
🗑️ 批量删除
</button>
<div class="dropdown"> <div class="dropdown">
<button class="btn btn-primary dropdown-toggle" id="export-btn"> <button class="btn btn-primary" id="export-btn">
导出 📥 导出
</button> </button>
<div class="dropdown-menu" id="export-menu"> <div class="dropdown-menu" id="export-menu">
<a href="#" class="dropdown-item" data-format="json">导出 JSON</a> <a href="#" class="dropdown-item" data-format="json">导出 JSON</a>
@@ -87,29 +95,43 @@
<!-- 账号列表 --> <!-- 账号列表 -->
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body" style="padding: 0;">
<table class="data-table"> <div class="table-container">
<thead> <table class="data-table">
<tr> <thead>
<th><input type="checkbox" id="select-all"></th> <tr>
<th>ID</th> <th style="width: 40px;"><input type="checkbox" id="select-all"></th>
<th>邮箱</th> <th style="width: 60px;">ID</th>
<th>邮箱服务</th> <th>邮箱</th>
<th>状态</th> <th style="width: 120px;">邮箱服务</th>
<th>注册时间</th> <th style="width: 100px;">状态</th>
<th>操作</th> <th style="width: 160px;">注册时间</th>
</tr> <th style="width: 140px;">操作</th>
</thead> </tr>
<tbody id="accounts-table"> </thead>
<!-- 动态加载 --> <tbody id="accounts-table">
</tbody> <tr>
</table> <td colspan="7">
<div class="empty-state">
<div class="skeleton skeleton-text" style="width: 60%;"></div>
<div class="skeleton skeleton-text" style="width: 80%;"></div>
<div class="skeleton skeleton-text" style="width: 40%;"></div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 --> <!-- 分页 -->
<div class="pagination" id="pagination"> <div class="pagination" id="pagination">
<button class="btn btn-sm" id="prev-page" disabled>上一页</button> <button class="btn btn-secondary btn-sm" id="prev-page" disabled>
<span id="page-info">第 1 页</span> ← 上一页
<button class="btn btn-sm" id="next-page">下一页</button> </button>
<span id="page-info">第 1 页 / 共 1 页</span>
<button class="btn btn-secondary btn-sm" id="next-page">
下一页 →
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -120,7 +142,7 @@
<div class="modal" id="detail-modal"> <div class="modal" id="detail-modal">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h3>账号详情</h3> <h3>📋 账号详情</h3>
<button class="modal-close" id="close-modal">&times;</button> <button class="modal-close" id="close-modal">&times;</button>
</div> </div>
<div class="modal-body" id="modal-body"> <div class="modal-body" id="modal-body">
@@ -129,6 +151,7 @@
</div> </div>
</div> </div>
<script src="/static/js/utils.js"></script>
<script src="/static/js/accounts.js"></script> <script src="/static/js/accounts.js"></script>
</body> </body>
</html> </html>
+30 -21
View File
@@ -3,8 +3,9 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpenAI/Codex CLI 自动注册系统</title> <title>注册控制台 - OpenAI 注册系统</title>
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🚀</text></svg>">
</head> </head>
<body> <body>
<div class="container"> <div class="container">
@@ -18,6 +19,9 @@
<a href="/accounts" class="nav-link">账号管理</a> <a href="/accounts" class="nav-link">账号管理</a>
<a href="/settings" class="nav-link">设置</a> <a href="/settings" class="nav-link">设置</a>
</div> </div>
<button class="theme-toggle" onclick="theme.toggle()" title="切换主题">
🌙
</button>
</nav> </nav>
<!-- 主内容区 --> <!-- 主内容区 -->
@@ -30,7 +34,7 @@
<!-- 注册表单 --> <!-- 注册表单 -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3>新建注册任务</h3> <h3>📝 新建注册任务</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<form id="registration-form"> <form id="registration-form">
@@ -80,8 +84,8 @@
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary" id="start-btn"> <button type="submit" class="btn btn-primary btn-lg" id="start-btn">
开始注册 🚀 开始注册
</button> </button>
<button type="button" class="btn btn-secondary" id="cancel-btn" disabled> <button type="button" class="btn btn-secondary" id="cancel-btn" disabled>
取消任务 取消任务
@@ -94,7 +98,7 @@
<!-- 批量任务状态 --> <!-- 批量任务状态 -->
<div class="card" id="batch-status-card" style="display: none;"> <div class="card" id="batch-status-card" style="display: none;">
<div class="card-header"> <div class="card-header">
<h3>批量任务进度</h3> <h3>📊 批量任务进度</h3>
<span id="batch-progress" class="status-badge">0/0</span> <span id="batch-progress" class="status-badge">0/0</span>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -102,9 +106,9 @@
<div id="progress-bar" class="progress-bar" style="width: 0%"></div> <div id="progress-bar" class="progress-bar" style="width: 0%"></div>
</div> </div>
<div class="batch-stats"> <div class="batch-stats">
<span>成功: <strong id="batch-success">0</strong></span> <span>成功: <strong id="batch-success">0</strong></span>
<span>失败: <strong id="batch-failed">0</strong></span> <span>失败: <strong id="batch-failed">0</strong></span>
<span>剩余: <strong id="batch-remaining">0</strong></span> <span>剩余: <strong id="batch-remaining">0</strong></span>
</div> </div>
</div> </div>
</div> </div>
@@ -112,23 +116,27 @@
<!-- 任务状态 --> <!-- 任务状态 -->
<div class="card" id="task-status-card" style="display: none;"> <div class="card" id="task-status-card" style="display: none;">
<div class="card-header"> <div class="card-header">
<h3>任务状态</h3> <h3>任务状态</h3>
<span id="task-status-badge" class="status-badge">等待中</span> <span id="task-status-badge" class="status-badge pending">等待中</span>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="task-info"> <div class="info-grid">
<div class="info-row"> <div class="info-item">
<span class="label">任务 ID:</span> <span class="label">任务 ID</span>
<span id="task-id" class="value">-</span> <span id="task-id" class="value">-</span>
</div> </div>
<div class="info-row"> <div class="info-item">
<span class="label">邮箱:</span> <span class="label">邮箱</span>
<span id="task-email" class="value">-</span> <span id="task-email" class="value">-</span>
</div> </div>
<div class="info-row"> <div class="info-item">
<span class="label">状态:</span> <span class="label">状态</span>
<span id="task-status" class="value">-</span> <span id="task-status" class="value">-</span>
</div> </div>
<div class="info-item">
<span class="label">邮箱服务</span>
<span id="task-service" class="value">-</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -136,18 +144,19 @@
<!-- 控制台日志 --> <!-- 控制台日志 -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3>控制台日志</h3> <h3>💻 控制台日志</h3>
<button class="btn btn-sm" id="clear-log-btn">清空</button> <button class="btn btn-ghost btn-sm" id="clear-log-btn">清空</button>
</div> </div>
<div class="card-body"> <div class="card-body" style="padding: 0;">
<div id="console-log" class="console-log"> <div id="console-log" class="console-log">
<div class="log-line info">[*] 系统就绪,等待开始注册...</div> <div class="log-line info">[系统] 准备就绪,等待开始注册...</div>
</div> </div>
</div> </div>
</div> </div>
</main> </main>
</div> </div>
<script src="/static/js/utils.js"></script>
<script src="/static/js/app.js"></script> <script src="/static/js/app.js"></script>
</body> </body>
</html> </html>
+56 -48
View File
@@ -3,8 +3,9 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>设置 - OpenAI 注册系统</title> <title>系统设置 - OpenAI 注册系统</title>
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚙️</text></svg>">
</head> </head>
<body> <body>
<div class="container"> <div class="container">
@@ -18,6 +19,9 @@
<a href="/accounts" class="nav-link">账号管理</a> <a href="/accounts" class="nav-link">账号管理</a>
<a href="/settings" class="nav-link active">设置</a> <a href="/settings" class="nav-link active">设置</a>
</div> </div>
<button class="theme-toggle" onclick="theme.toggle()" title="切换主题">
🌙
</button>
</nav> </nav>
<!-- 主内容区 --> <!-- 主内容区 -->
@@ -29,10 +33,10 @@
<!-- 设置标签页 --> <!-- 设置标签页 -->
<div class="tabs"> <div class="tabs">
<button class="tab-btn active" data-tab="proxy">代理设置</button> <button class="tab-btn active" data-tab="proxy">🌐 代理设置</button>
<button class="tab-btn" data-tab="email">邮箱服务</button> <button class="tab-btn" data-tab="email">📧 邮箱服务</button>
<button class="tab-btn" data-tab="registration">注册配置</button> <button class="tab-btn" data-tab="registration">⚙️ 注册配置</button>
<button class="tab-btn" data-tab="database">数据库</button> <button class="tab-btn" data-tab="database">💾 数据库</button>
</div> </div>
<!-- 代理设置 --> <!-- 代理设置 -->
@@ -73,18 +77,18 @@
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="proxy-username">用户名 (可选)</label> <label for="proxy-username">用户名 (可选)</label>
<input type="text" id="proxy-username" name="username"> <input type="text" id="proxy-username" name="username" autocomplete="off">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="proxy-password">密码 (可选)</label> <label for="proxy-password">密码 (可选)</label>
<input type="password" id="proxy-password" name="password"> <input type="password" id="proxy-password" name="password" autocomplete="new-password">
</div> </div>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary">保存设置</button> <button type="submit" class="btn btn-primary">💾 保存设置</button>
<button type="button" class="btn btn-secondary" id="test-proxy-btn">测试连接</button> <button type="button" class="btn btn-secondary" id="test-proxy-btn">🔌 测试连接</button>
</div> </div>
</form> </form>
</div> </div>
@@ -96,8 +100,8 @@
<!-- Outlook 批量导入 --> <!-- Outlook 批量导入 -->
<div class="card" id="outlook-import-card"> <div class="card" id="outlook-import-card">
<div class="card-header"> <div class="card-header">
<h3>Outlook 批量导入</h3> <h3>📥 Outlook 批量导入</h3>
<button class="btn btn-sm btn-secondary" id="toggle-import-btn">展开</button> <button class="btn btn-ghost btn-sm" id="toggle-import-btn">展开</button>
</div> </div>
<div class="card-body" id="outlook-import-body" style="display: none;"> <div class="card-body" id="outlook-import-body" style="display: none;">
<div class="import-info"> <div class="import-info">
@@ -125,41 +129,44 @@
</div> </div>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="button" class="btn btn-primary" id="outlook-import-btn">开始导入</button> <button type="button" class="btn btn-primary" id="outlook-import-btn">📥 开始导入</button>
<button type="button" class="btn btn-secondary" id="clear-import-btn">清空</button> <button type="button" class="btn btn-secondary" id="clear-import-btn">清空</button>
</div> </div>
<div id="import-result" style="display: none; margin-top: 16px;"> <div id="import-result" style="display: none; margin-top: var(--spacing-md);"></div>
<div class="import-stats"></div>
<div class="import-errors" style="margin-top: 8px;"></div>
</div>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3>邮箱服务配置</h3> <h3>邮箱服务配置</h3>
<div class="toolbar-left"> <button class="btn btn-primary btn-sm" id="add-email-service-btn"> 添加服务</button>
<button class="btn btn-sm btn-primary" id="add-email-service-btn">添加服务</button>
<button class="btn btn-sm btn-danger" id="batch-delete-btn" style="display: none;">批量删除</button>
</div>
</div> </div>
<div class="card-body"> <div class="card-body" style="padding: 0;">
<table class="data-table"> <div class="table-container">
<thead> <table class="data-table">
<tr> <thead>
<th><input type="checkbox" id="select-all-services"></th> <tr>
<th>名称</th> <th style="width: 40px;"><input type="checkbox" id="select-all-services"></th>
<th>类型</th> <th>名称</th>
<th>状态</th> <th style="width: 120px;">类型</th>
<th>优先级</th> <th style="width: 100px;">状态</th>
<th>最后使用</th> <th style="width: 80px;">优先级</th>
<th>操作</th> <th style="width: 160px;">最后使用</th>
</tr> <th style="width: 180px;">操作</th>
</thead> </tr>
<tbody id="email-services-table"> </thead>
<!-- 动态加载 --> <tbody id="email-services-table">
</tbody> <tr>
</table> <td colspan="7">
<div class="empty-state">
<div class="skeleton skeleton-text"></div>
<div class="skeleton skeleton-text" style="width: 80%;"></div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -171,7 +178,7 @@
<h3>注册配置</h3> <h3>注册配置</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<form id="registration-form"> <form id="registration-settings-form">
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="max-retries">最大重试次数</label> <label for="max-retries">最大重试次数</label>
@@ -202,7 +209,7 @@
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary">保存设置</button> <button type="submit" class="btn btn-primary">💾 保存设置</button>
</div> </div>
</form> </form>
</div> </div>
@@ -216,28 +223,28 @@
<h3>数据库信息</h3> <h3>数据库信息</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="info-grid"> <div class="info-grid" style="margin-bottom: var(--spacing-lg);">
<div class="info-item"> <div class="info-item">
<span class="label">数据库大小:</span> <span class="label">数据库大小</span>
<span id="db-size" class="value">-</span> <span id="db-size" class="value">-</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="label">账号数量:</span> <span class="label">账号数量</span>
<span id="db-accounts" class="value">-</span> <span id="db-accounts" class="value">-</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="label">邮箱服务数量:</span> <span class="label">邮箱服务数量</span>
<span id="db-services" class="value">-</span> <span id="db-services" class="value">-</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="label">任务记录数量:</span> <span class="label">任务记录数量</span>
<span id="db-tasks" class="value">-</span> <span id="db-tasks" class="value">-</span>
</div> </div>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button class="btn btn-secondary" id="backup-btn">备份数据库</button> <button class="btn btn-secondary" id="backup-btn">💾 备份数据库</button>
<button class="btn btn-warning" id="cleanup-btn">清理过期数据</button> <button class="btn btn-warning" id="cleanup-btn">🧹 清理过期数据</button>
</div> </div>
</div> </div>
</div> </div>
@@ -249,7 +256,7 @@
<div class="modal" id="add-service-modal"> <div class="modal" id="add-service-modal">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h3>添加邮箱服务</h3> <h3> 添加邮箱服务</h3>
<button class="modal-close" id="close-service-modal">&times;</button> <button class="modal-close" id="close-service-modal">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@@ -265,7 +272,7 @@
<div class="form-group"> <div class="form-group">
<label for="service-name">服务名称</label> <label for="service-name">服务名称</label>
<input type="text" id="service-name" name="name" required> <input type="text" id="service-name" name="name" required placeholder="例如:我的 Outlook 账号">
</div> </div>
<div id="service-config-fields"> <div id="service-config-fields">
@@ -281,6 +288,7 @@
</div> </div>
</div> </div>
<script src="/static/js/utils.js"></script>
<script src="/static/js/settings.js"></script> <script src="/static/js/settings.js"></script>
</body> </body>
</html> </html>