feat(upload): #13 添加上传至sub2api

This commit is contained in:
cnlimiter
2026-03-18 18:23:04 +08:00
parent 6a5d9064f3
commit ffd3a81a38
8 changed files with 551 additions and 45 deletions

View File

@@ -26,6 +26,7 @@ const elements = {
batchRefreshBtn: document.getElementById('batch-refresh-btn'),
batchValidateBtn: document.getElementById('batch-validate-btn'),
batchUploadCpaBtn: document.getElementById('batch-upload-cpa-btn'),
batchUploadSub2ApiBtn: document.getElementById('batch-upload-sub2api-btn'),
batchCheckSubBtn: document.getElementById('batch-check-sub-btn'),
batchUploadTmBtn: document.getElementById('batch-upload-tm-btn'),
batchDeleteBtn: document.getElementById('batch-delete-btn'),
@@ -100,6 +101,9 @@ function initEventListeners() {
// 批量检测订阅
elements.batchCheckSubBtn.addEventListener('click', handleBatchCheckSubscription);
// 批量上传Sub2API
elements.batchUploadSub2ApiBtn.addEventListener('click', handleBatchUploadSub2Api);
// 批量上传TM
elements.batchUploadTmBtn.addEventListener('click', handleBatchUploadTm);
@@ -466,6 +470,7 @@ function updateBatchButtons() {
elements.batchRefreshBtn.disabled = count === 0;
elements.batchValidateBtn.disabled = count === 0;
elements.batchUploadCpaBtn.disabled = count === 0;
elements.batchUploadSub2ApiBtn.disabled = count === 0;
elements.batchCheckSubBtn.disabled = count === 0;
elements.batchUploadTmBtn.disabled = count === 0;
elements.exportBtn.disabled = count === 0;
@@ -474,6 +479,7 @@ function updateBatchButtons() {
elements.batchRefreshBtn.textContent = count > 0 ? `🔄 刷新 (${count})` : '🔄 刷新Token';
elements.batchValidateBtn.textContent = count > 0 ? `✅ 验证 (${count})` : '✅ 验证Token';
elements.batchUploadCpaBtn.textContent = count > 0 ? `☁️ 上传 (${count})` : '☁️ 上传CPA';
elements.batchUploadSub2ApiBtn.textContent = count > 0 ? `🔗 Sub2API (${count})` : '🔗 上传Sub2API';
elements.batchCheckSubBtn.textContent = count > 0 ? `🔍 检测 (${count})` : '🔍 检测订阅';
elements.batchUploadTmBtn.textContent = count > 0 ? `🚀 上传TM (${count})` : '🚀 上传TM';
}
@@ -908,6 +914,107 @@ async function handleBatchCheckSubscription() {
}
}
// ============== Sub2API 上传 ==============
// 弹出 Sub2API 服务选择框,返回 Promise<{service_id: number|null}|null>
// null 表示用户取消,{service_id: null} 表示自动选择
function selectSub2ApiService() {
return new Promise(async (resolve) => {
const modal = document.getElementById('sub2api-service-modal');
const listEl = document.getElementById('sub2api-service-list');
const closeBtn = document.getElementById('close-sub2api-modal');
const cancelBtn = document.getElementById('cancel-sub2api-modal-btn');
const autoBtn = document.getElementById('sub2api-use-auto-btn');
listEl.innerHTML = '<div style="text-align:center;color:var(--text-muted)">加载中...</div>';
modal.classList.add('active');
let services = [];
try {
services = await api.get('/sub2api-services?enabled=true');
} catch (e) {
services = [];
}
if (services.length === 0) {
listEl.innerHTML = '<div style="text-align:center;color:var(--text-muted);padding:12px;">暂无已启用的 Sub2API 服务,将自动选择第一个</div>';
} else {
listEl.innerHTML = services.map(s => `
<div class="sub2api-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(--primary);color:#fff;font-size:0.7rem;padding:2px 8px;border-radius:10px;">选择</span>
</div>
`).join('');
listEl.querySelectorAll('.sub2api-service-item').forEach(item => {
item.addEventListener('mouseenter', () => item.style.background = 'var(--surface-hover)');
item.addEventListener('mouseleave', () => item.style.background = '');
item.addEventListener('click', () => {
cleanup();
resolve({ service_id: parseInt(item.dataset.id) });
});
});
}
function cleanup() {
modal.classList.remove('active');
closeBtn.removeEventListener('click', onCancel);
cancelBtn.removeEventListener('click', onCancel);
autoBtn.removeEventListener('click', onAuto);
}
function onCancel() { cleanup(); resolve(null); }
function onAuto() { cleanup(); resolve({ service_id: null }); }
closeBtn.addEventListener('click', onCancel);
cancelBtn.addEventListener('click', onCancel);
autoBtn.addEventListener('click', onAuto);
});
}
// 批量上传到 Sub2API
async function handleBatchUploadSub2Api() {
const count = getEffectiveCount();
if (count === 0) return;
const choice = await selectSub2ApiService();
if (choice === null) return; // 用户取消
const confirmed = await confirm(`确定要将选中的 ${count} 个账号上传到 Sub2API 吗?`);
if (!confirmed) return;
elements.batchUploadSub2ApiBtn.disabled = true;
elements.batchUploadSub2ApiBtn.textContent = '上传中...';
try {
const payload = buildBatchPayload();
if (choice.service_id != null) payload.service_id = choice.service_id;
const result = await api.post('/accounts/batch-upload-sub2api', payload);
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();
}
}
// ============== Team Manager 上传 ==============
// 上传单账号到 Team Manager

View File

@@ -48,6 +48,15 @@ const elements = {
cpaServiceForm: document.getElementById('cpa-service-form'),
cpaServiceModalTitle: document.getElementById('cpa-service-modal-title'),
testCpaServiceBtn: document.getElementById('test-cpa-service-btn'),
// Sub2API 服务管理
addSub2ApiServiceBtn: document.getElementById('add-sub2api-service-btn'),
sub2ApiServicesTable: document.getElementById('sub2api-services-table'),
sub2ApiServiceEditModal: document.getElementById('sub2api-service-edit-modal'),
closeSub2ApiServiceModal: document.getElementById('close-sub2api-service-modal'),
cancelSub2ApiServiceBtn: document.getElementById('cancel-sub2api-service-btn'),
sub2ApiServiceForm: document.getElementById('sub2api-service-form'),
sub2ApiServiceModalTitle: document.getElementById('sub2api-service-modal-title'),
testSub2ApiServiceBtn: document.getElementById('test-sub2api-service-btn'),
// Team Manager 设置
tmForm: document.getElementById('tm-form'),
testTmBtn: document.getElementById('test-tm-btn'),
@@ -70,6 +79,7 @@ document.addEventListener('DOMContentLoaded', () => {
loadDatabaseInfo();
loadProxies();
loadCpaServices();
loadSub2ApiServices();
initEventListeners();
});
@@ -255,6 +265,28 @@ function initEventListeners() {
if (elements.testCpaServiceBtn) {
elements.testCpaServiceBtn.addEventListener('click', handleTestCpaService);
}
// Sub2API 服务管理
if (elements.addSub2ApiServiceBtn) {
elements.addSub2ApiServiceBtn.addEventListener('click', () => openSub2ApiServiceModal());
}
if (elements.closeSub2ApiServiceModal) {
elements.closeSub2ApiServiceModal.addEventListener('click', closeSub2ApiServiceModal);
}
if (elements.cancelSub2ApiServiceBtn) {
elements.cancelSub2ApiServiceBtn.addEventListener('click', closeSub2ApiServiceModal);
}
if (elements.sub2ApiServiceEditModal) {
elements.sub2ApiServiceEditModal.addEventListener('click', (e) => {
if (e.target === elements.sub2ApiServiceEditModal) closeSub2ApiServiceModal();
});
}
if (elements.sub2ApiServiceForm) {
elements.sub2ApiServiceForm.addEventListener('submit', handleSaveSub2ApiService);
}
if (elements.testSub2ApiServiceBtn) {
elements.testSub2ApiServiceBtn.addEventListener('click', handleTestSub2ApiService);
}
}
// 加载设置
@@ -1230,6 +1262,154 @@ async function handleTestCpaService() {
}
}
// ============================================================================
// Sub2API 服务管理
// ============================================================================
let _sub2apiEditingId = null;
async function loadSub2ApiServices() {
try {
const services = await api.get('/sub2api-services');
renderSub2ApiServices(services);
} catch (e) {
if (elements.sub2ApiServicesTable) {
elements.sub2ApiServicesTable.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:20px;">加载失败</td></tr>';
}
}
}
function renderSub2ApiServices(services) {
if (!elements.sub2ApiServicesTable) return;
if (!services || services.length === 0) {
elements.sub2ApiServicesTable.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:20px;">暂无服务,点击"添加服务"按钮添加</td></tr>';
return;
}
elements.sub2ApiServicesTable.innerHTML = services.map(s => `
<tr>
<td>${escapeHtml(s.name)}</td>
<td><code>${escapeHtml(s.api_url)}</code></td>
<td><span class="status-badge ${s.enabled ? 'active' : 'disabled'}">${s.enabled ? '已启用' : '已禁用'}</span></td>
<td>${s.priority}</td>
<td>
<div class="action-buttons">
<button class="btn btn-ghost btn-sm" onclick="editSub2ApiService(${s.id})" title="编辑">✏️</button>
<button class="btn btn-ghost btn-sm" onclick="deleteSub2ApiService(${s.id}, '${escapeHtml(s.name)}')" title="删除">🗑️</button>
</div>
</td>
</tr>
`).join('');
}
function openSub2ApiServiceModal(svc = null) {
_sub2apiEditingId = svc ? svc.id : null;
elements.sub2ApiServiceModalTitle.textContent = svc ? '编辑 Sub2API 服务' : '添加 Sub2API 服务';
elements.sub2ApiServiceForm.reset();
document.getElementById('sub2api-service-id').value = svc ? svc.id : '';
if (svc) {
document.getElementById('sub2api-service-name').value = svc.name || '';
document.getElementById('sub2api-service-url').value = svc.api_url || '';
document.getElementById('sub2api-service-priority').value = svc.priority ?? 0;
document.getElementById('sub2api-service-enabled').checked = svc.enabled !== false;
document.getElementById('sub2api-service-key').placeholder = svc.has_key ? '已配置,留空保持不变' : '请输入 API Key';
}
elements.sub2ApiServiceEditModal.classList.add('active');
}
function closeSub2ApiServiceModal() {
elements.sub2ApiServiceEditModal.classList.remove('active');
elements.sub2ApiServiceForm.reset();
_sub2apiEditingId = null;
}
async function editSub2ApiService(id) {
try {
const svc = await api.get(`/sub2api-services/${id}`);
openSub2ApiServiceModal(svc);
} catch (e) {
toast.error('加载失败: ' + e.message);
}
}
async function deleteSub2ApiService(id, name) {
if (!confirm(`确认删除 Sub2API 服务「${name}」?`)) return;
try {
await api.delete(`/sub2api-services/${id}`);
toast.success('服务已删除');
loadSub2ApiServices();
} catch (e) {
toast.error('删除失败: ' + e.message);
}
}
async function handleSaveSub2ApiService(e) {
e.preventDefault();
const id = document.getElementById('sub2api-service-id').value;
const data = {
name: document.getElementById('sub2api-service-name').value,
api_url: document.getElementById('sub2api-service-url').value,
api_key: document.getElementById('sub2api-service-key').value || undefined,
priority: parseInt(document.getElementById('sub2api-service-priority').value) || 0,
enabled: document.getElementById('sub2api-service-enabled').checked,
};
if (!id && !data.api_key) {
toast.error('请填写 API Key');
return;
}
if (!data.api_key) delete data.api_key;
try {
if (id) {
await api.patch(`/sub2api-services/${id}`, data);
toast.success('服务已更新');
} else {
await api.post('/sub2api-services', data);
toast.success('服务已添加');
}
closeSub2ApiServiceModal();
loadSub2ApiServices();
} catch (e) {
toast.error('保存失败: ' + e.message);
}
}
async function handleTestSub2ApiService() {
const apiUrl = document.getElementById('sub2api-service-url').value.trim();
const apiKey = document.getElementById('sub2api-service-key').value.trim();
const id = document.getElementById('sub2api-service-id').value;
if (!apiUrl) {
toast.error('请先填写 API URL');
return;
}
if (!id && !apiKey) {
toast.error('请先填写 API Key');
return;
}
elements.testSub2ApiServiceBtn.disabled = true;
elements.testSub2ApiServiceBtn.textContent = '测试中...';
try {
let result;
if (id && !apiKey) {
result = await api.post(`/sub2api-services/${id}/test`);
} else {
result = await api.post('/sub2api-services/test-connection', { api_url: apiUrl, api_key: apiKey });
}
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.message);
}
} catch (e) {
toast.error('测试失败: ' + e.message);
} finally {
elements.testSub2ApiServiceBtn.disabled = false;
elements.testSub2ApiServiceBtn.textContent = '🔌 测试连接';
}
}
function escapeHtml(text) {
if (!text) return '';
const d = document.createElement('div');