feat(cpa): 支持多cpa服务

This commit is contained in:
cnlimiter
2026-03-18 14:01:44 +08:00
parent 7ce403ece3
commit 931ea798cc
14 changed files with 778 additions and 51 deletions

View File

@@ -740,11 +740,86 @@ function escapeHtml(text) {
return div.innerHTML;
}
// ============== CPA 服务选择 ==============
// 弹出 CPA 服务选择框,返回 Promise<{cpa_service_id: number|null}|null>
// null 表示用户取消,{cpa_service_id: null} 表示使用全局配置
function selectCpaService() {
return new Promise(async (resolve) => {
const modal = document.getElementById('cpa-service-modal');
const listEl = document.getElementById('cpa-service-list');
const closeBtn = document.getElementById('close-cpa-modal');
const cancelBtn = document.getElementById('cancel-cpa-modal-btn');
const globalBtn = document.getElementById('cpa-use-global-btn');
// 加载服务列表
listEl.innerHTML = '<div style="text-align:center;color:var(--text-muted)">加载中...</div>';
modal.classList.add('active');
let services = [];
try {
services = await api.get('/cpa-services?enabled=true');
} catch (e) {
services = [];
}
if (services.length === 0) {
listEl.innerHTML = '<div style="text-align:center;color:var(--text-muted);padding:12px;">暂无已启用的 CPA 服务,将使用全局配置</div>';
} else {
listEl.innerHTML = services.map(s => `
<div class="cpa-service-item" data-id="${s.id}" style="
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
transition: background 0.15s;
display: flex;
justify-content: space-between;
align-items: center;
">
<div>
<div style="font-weight:500;">${escapeHtml(s.name)}</div>
<div style="font-size:0.8rem;color:var(--text-muted);">${escapeHtml(s.api_url)}</div>
</div>
<span class="badge" style="background:var(--success-color);color:#fff;font-size:0.7rem;padding:2px 8px;border-radius:10px;">选择</span>
</div>
`).join('');
listEl.querySelectorAll('.cpa-service-item').forEach(item => {
item.addEventListener('mouseenter', () => item.style.background = 'var(--surface-hover)');
item.addEventListener('mouseleave', () => item.style.background = '');
item.addEventListener('click', () => {
cleanup();
resolve({ cpa_service_id: parseInt(item.dataset.id) });
});
});
}
function cleanup() {
modal.classList.remove('active');
closeBtn.removeEventListener('click', onCancel);
cancelBtn.removeEventListener('click', onCancel);
globalBtn.removeEventListener('click', onGlobal);
}
function onCancel() { cleanup(); resolve(null); }
function onGlobal() { cleanup(); resolve({ cpa_service_id: null }); }
closeBtn.addEventListener('click', onCancel);
cancelBtn.addEventListener('click', onCancel);
globalBtn.addEventListener('click', onGlobal);
});
}
// 上传单个账号到CPA
async function uploadToCpa(id) {
const choice = await selectCpaService();
if (choice === null) return; // 用户取消
try {
toast.info('正在上传到CPA...');
const result = await api.post(`/accounts/${id}/upload-cpa`);
const payload = {};
if (choice.cpa_service_id != null) payload.cpa_service_id = choice.cpa_service_id;
const result = await api.post(`/accounts/${id}/upload-cpa`, payload);
if (result.success) {
toast.success('上传成功');
@@ -762,6 +837,9 @@ async function handleBatchUploadCpa() {
const count = getEffectiveCount();
if (count === 0) return;
const choice = await selectCpaService();
if (choice === null) return; // 用户取消
const confirmed = await confirm(`确定要将选中的 ${count} 个账号上传到CPA吗`);
if (!confirmed) return;
@@ -769,15 +847,13 @@ async function handleBatchUploadCpa() {
elements.batchUploadCpaBtn.textContent = '上传中...';
try {
const result = await api.post('/accounts/batch-upload-cpa', buildBatchPayload());
const payload = buildBatchPayload();
if (choice.cpa_service_id != null) payload.cpa_service_id = choice.cpa_service_id;
const result = await api.post('/accounts/batch-upload-cpa', payload);
let message = `成功: ${result.success_count}`;
if (result.failed_count > 0) {
message += `, 失败: ${result.failed_count}`;
}
if (result.skipped_count > 0) {
message += `, 跳过: ${result.skipped_count}`;
}
if (result.failed_count > 0) message += `, 失败: ${result.failed_count}`;
if (result.skipped_count > 0) message += `, 跳过: ${result.skipped_count}`;
toast.success(message);
loadAccounts();

View File

@@ -83,7 +83,9 @@ const elements = {
concurrencyHint: document.getElementById('concurrency-hint'),
intervalGroup: document.getElementById('interval-group'),
// 注册后自动操作
autoUploadCpa: document.getElementById('auto-upload-cpa')
autoUploadCpa: document.getElementById('auto-upload-cpa'),
cpaServiceSelectGroup: document.getElementById('cpa-service-select-group'),
cpaServiceSelect: document.getElementById('cpa-service-select')
};
// 初始化
@@ -97,7 +99,7 @@ document.addEventListener('DOMContentLoaded', () => {
checkCpaEnabled();
});
// 检查 CPA 是否启用,未启用则禁用复选框
// 检查 CPA 是否启用,未启用则禁用复选框;同时加载 CPA 服务列表
async function checkCpaEnabled() {
if (!elements.autoUploadCpa) return;
try {
@@ -111,6 +113,32 @@ async function checkCpaEnabled() {
} catch (e) {
elements.autoUploadCpa.disabled = true;
}
// 加载 CPA 服务列表
await loadCpaServiceOptions();
// 复选框联动显示/隐藏服务选择器
if (elements.autoUploadCpa) {
elements.autoUploadCpa.addEventListener('change', () => {
if (elements.cpaServiceSelectGroup) {
elements.cpaServiceSelectGroup.style.display =
elements.autoUploadCpa.checked ? 'block' : 'none';
}
});
}
}
async function loadCpaServiceOptions() {
if (!elements.cpaServiceSelect) return;
try {
const services = await api.get('/cpa-services?enabled=true');
// 保留「使用全局配置」选项
const defaultOpt = '<option value="">使用全局配置</option>';
const opts = services.map(s =>
`<option value="${s.id}">${s.name.replace(/</g,'&lt;')}</option>`
).join('');
elements.cpaServiceSelect.innerHTML = defaultOpt + opts;
} catch (e) {
// 加载失败静默处理,保持默认选项
}
}
// 事件监听
@@ -357,6 +385,10 @@ async function handleStartRegistration(e) {
email_service_type: emailServiceType,
auto_upload_cpa: elements.autoUploadCpa ? elements.autoUploadCpa.checked : false
};
// 带上指定 CPA 服务
if (requestData.auto_upload_cpa && elements.cpaServiceSelect && elements.cpaServiceSelect.value) {
requestData.cpa_service_id = parseInt(elements.cpaServiceSelect.value);
}
// 如果选择了数据库中的服务,传递 service_id
if (serviceId && serviceId !== 'default') {

View File

@@ -44,6 +44,15 @@ const elements = {
// CPA 设置
cpaForm: document.getElementById('cpa-form'),
testCpaBtn: document.getElementById('test-cpa-btn'),
// CPA 服务管理
addCpaServiceBtn: document.getElementById('add-cpa-service-btn'),
cpaServicesTable: document.getElementById('cpa-services-table'),
cpaServiceEditModal: document.getElementById('cpa-service-edit-modal'),
closeCpaServiceModal: document.getElementById('close-cpa-service-modal'),
cancelCpaServiceBtn: document.getElementById('cancel-cpa-service-btn'),
cpaServiceForm: document.getElementById('cpa-service-form'),
cpaServiceModalTitle: document.getElementById('cpa-service-modal-title'),
testCpaServiceBtn: document.getElementById('test-cpa-service-btn'),
// Team Manager 设置
tmForm: document.getElementById('tm-form'),
testTmBtn: document.getElementById('test-tm-btn'),
@@ -65,6 +74,7 @@ document.addEventListener('DOMContentLoaded', () => {
loadEmailServices();
loadDatabaseInfo();
loadProxies();
loadCpaServices();
initEventListeners();
});
@@ -247,6 +257,28 @@ function initEventListeners() {
if (elements.testTmBtn) {
elements.testTmBtn.addEventListener('click', handleTestTm);
}
// CPA 服务管理
if (elements.addCpaServiceBtn) {
elements.addCpaServiceBtn.addEventListener('click', () => openCpaServiceModal());
}
if (elements.closeCpaServiceModal) {
elements.closeCpaServiceModal.addEventListener('click', closeCpaServiceModal);
}
if (elements.cancelCpaServiceBtn) {
elements.cancelCpaServiceBtn.addEventListener('click', closeCpaServiceModal);
}
if (elements.cpaServiceEditModal) {
elements.cpaServiceEditModal.addEventListener('click', (e) => {
if (e.target === elements.cpaServiceEditModal) closeCpaServiceModal();
});
}
if (elements.cpaServiceForm) {
elements.cpaServiceForm.addEventListener('submit', handleSaveCpaService);
}
if (elements.testCpaServiceBtn) {
elements.testCpaServiceBtn.addEventListener('click', handleTestCpaService);
}
}
// 加载设置
@@ -1182,3 +1214,172 @@ async function handleTestTm() {
elements.testTmBtn.textContent = '🔌 测试连接';
}
}
// ============== CPA 服务管理 ==============
async function loadCpaServices() {
if (!elements.cpaServicesTable) return;
try {
const services = await api.get('/cpa-services');
renderCpaServicesTable(services);
} catch (e) {
elements.cpaServicesTable.innerHTML = `<tr><td colspan="5" style="text-align:center;color:var(--danger-color);">${e.message}</td></tr>`;
}
}
function renderCpaServicesTable(services) {
if (!services || services.length === 0) {
elements.cpaServicesTable.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:20px;">暂无 CPA 服务,点击「添加服务」新增</td></tr>';
return;
}
elements.cpaServicesTable.innerHTML = services.map(s => `
<tr>
<td>${escapeHtml(s.name)}</td>
<td style="font-size:0.85rem;color:var(--text-muted);">${escapeHtml(s.api_url)}</td>
<td>
<span class="badge" style="background:${s.enabled ? 'var(--success-color)' : 'var(--border)'};color:${s.enabled ? '#fff' : 'var(--text-muted)'};font-size:0.75rem;padding:2px 8px;border-radius:10px;">
${s.enabled ? '启用' : '禁用'}
</span>
</td>
<td style="text-align:center;">${s.priority}</td>
<td>
<button class="btn btn-secondary btn-sm" onclick="editCpaService(${s.id})">编辑</button>
<button class="btn btn-secondary btn-sm" onclick="testCpaServiceById(${s.id})">测试</button>
<button class="btn btn-danger btn-sm" onclick="deleteCpaService(${s.id}, '${escapeHtml(s.name)}')">删除</button>
</td>
</tr>
`).join('');
}
function openCpaServiceModal(service = null) {
document.getElementById('cpa-service-id').value = service ? service.id : '';
document.getElementById('cpa-service-name').value = service ? service.name : '';
document.getElementById('cpa-service-url').value = service ? service.api_url : '';
document.getElementById('cpa-service-token').value = '';
document.getElementById('cpa-service-priority').value = service ? service.priority : 0;
document.getElementById('cpa-service-enabled').checked = service ? service.enabled : true;
elements.cpaServiceModalTitle.textContent = service ? '编辑 CPA 服务' : '添加 CPA 服务';
elements.cpaServiceEditModal.classList.add('active');
}
function closeCpaServiceModal() {
elements.cpaServiceEditModal.classList.remove('active');
}
async function editCpaService(id) {
try {
const service = await api.get(`/cpa-services/${id}`);
openCpaServiceModal(service);
} catch (e) {
toast.error('获取服务信息失败: ' + e.message);
}
}
async function handleSaveCpaService(e) {
e.preventDefault();
const id = document.getElementById('cpa-service-id').value;
const name = document.getElementById('cpa-service-name').value.trim();
const apiUrl = document.getElementById('cpa-service-url').value.trim();
const apiToken = document.getElementById('cpa-service-token').value.trim();
const priority = parseInt(document.getElementById('cpa-service-priority').value) || 0;
const enabled = document.getElementById('cpa-service-enabled').checked;
if (!name || !apiUrl) {
toast.error('名称和 API URL 不能为空');
return;
}
if (!id && !apiToken) {
toast.error('新增服务时 API Token 不能为空');
return;
}
try {
const payload = { name, api_url: apiUrl, priority, enabled };
if (apiToken) payload.api_token = apiToken;
if (id) {
await api.patch(`/cpa-services/${id}`, payload);
toast.success('服务已更新');
} else {
payload.api_token = apiToken;
await api.post('/cpa-services', payload);
toast.success('服务已添加');
}
closeCpaServiceModal();
loadCpaServices();
} catch (e) {
toast.error('保存失败: ' + e.message);
}
}
async function deleteCpaService(id, name) {
const confirmed = await confirm(`确定要删除 CPA 服务「${name}」吗?`);
if (!confirmed) return;
try {
await api.delete(`/cpa-services/${id}`);
toast.success('已删除');
loadCpaServices();
} catch (e) {
toast.error('删除失败: ' + e.message);
}
}
async function testCpaServiceById(id) {
try {
const result = await api.post(`/cpa-services/${id}/test`);
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.message);
}
} catch (e) {
toast.error('测试失败: ' + e.message);
}
}
async function handleTestCpaService() {
const apiUrl = document.getElementById('cpa-service-url').value.trim();
const apiToken = document.getElementById('cpa-service-token').value.trim();
const id = document.getElementById('cpa-service-id').value;
if (!apiUrl) {
toast.error('请先填写 API URL');
return;
}
// 新增时必须有 token编辑时 token 可为空(用已保存的)
if (!id && !apiToken) {
toast.error('请先填写 API Token');
return;
}
elements.testCpaServiceBtn.disabled = true;
elements.testCpaServiceBtn.textContent = '测试中...';
try {
let result;
if (id && !apiToken) {
// 编辑时未填 token直接测试已保存的服务
result = await api.post(`/cpa-services/${id}/test`);
} else {
result = await api.post('/cpa-services/test-connection', { api_url: apiUrl, api_token: apiToken });
}
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.message);
}
} catch (e) {
toast.error('测试失败: ' + e.message);
} finally {
elements.testCpaServiceBtn.disabled = false;
elements.testCpaServiceBtn.textContent = '🔌 测试连接';
}
}
function escapeHtml(text) {
if (!text) return '';
const d = document.createElement('div');
d.textContent = text;
return d.innerHTML;
}