mirror of
https://github.com/cnlimiter/codex-register.git
synced 2026-05-06 20:02:51 +08:00
feat(accounts): 添加CPA上传功能及批量操作支持
This commit is contained in:
@@ -119,6 +119,11 @@ class Settings(BaseSettings):
|
||||
default=SecretStr("your-encryption-key-change-in-production")
|
||||
)
|
||||
|
||||
# CPA 上传配置
|
||||
cpa_enabled: bool = Field(default=False)
|
||||
cpa_api_url: str = Field(default="") # 例如: https://cpa.example.com
|
||||
cpa_api_token: SecretStr = Field(default=SecretStr(""))
|
||||
|
||||
|
||||
# 全局配置实例
|
||||
_settings: Optional[Settings] = None
|
||||
|
||||
@@ -624,3 +624,74 @@ async def disable_proxy(proxy_id: int):
|
||||
if not proxy:
|
||||
raise HTTPException(status_code=404, detail="代理不存在")
|
||||
return {"success": True, "message": "代理已禁用"}
|
||||
|
||||
|
||||
# ============== CPA 设置 ==============
|
||||
|
||||
class CPASettings(BaseModel):
|
||||
"""CPA 设置"""
|
||||
enabled: bool = False
|
||||
api_url: str = ""
|
||||
api_token: str = ""
|
||||
|
||||
|
||||
class CPATestRequest(BaseModel):
|
||||
"""CPA 测试请求"""
|
||||
api_url: str
|
||||
api_token: str
|
||||
|
||||
|
||||
@router.get("/cpa")
|
||||
async def get_cpa_settings():
|
||||
"""获取 CPA 设置"""
|
||||
settings = get_settings()
|
||||
|
||||
return {
|
||||
"enabled": settings.cpa_enabled,
|
||||
"api_url": settings.cpa_api_url,
|
||||
"has_token": bool(settings.cpa_api_token and settings.cpa_api_token.get_secret_value()),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/cpa")
|
||||
async def update_cpa_settings(request: CPASettings):
|
||||
"""更新 CPA 设置"""
|
||||
update_dict = {
|
||||
"cpa_enabled": request.enabled,
|
||||
"cpa_api_url": request.api_url,
|
||||
}
|
||||
|
||||
# 只有提供了 token 才更新
|
||||
if request.api_token:
|
||||
update_dict["cpa_api_token"] = request.api_token
|
||||
|
||||
update_settings(**update_dict)
|
||||
|
||||
return {"success": True, "message": "CPA 设置已更新"}
|
||||
|
||||
|
||||
@router.post("/cpa/test")
|
||||
async def test_cpa_connection(request: CPATestRequest):
|
||||
"""测试 CPA 连接"""
|
||||
from ...core.cpa_upload import test_cpa_connection as do_test
|
||||
|
||||
settings = get_settings()
|
||||
proxy = settings.proxy_url
|
||||
|
||||
# 如果传入 'use_saved_token',使用已保存的 token
|
||||
api_token = request.api_token
|
||||
if api_token == 'use_saved_token' or not api_token:
|
||||
if settings.cpa_api_token:
|
||||
api_token = settings.cpa_api_token.get_secret_value()
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "未配置 API Token"
|
||||
}
|
||||
|
||||
success, message = do_test(request.api_url, api_token, proxy)
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"message": message
|
||||
}
|
||||
|
||||
@@ -280,6 +280,15 @@ body {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* 工具栏卡片允许下拉菜单溢出 */
|
||||
.card.toolbar-card {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.card-body.toolbar {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
表单元素
|
||||
============================================ */
|
||||
@@ -710,6 +719,7 @@ body {
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-md);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
|
||||
@@ -23,6 +23,7 @@ const elements = {
|
||||
refreshBtn: document.getElementById('refresh-btn'),
|
||||
batchRefreshBtn: document.getElementById('batch-refresh-btn'),
|
||||
batchValidateBtn: document.getElementById('batch-validate-btn'),
|
||||
batchUploadCpaBtn: document.getElementById('batch-upload-cpa-btn'),
|
||||
batchDeleteBtn: document.getElementById('batch-delete-btn'),
|
||||
exportBtn: document.getElementById('export-btn'),
|
||||
exportMenu: document.getElementById('export-menu'),
|
||||
@@ -40,6 +41,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
loadStats();
|
||||
loadAccounts();
|
||||
initEventListeners();
|
||||
updateBatchButtons(); // 初始化按钮状态
|
||||
});
|
||||
|
||||
// 事件监听
|
||||
@@ -83,6 +85,9 @@ function initEventListeners() {
|
||||
// 批量验证Token
|
||||
elements.batchValidateBtn.addEventListener('click', handleBatchValidate);
|
||||
|
||||
// 批量上传CPA
|
||||
elements.batchUploadCpaBtn.addEventListener('click', handleBatchUploadCpa);
|
||||
|
||||
// 批量删除
|
||||
elements.batchDeleteBtn.addEventListener('click', handleBatchDelete);
|
||||
|
||||
@@ -181,7 +186,7 @@ async function loadAccounts() {
|
||||
// 显示加载状态
|
||||
elements.table.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="8">
|
||||
<td colspan="9">
|
||||
<div class="empty-state">
|
||||
<div class="skeleton skeleton-text" style="width: 60%;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 80%;"></div>
|
||||
@@ -217,7 +222,7 @@ async function loadAccounts() {
|
||||
console.error('加载账号列表失败:', error);
|
||||
elements.table.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="8">
|
||||
<td colspan="9">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">❌</div>
|
||||
<div class="empty-state-title">加载失败</div>
|
||||
@@ -236,7 +241,7 @@ function renderAccounts(accounts) {
|
||||
if (accounts.length === 0) {
|
||||
elements.table.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="8">
|
||||
<td colspan="9">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">📭</div>
|
||||
<div class="empty-state-title">暂无数据</div>
|
||||
@@ -271,12 +276,22 @@ function renderAccounts(accounts) {
|
||||
${getStatusText('account', account.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="cpa-status">
|
||||
${account.cpa_uploaded
|
||||
? `<span class="badge uploaded" title="已上传于 ${format.date(account.cpa_uploaded_at)}">✓</span>`
|
||||
: `<span class="badge pending">-</span>`}
|
||||
</div>
|
||||
</td>
|
||||
<td>${format.date(account.last_refresh) || '-'}</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-ghost btn-sm" onclick="refreshToken(${account.id})" title="刷新Token">
|
||||
🔄
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="uploadToCpa(${account.id})" title="上传到CPA">
|
||||
☁️
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="viewAccount(${account.id})" title="查看详情">
|
||||
👁️
|
||||
</button>
|
||||
@@ -335,10 +350,13 @@ function updateBatchButtons() {
|
||||
elements.batchDeleteBtn.disabled = count === 0;
|
||||
elements.batchRefreshBtn.disabled = count === 0;
|
||||
elements.batchValidateBtn.disabled = count === 0;
|
||||
elements.batchUploadCpaBtn.disabled = count === 0;
|
||||
elements.exportBtn.disabled = count === 0;
|
||||
|
||||
elements.batchDeleteBtn.textContent = count > 0 ? `🗑️ 删除 (${count})` : '🗑️ 批量删除';
|
||||
elements.batchRefreshBtn.textContent = count > 0 ? `🔄 刷新 (${count})` : '🔄 刷新Token';
|
||||
elements.batchValidateBtn.textContent = count > 0 ? `✅ 验证 (${count})` : '✅ 验证Token';
|
||||
elements.batchUploadCpaBtn.textContent = count > 0 ? `☁️ 上传 (${count})` : '☁️ 上传CPA';
|
||||
}
|
||||
|
||||
// 刷新单个账号Token
|
||||
@@ -538,19 +556,57 @@ async function handleBatchDelete() {
|
||||
}
|
||||
|
||||
// 导出账号
|
||||
function exportAccounts(format) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (elements.filterStatus.value) {
|
||||
params.append('status', elements.filterStatus.value);
|
||||
async function exportAccounts(format) {
|
||||
if (selectedAccounts.size === 0) {
|
||||
toast.warning('请先选择要导出的账号');
|
||||
return;
|
||||
}
|
||||
|
||||
if (elements.filterService.value) {
|
||||
params.append('email_service', elements.filterService.value);
|
||||
}
|
||||
toast.info(`正在导出 ${selectedAccounts.size} 个账号...`);
|
||||
|
||||
window.location.href = `/api/accounts/export/${format}?${params}`;
|
||||
toast.info('正在导出...');
|
||||
try {
|
||||
const response = await fetch('/api/accounts/export/' + format, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ids: Array.from(selectedAccounts)
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`导出失败: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
// 获取文件内容
|
||||
const blob = await response.blob();
|
||||
|
||||
// 从 Content-Disposition 获取文件名
|
||||
const disposition = response.headers.get('Content-Disposition');
|
||||
let filename = `accounts_${Date.now()}.${format === 'cpa' ? 'json' : format}`;
|
||||
if (disposition) {
|
||||
const match = disposition.match(/filename=(.+)/);
|
||||
if (match) {
|
||||
filename = match[1];
|
||||
}
|
||||
}
|
||||
|
||||
// 创建下载链接
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
a.remove();
|
||||
|
||||
toast.success('导出成功');
|
||||
} catch (error) {
|
||||
console.error('导出失败:', error);
|
||||
toast.error('导出失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// HTML 转义
|
||||
@@ -560,3 +616,52 @@ function escapeHtml(text) {
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// 上传单个账号到CPA
|
||||
async function uploadToCpa(id) {
|
||||
try {
|
||||
toast.info('正在上传到CPA...');
|
||||
const result = await api.post(`/accounts/${id}/upload-cpa`);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('上传成功');
|
||||
loadAccounts();
|
||||
} else {
|
||||
toast.error('上传失败: ' + (result.error || '未知错误'));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('上传失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 批量上传到CPA
|
||||
async function handleBatchUploadCpa() {
|
||||
if (selectedAccounts.size === 0) return;
|
||||
|
||||
const confirmed = await confirm(`确定要将选中的 ${selectedAccounts.size} 个账号上传到CPA吗?`);
|
||||
if (!confirmed) return;
|
||||
|
||||
elements.batchUploadCpaBtn.disabled = true;
|
||||
elements.batchUploadCpaBtn.textContent = '上传中...';
|
||||
|
||||
try {
|
||||
const result = await api.post('/accounts/batch-upload-cpa', {
|
||||
ids: Array.from(selectedAccounts)
|
||||
});
|
||||
|
||||
let message = `成功: ${result.success_count}`;
|
||||
if (result.failed_count > 0) {
|
||||
message += `, 失败: ${result.failed_count}`;
|
||||
}
|
||||
if (result.skipped_count > 0) {
|
||||
message += `, 跳过: ${result.skipped_count}`;
|
||||
}
|
||||
|
||||
toast.success(message);
|
||||
loadAccounts();
|
||||
} catch (error) {
|
||||
toast.error('批量上传失败: ' + error.message);
|
||||
} finally {
|
||||
updateBatchButtons();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,10 @@ const elements = {
|
||||
proxyItemForm: document.getElementById('proxy-item-form'),
|
||||
closeProxyModal: document.getElementById('close-proxy-modal'),
|
||||
cancelProxyBtn: document.getElementById('cancel-proxy-btn'),
|
||||
proxyModalTitle: document.getElementById('proxy-modal-title')
|
||||
proxyModalTitle: document.getElementById('proxy-modal-title'),
|
||||
// CPA 设置
|
||||
cpaForm: document.getElementById('cpa-form'),
|
||||
testCpaBtn: document.getElementById('test-cpa-btn')
|
||||
};
|
||||
|
||||
// 选中的服务 ID
|
||||
@@ -194,6 +197,15 @@ function initEventListeners() {
|
||||
if (elements.proxyItemForm) {
|
||||
elements.proxyItemForm.addEventListener('submit', handleSaveProxyItem);
|
||||
}
|
||||
|
||||
// CPA 设置
|
||||
if (elements.cpaForm) {
|
||||
elements.cpaForm.addEventListener('submit', handleSaveCpa);
|
||||
}
|
||||
|
||||
if (elements.testCpaBtn) {
|
||||
elements.testCpaBtn.addEventListener('click', handleTestCpa);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载设置
|
||||
@@ -215,6 +227,9 @@ async function loadSettings() {
|
||||
document.getElementById('sleep-min').value = data.registration?.sleep_min || 5;
|
||||
document.getElementById('sleep-max').value = data.registration?.sleep_max || 30;
|
||||
|
||||
// 加载 CPA 设置
|
||||
loadCpaSettings();
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载设置失败:', error);
|
||||
toast.error('加载设置失败');
|
||||
@@ -823,3 +838,84 @@ async function handleTestAllProxies() {
|
||||
elements.testAllProxiesBtn.textContent = '🔌 测试全部';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// CPA 设置管理
|
||||
// ============================================================================
|
||||
|
||||
// 加载 CPA 设置
|
||||
async function loadCpaSettings() {
|
||||
try {
|
||||
const data = await api.get('/settings/cpa');
|
||||
|
||||
document.getElementById('cpa-enabled').checked = data.enabled || false;
|
||||
document.getElementById('cpa-api-url').value = data.api_url || '';
|
||||
// 不填充 token,只显示是否有值
|
||||
document.getElementById('cpa-api-token').value = '';
|
||||
document.getElementById('cpa-api-token').placeholder = data.has_token ? '已配置,留空保持不变' : '请输入 API Token';
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载 CPA 设置失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存 CPA 设置
|
||||
async function handleSaveCpa(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const data = {
|
||||
enabled: document.getElementById('cpa-enabled').checked,
|
||||
api_url: document.getElementById('cpa-api-url').value,
|
||||
api_token: document.getElementById('cpa-api-token').value || ''
|
||||
};
|
||||
|
||||
try {
|
||||
await api.post('/settings/cpa', data);
|
||||
toast.success('CPA 设置已保存');
|
||||
loadCpaSettings();
|
||||
} catch (error) {
|
||||
toast.error('保存失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 测试 CPA 连接
|
||||
async function handleTestCpa() {
|
||||
const apiUrl = document.getElementById('cpa-api-url').value;
|
||||
const apiToken = document.getElementById('cpa-api-token').value;
|
||||
|
||||
if (!apiUrl) {
|
||||
toast.warning('请输入 API URL');
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果 token 为空,尝试使用已保存的 token 进行测试
|
||||
if (!apiToken) {
|
||||
const cpaSettings = await api.get('/settings/cpa');
|
||||
if (!cpaSettings.has_token) {
|
||||
toast.warning('请输入 API Token');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
elements.testCpaBtn.disabled = true;
|
||||
elements.testCpaBtn.innerHTML = '<span class="loading-spinner"></span> 测试中...';
|
||||
|
||||
try {
|
||||
const result = await api.post('/settings/cpa/test', {
|
||||
api_url: apiUrl,
|
||||
api_token: apiToken || 'use_saved_token'
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('测试失败: ' + error.message);
|
||||
} finally {
|
||||
elements.testCpaBtn.disabled = false;
|
||||
elements.testCpaBtn.textContent = '🔌 测试连接';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,18 @@
|
||||
.token-status .dot.healthy { background: var(--success-color); }
|
||||
.token-status .dot.warning { background: var(--warning-color); }
|
||||
.token-status .dot.expired { background: var(--danger-color); }
|
||||
.cpa-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.cpa-status .badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.cpa-status .badge.uploaded { background: var(--success-color); color: white; }
|
||||
.cpa-status .badge.pending { background: var(--border-color); color: var(--text-secondary); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -80,7 +92,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<div class="card">
|
||||
<div class="card toolbar-card">
|
||||
<div class="card-body toolbar">
|
||||
<div class="toolbar-left">
|
||||
<select id="filter-status" class="form-select">
|
||||
@@ -111,6 +123,9 @@
|
||||
<button class="btn btn-info" id="batch-validate-btn" disabled title="批量验证Token">
|
||||
✅ 验证Token
|
||||
</button>
|
||||
<button class="btn btn-success" id="batch-upload-cpa-btn" disabled title="批量上传到CPA">
|
||||
☁️ 上传CPA
|
||||
</button>
|
||||
<button class="btn btn-danger" id="batch-delete-btn" disabled>
|
||||
🗑️ 批量删除
|
||||
</button>
|
||||
@@ -121,6 +136,7 @@
|
||||
<div class="dropdown-menu" id="export-menu">
|
||||
<a href="#" class="dropdown-item" data-format="json">导出 JSON</a>
|
||||
<a href="#" class="dropdown-item" data-format="csv">导出 CSV</a>
|
||||
<a href="#" class="dropdown-item" data-format="cpa">导出 CPA 格式</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,13 +156,14 @@
|
||||
<th style="width: 100px;">密码</th>
|
||||
<th style="width: 120px;">邮箱服务</th>
|
||||
<th style="width: 80px;">状态</th>
|
||||
<th style="width: 80px;">CPA</th>
|
||||
<th style="width: 140px;">最后刷新</th>
|
||||
<th style="width: 140px;">操作</th>
|
||||
<th style="width: 150px;">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="accounts-table">
|
||||
<tr>
|
||||
<td colspan="8">
|
||||
<td colspan="9">
|
||||
<div class="empty-state">
|
||||
<div class="skeleton skeleton-text" style="width: 60%;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 80%;"></div>
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<!-- 设置标签页 -->
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" data-tab="proxy">🌐 代理设置</button>
|
||||
<button class="tab-btn" data-tab="cpa">☁️ CPA上传</button>
|
||||
<button class="tab-btn" data-tab="registration">⚙️ 注册配置</button>
|
||||
<button class="tab-btn" data-tab="database">💾 数据库</button>
|
||||
</div>
|
||||
@@ -186,6 +187,44 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CPA 上传设置 -->
|
||||
<div class="tab-content" id="cpa-tab">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>CPA 上传配置</h3>
|
||||
<span class="hint">配置 Codex Protocol API 上传功能</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="cpa-form">
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" id="cpa-enabled" name="enabled">
|
||||
启用 CPA 上传
|
||||
</label>
|
||||
<p class="hint">启用后可在账号管理页面上传账号到 CPA 管理平台</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="cpa-api-url">API URL</label>
|
||||
<input type="text" id="cpa-api-url" name="api_url" placeholder="例如: https://cpa.example.com">
|
||||
<p class="hint">CPA 管理平台的 API 地址</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="cpa-api-token">API Token</label>
|
||||
<input type="password" id="cpa-api-token" name="api_token" placeholder="留空则保持原值" autocomplete="new-password">
|
||||
<p class="hint">CPA 管理平台的认证 Token</p>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">💾 保存设置</button>
|
||||
<button type="button" class="btn btn-secondary" id="test-cpa-btn">🔌 测试连接</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 注册配置 -->
|
||||
<div class="tab-content" id="registration-tab">
|
||||
<div class="card">
|
||||
|
||||
Reference in New Issue
Block a user