Files
gpt/static/account-manager.js
432539 fef45134d5 Initial commit: GPT 账号注册机 (open source)
只开源「GPT 账号注册机 / 管理」(account-manager):账号列表/导入导出、检测/批量检测、
拿 RT、协议/浏览器注册等注册机能力。批量直开 / 协议支付 / 提取链接 / 直卡直开 等其它功能
不含实现代码,仅通过 iframe 内嵌外链访问。真实机密均由 .gitignore 忽略,仅提供脱敏示例配置。
2026-08-05 14:54:32 +08:00

1242 lines
64 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 账号管理 —— GPT 账号(sub2api/probe) 为主 + PLUS 邮箱(查收/拿RT) 为辅。
GPT 侧移植自 auto public/app.js 的 renderProbeAccounts 等;PLUS 侧沿用先前实现。 */
(function () {
'use strict';
const API_BASE = (document.documentElement.getAttribute('data-api-base') || '').replace(/\/$/, '');
const $ = (sel) => document.querySelector(sel);
const state = {
activeTab: 'gpt',
// GPT / probe
probe: [],
probeSelected: new Set(),
probeShowPw: new Set(),
probeAutoRefresh: null,
pbPage: 1,
pbPageSize: 10,
pbOpenedOnly: false,
// PLUS
plus: [],
plSelected: new Set(),
plShowPw: new Set(),
plCodes: new Map(),
plStatusMap: new Map(),
plSearch: '',
plFilter: 'all',
plPage: 1,
plPageSize: 10,
};
const SUB2API_DEFAULT_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
// ---------- 工具 ----------
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
function domainOf(email) { const m = String(email || '').split('@'); return m[1] || ''; }
function fmtTime(t) { if (!t) return ''; const d = new Date(t); return Number.isNaN(d.getTime()) ? String(t) : d.toLocaleString(); }
function fmtClock(t) { if (!t) return ''; const d = new Date(t); return Number.isNaN(d.getTime()) ? '' : d.toLocaleTimeString(); }
let toastTimer = null;
function toast(msg, type) {
const el = $('#amToast');
el.textContent = msg;
el.className = 'am-toast show' + (type === 'ok' ? ' ok' : type === 'err' ? ' err' : '');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { el.className = 'am-toast'; }, 2600);
}
async function copyText(text, okMsg) {
try { await navigator.clipboard.writeText(String(text || '')); toast(okMsg || '已复制', 'ok'); }
catch { toast('复制失败', 'err'); }
}
// 主题化确认弹窗(替代原生 confirm),返回 Promise<boolean>
function confirmDialog(message, opts) {
opts = opts || {};
return new Promise((resolve) => {
const modal = $('#confirmModal');
const okBtn = $('#confirmOk');
const cancelBtn = $('#confirmCancel');
const closeX = $('#confirmCloseX');
const input = $('#confirmInput'); if (input) input.hidden = true;
$('#confirmTitle').textContent = opts.title || '确认操作';
$('#confirmMsg').textContent = String(message == null ? '' : message);
okBtn.textContent = opts.okText || '确定';
okBtn.className = opts.danger ? 'danger' : '';
let done = false;
const cleanup = (val) => {
if (done) return; done = true;
modal.hidden = true;
okBtn.removeEventListener('click', onOk);
cancelBtn.removeEventListener('click', onCancel);
closeX.removeEventListener('click', onCancel);
modal.removeEventListener('mousedown', onBackdrop);
document.removeEventListener('keydown', onKey);
resolve(val);
};
const onOk = () => cleanup(true);
const onCancel = () => cleanup(false);
const onBackdrop = (e) => { if (e.target === modal) cleanup(false); };
const onKey = (e) => { if (e.key === 'Escape') cleanup(false); else if (e.key === 'Enter') cleanup(true); };
okBtn.addEventListener('click', onOk);
cancelBtn.addEventListener('click', onCancel);
closeX.addEventListener('click', onCancel);
modal.addEventListener('mousedown', onBackdrop);
document.addEventListener('keydown', onKey);
modal.hidden = false;
okBtn.focus();
});
}
function promptDialog(message, opts) {
opts = opts || {};
return new Promise((resolve) => {
const modal = $('#confirmModal');
const okBtn = $('#confirmOk');
const cancelBtn = $('#confirmCancel');
const closeX = $('#confirmCloseX');
const input = $('#confirmInput');
$('#confirmTitle').textContent = opts.title || '请输入';
$('#confirmMsg').textContent = String(message == null ? '' : message);
okBtn.textContent = opts.okText || '确定';
okBtn.className = '';
input.hidden = false;
input.value = opts.value || '';
input.placeholder = opts.placeholder || '';
let done = false;
const cleanup = (val) => {
if (done) return; done = true;
modal.hidden = true;
input.hidden = true;
okBtn.removeEventListener('click', onOk);
cancelBtn.removeEventListener('click', onCancel);
closeX.removeEventListener('click', onCancel);
modal.removeEventListener('mousedown', onBackdrop);
document.removeEventListener('keydown', onKey);
resolve(val);
};
const onOk = () => cleanup(input.value);
const onCancel = () => cleanup(null);
const onBackdrop = (e) => { if (e.target === modal) cleanup(null); };
const onKey = (e) => { if (e.key === 'Escape') cleanup(null); else if (e.key === 'Enter') cleanup(input.value); };
okBtn.addEventListener('click', onOk);
cancelBtn.addEventListener('click', onCancel);
closeX.addEventListener('click', onCancel);
modal.addEventListener('mousedown', onBackdrop);
document.addEventListener('keydown', onKey);
modal.hidden = false;
setTimeout(() => input.focus(), 0);
});
}
async function api(method, path, body) {
const opts = { method, headers: {} };
if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); }
const res = await fetch(API_BASE + path, opts);
const ct = res.headers.get('content-type') || '';
const data = ct.includes('application/json') ? await res.json() : {};
if (!res.ok) throw new Error(data.error || ('HTTP ' + res.status));
return data;
}
async function downloadPost(path, body, fallbackName) {
const res = await fetch(API_BASE + path, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}),
});
if (!res.ok) throw new Error('HTTP ' + res.status);
const count = res.headers.get('X-Export-Count');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = fallbackName;
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
return count;
}
function paginate(list, page, size) {
const total = list.length;
const pages = Math.max(1, Math.ceil(total / size));
const p = Math.min(Math.max(1, page), pages);
const startIdx = (p - 1) * size;
return { items: list.slice(startIdx, startIdx + size), page: p, pages, total, startIdx };
}
// ==========================================================================
// Tabs + 统计卡片
// ==========================================================================
// 视图切换已下线:拿RT 能力已并入账号管理(工具栏「重拿RT」),页面固定展示 sub2api 账号管理主视图。
// 旧 PLUS 邮箱视图的 DOM 仍保留但隐藏,避免删除后遗留坏引用/JS 报错(后端接口不动)。
function switchTab() {
state.activeTab = 'gpt';
const vg = $('#view-gpt'); if (vg) vg.hidden = false;
const vp = $('#view-plus'); if (vp) vp.hidden = true;
renderStats();
loadProbe();
}
function renderStats() {
if (state.activeTab === 'gpt') {
const okCount = state.probe.filter((a) => a.status === 'ok').length;
const renewed = state.probeAutoRefresh && state.probeAutoRefresh.lastResult ? (state.probeAutoRefresh.lastResult.refreshed || 0) : 0;
$('#statAL').textContent = '账号总数'; $('#statA').textContent = state.probe.length;
$('#statBL').textContent = '已选中'; $('#statB').textContent = state.probeSelected.size;
$('#statCL').textContent = '有效 AT'; $('#statC').textContent = okCount;
$('#statDL').textContent = '续期数量'; $('#statD').textContent = renewed;
updateBatchLabel();
} else {
$('#statAL').textContent = 'PLUS 总数'; $('#statA').textContent = state.plus.length;
$('#statBL').textContent = '已选中'; $('#statB').textContent = state.plSelected.size;
$('#statCL').textContent = 'PLUS数量'; $('#statC').textContent = state.plus.filter((a) => a.isPlus).length;
$('#statDL').textContent = 'RT提取数量'; $('#statD').textContent = state.plus.filter((a) => a.codexRt).length;
}
}
// ==========================================================================
// GPT 账号管理(probe / sub2api
// ==========================================================================
function formatTokenExp(a) {
const exp = Number(a.tokenExp) || 0;
if (!exp) return '<span class="muted">未知</span>';
const main = new Date(exp * 1000).toLocaleString();
const sec = a.tokenExpiresInSec;
const expired = a.tokenExpired === true || (typeof sec === 'number' && sec <= 0);
let sub = '';
if (expired) sub = '已过期';
else if (typeof sec === 'number') {
const d = Math.floor(sec / 86400), h = Math.floor((sec % 86400) / 3600), m = Math.floor((sec % 3600) / 60);
sub = d > 0 ? `剩余 ${d}${h} 小时` : h > 0 ? `剩余 ${h} 小时 ${m} 分` : `剩余 ${m} 分`;
}
return `<div class="pb-exp-cell${expired ? ' expired' : ''}"><div class="pb-exp-main">${escapeHtml(main)}</div><div class="pb-exp-sub">${escapeHtml(sub)}</div></div>`;
}
function statusPill(a) {
const s = a.status || 'unknown';
const label = { ok: '有效', expiring: '临期', invalid: '失效', unknown: '未知' }[s] || '未知';
return `<span class="pb-status ${s}">${label}</span>`;
}
function renderAutoRefreshHint() {
const ar = state.probeAutoRefresh;
const cb = $('#pbAutoRefresh');
const hint = $('#pbAutoRefreshHint');
if (!ar) { if (cb) cb.checked = true; if (hint) hint.textContent = '—'; return; }
if (cb) cb.checked = ar.enabled !== false;
if (!hint) return;
if (ar.running) { hint.textContent = '刷新中…'; return; }
if (!ar.enabled) { hint.textContent = '已关闭'; return; }
const parts = [`每 ${ar.intervalMin || 15} 分钟`];
if (ar.lastRunAt) parts.push(`上次 ${fmtClock(ar.lastRunAt)}`);
if (ar.lastResult) parts.push(`续期 ${ar.lastResult.refreshed || 0}`);
hint.textContent = parts.join(' · ');
}
let pbUnknownKickAt = 0;
function kickUnknownIfNeeded() {
const ar = state.probeAutoRefresh;
if (!ar || ar.enabled === false || ar.running) return;
const unknowns = state.probe.filter((a) => (a.status === 'unknown') && a.hasRefresh);
if (!unknowns.length) return;
const now = Date.now();
if (now - pbUnknownKickAt < 30000) return;
pbUnknownKickAt = now;
api('POST', '/api/probe/accounts/auto-refresh', { enabled: true, runNow: true })
.then((next) => { state.probeAutoRefresh = next; renderAutoRefreshHint(); setTimeout(() => loadProbe(true), 3000); })
.catch(() => {});
}
async function loadProbe(skipKick) {
try {
const data = await api('GET', '/api/probe/accounts');
state.probe = data.accounts || [];
state.probeAutoRefresh = data.autoRefresh || null;
renderProbe();
if (!skipKick) kickUnknownIfNeeded();
} catch (e) { toast('加载 GPT 账号失败:' + e.message, 'err'); }
}
async function pbDetectOne(id, btn) {
if (btn) btn.disabled = true;
toast('正在通过 AT 检测套餐…', 'info');
try {
const r = await api('POST', '/api/probe/accounts/detect', { id });
await loadProbe(true);
if (r.ok && r.source === 'live') {
toast('实时检测:' + (r.planLabel || '未知') + (r.active ? '(订阅有效)' : '') + (r.refreshed ? '|已刷新TOKEN' : ''), 'ok');
} else if (r.ok) {
toast('实时校验失败(' + (r.liveError || '未知') + '),已回退 AT 标记:' + (r.planLabel || '未知'), 'err');
} else {
toast(r.error || '检测失败', 'err');
}
} catch (e) {
toast('检测失败:' + e.message, 'err');
} finally {
if (btn) btn.disabled = false;
}
}
function pbFiltered() { return state.pbOpenedOnly ? state.probe.filter((a) => a.opened) : state.probe; }
function pbPageItems() { return paginate(pbFiltered(), state.pbPage, state.pbPageSize).items; }
function renderProbe() {
const tbody = $('#pbRows');
const total = state.probe.length;
const list = pbFiltered();
const pg = paginate(list, state.pbPage, state.pbPageSize);
state.pbPage = pg.page;
tbody.innerHTML = '';
renderAutoRefreshHint();
// 空态与表格互斥:真正 0 账号→导入提示;有账号但筛选无结果→无匹配结果;否则→表格
const emptyEl = $('#pbEmpty');
const tableEl = $('#pbTable');
if (total === 0) {
emptyEl.innerHTML = '<b>暂无 GPT 账号</b><span>点击「导入账号」粘贴 sub2api.json、每行 email----password----clientId----refreshToken,或每行一个 access token。</span>';
emptyEl.hidden = false;
tableEl.hidden = true;
$('#pbPageInfo').textContent = `第 1 / 1 页,共 0 条`;
$('#pbCheckAll').checked = false;
renderStats();
return;
}
if (list.length === 0) {
emptyEl.innerHTML = '<b>无匹配结果</b><span>当前搜索/筛选条件下没有账号。</span>';
emptyEl.hidden = false;
tableEl.hidden = true;
$('#pbPageInfo').textContent = `第 1 / 1 页,共 0 条`;
$('#pbCheckAll').checked = false;
renderStats();
return;
}
emptyEl.hidden = true;
tableEl.hidden = false;
pg.items.forEach((a, i) => {
const sel = state.probeSelected.has(a.id);
const showPw = state.probeShowPw.has(a.id);
const planKey = String(a.planType || '').toLowerCase();
const planCls = ['free', 'plus', 'pro'].includes(planKey) ? planKey : '';
const atCell = a.hasAccess
? `<span class="at-chip has" data-pb-copy-at="${escapeHtml(a.id)}" title="点击复制 Access Token">有 AT ⧉</span>`
: '<span class="at-chip none">无</span>';
const rtCell = a.hasRefresh
? `<span class="rt-chip has" data-pb-copy-rt="${escapeHtml(a.id)}" title="点击复制 Refresh Token">有 ⧉</span>`
: '<span class="rt-chip none">无</span>';
const subCell = a.subOk
? '<span class="sub-chip yes" title="第二阶段 SUB / sub2api 授权成功">是</span>'
: '<span class="sub-chip no" title="未取得 SUB 授权">否</span>';
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="col-check"><input type="checkbox" data-pb-check="${escapeHtml(a.id)}" ${sel ? 'checked' : ''}></td>
<td class="col-idx">${pg.startIdx + i + 1}</td>
<td>
<div class="email-cell">
<span class="text-mono am-email" title="${escapeHtml(a.email || '(无邮箱)')}">${escapeHtml(a.email || '(无邮箱)')}</span>
<span class="pb-plan-tag ${planCls}">${escapeHtml(a.planLabel || '未知')}</span>
${a.opened ? `<span class="pb-opened-tag" title="批量直开已开通${a.activatedAt ? '' + fmtTime(a.activatedAt) : ''}">已开通</span>` : ''}
</div>
${a.note ? `<div class="muted" style="font-size:12px">${escapeHtml(a.note)}</div>` : ''}
</td>
<td><div class="at-cell">${atCell}${a.hasAccess ? formatTokenExp(a) : ''}</div></td>
<td>${rtCell}</td>
<td class="col-sub">${subCell}</td>
<td>${statusPill(a)}</td>
<td class="col-ops">
<div class="row-ops">
<button class="op-link detect" data-pb-detect="${escapeHtml(a.id)}" title="通过 AT 检测真实套餐">检测</button>
<button class="op-link" data-pb-edit="${escapeHtml(a.id)}">编辑</button>
<button class="op-link" data-pb-refresh="${escapeHtml(a.id)}">刷新TOKEN</button>
<button class="op-link danger" data-pb-del="${escapeHtml(a.id)}">删除</button>
</div>
</td>`;
tbody.appendChild(tr);
});
$('#pbPageInfo').textContent = `第 ${pg.page} / ${pg.pages} 页,共 ${pg.total} 条`;
$('#pbCheckAll').checked = pg.items.length > 0 && pg.items.every((a) => state.probeSelected.has(a.id));
renderStats();
}
// 导入 sub2api
function pbParseCount(text) {
text = String(text || '').trim();
if (!text) return 0;
try {
const obj = JSON.parse(text);
const arr = Array.isArray(obj) ? obj : (obj && Array.isArray(obj.accounts) ? obj.accounts : []);
return arr.filter((a) => a && (a.name || a.email || (a.credentials && (a.credentials.access_token || a.credentials.refresh_token)))).length;
} catch { /* not JSON — count lines */ }
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
let n = 0; const seen = new Set();
const add = (k) => { if (k && !seen.has(k)) { seen.add(k); n++; } };
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim(); if (!line) continue;
const delim = line.includes('----') ? '----' : line.includes('\t') ? '\t' : line.includes('|') ? '|' : null;
if (delim) {
const parts = line.split(delim).map((s) => s.trim()).filter(Boolean);
if (parts.length >= 4 && emailRe.test(parts[0])) add('email:' + parts[0].toLowerCase());
continue;
}
const ws = line.split(/\s+/).filter(Boolean);
if (ws.length >= 4 && emailRe.test(ws[0])) add('email:' + ws[0].toLowerCase());
else add('at:' + (ws.length === 1 ? ws[0] : line)); // 裸 access token
}
return n;
}
function updatePbParsePreview() { $('#pbParsePreview').textContent = `识别到 ${pbParseCount($('#pbImportText').value)} 个有效账号`; }
function openPbImport() { $('#pbImportText').value = ''; updatePbParsePreview(); $('#pbImportModal').hidden = false; }
function closePbImport() { $('#pbImportModal').hidden = true; }
async function doPbImport() {
const text = $('#pbImportText').value.trim();
if (!text) return toast('请粘贴 sub2api.json 或账号行', 'err');
try {
const r = await api('POST', '/api/probe/accounts/import', { text });
closePbImport();
toast(`导入完成:新增 ${r.added}|更新 ${r.updated}|共 ${r.total}`, 'ok');
await loadProbe(true);
if ((r.added + r.updated) > 0) setTimeout(() => loadProbe(true), 3000);
} catch (e) { toast('导入失败:' + e.message, 'err'); }
}
// 编辑
function openPbEdit(id) {
const a = state.probe.find((x) => String(x.id) === String(id));
if (!a) return toast('账号不存在', 'err');
$('#pbEditId').value = a.id;
$('#pbEditEmail').value = a.email || '';
$('#pbEditPassword').value = a.password || '';
$('#pbEditTwoFactor').value = a.twoFactorSecret || '';
$('#pbEditAccessToken').value = a.accessToken || '';
$('#pbEditRefreshToken').value = a.refreshToken || '';
$('#pbEditAccountId').value = a.chatgptAccountId || '';
$('#pbEditNote').value = a.note || '';
$('#pbEditModal').hidden = false;
}
function closePbEdit() { $('#pbEditModal').hidden = true; }
async function savePbEdit() {
const id = $('#pbEditId').value; if (!id) return;
try {
await api('PUT', '/api/probe/accounts', {
id,
email: $('#pbEditEmail').value.trim(),
password: $('#pbEditPassword').value.trim(),
twoFactorSecret: $('#pbEditTwoFactor').value.trim(),
accessToken: $('#pbEditAccessToken').value.trim(),
refreshToken: $('#pbEditRefreshToken').value.trim(),
chatgptAccountId: $('#pbEditAccountId').value.trim(),
note: $('#pbEditNote').value.trim(),
});
closePbEdit();
toast('已保存', 'ok');
loadProbe(true);
} catch (e) { toast('保存失败:' + e.message, 'err'); }
}
async function pbRefreshOne(id) {
try {
toast('正在刷新 TOKEN…');
const r = await api('POST', '/api/probe/accounts/refresh', { id });
if (r.ok) toast('TOKEN 已刷新,状态:有效', 'ok');
else toast(`刷新失败:${r.error || 'unknown'}`, 'err');
loadProbe(true);
} catch (e) { toast('刷新失败:' + e.message, 'err'); loadProbe(true); }
}
async function pbDeleteOne(id) {
if (!(await confirmDialog('确认删除该 GPT 账号?', { title: '删除账号', okText: '删除', danger: true }))) return;
try {
const r = await api('DELETE', '/api/probe/accounts', { ids: [id] });
state.probeSelected.delete(id);
toast(`已删除 ${r.removed} 个`, 'ok');
loadProbe(true);
} catch (e) { toast('删除失败:' + e.message, 'err'); }
}
async function pbDeleteSelected() {
const ids = Array.from(state.probeSelected);
if (!ids.length) return toast('请先勾选要删除的账号', 'err');
if (!(await confirmDialog(`确认删除所选 ${ids.length} 个 GPT 账号?`, { title: '删除所选', okText: '删除', danger: true }))) return;
try {
const r = await api('DELETE', '/api/probe/accounts', { ids });
state.probeSelected.clear();
toast(`已删除 ${r.removed} 个`, 'ok');
loadProbe(true);
} catch (e) { toast('删除失败:' + e.message, 'err'); }
}
async function pbDeleteAll() {
if (!state.probe.length) return toast('没有账号可清空');
if (!(await confirmDialog(`确认清空全部 ${state.probe.length} 个 GPT 账号?`, { title: '清空账号', okText: '清空', danger: true }))) return;
try {
const r = await api('DELETE', '/api/probe/accounts', { all: true });
state.probeSelected.clear();
toast(`已清空(删除 ${r.removed} 个)`, 'ok');
loadProbe(true);
} catch (e) { toast('清空失败:' + e.message, 'err'); }
}
// 删除失效(status==='invalid')/删除过期(tokenExpired===true):前端按 enriched 字段收集 id,走通用 DELETE
async function pbDeleteByFilter(kind) {
let ids;
const label = kind === 'invalid' ? '失效' : '过期';
if (kind === 'invalid') ids = state.probe.filter((a) => a.status === 'invalid').map((a) => a.id);
else ids = state.probe.filter((a) => a.tokenExpired === true).map((a) => a.id);
if (!ids.length) return toast('没有符合条件的账号');
if (!(await confirmDialog(`确认删除 ${ids.length}${label}账号?`, { title: `删除${label}账号`, okText: '删除', danger: true }))) return;
try {
const r = await api('DELETE', '/api/probe/accounts', { ids });
ids.forEach((id) => state.probeSelected.delete(id));
toast(`已删除 ${r.removed}${label}账号`, 'ok');
loadProbe(true);
} catch (e) { toast('删除失败:' + e.message, 'err'); }
}
// 批量检测:≤5 线程实时套餐校验(选中优先,否则全部)
let detectPoll = null;
async function pbDetectBatch() {
const ids = Array.from(state.probeSelected);
const body = ids.length ? { ids } : {};
const startTs = Date.now();
try {
const r = await api('POST', '/api/probe/accounts/detect-batch', body);
if (!r.ok) return toast(r.error || '无法启动批量检测', 'err');
toast(`已开始批量检测 ${r.total} 个账号`, 'ok');
startDetectPolling(startTs);
} catch (e) { toast('启动失败:' + e.message, 'err'); }
}
function startDetectPolling(startTs) {
const btn = $('#btnPbDetectBatch');
const statusEl = $('#pbDetectStatus');
if (btn) btn.disabled = true;
if (detectPoll) clearInterval(detectPoll);
const tick = async () => {
try {
const s = await api('GET', '/api/probe/accounts/detect-batch/status');
if (statusEl) { statusEl.hidden = false; statusEl.textContent = `批量检测 ${s.done}/${s.total}(成功 ${s.ok} · 失败 ${s.fail}`; }
const finished = !s.running && s.finishedAt && s.finishedAt >= (startTs - 2000);
if (finished) {
clearInterval(detectPoll); detectPoll = null;
if (btn) btn.disabled = false;
toast(`批量检测完成:成功 ${s.ok} / 共 ${s.total}`, 'ok');
await loadProbe(true);
setTimeout(() => { if (statusEl) { statusEl.hidden = true; } }, 4000);
}
} catch (e) { /* 轮询期临时错误忽略,继续下次 */ }
};
tick();
detectPoll = setInterval(tick, 1200);
}
// 工具栏下拉:点击切换、外部点击/Esc 关闭
function initDropdowns() {
const dds = Array.from(document.querySelectorAll('.am-dropdown'));
const closeAll = () => dds.forEach((o) => { o.classList.remove('open'); const t = o.querySelector('.am-dropdown-toggle'); if (t) t.setAttribute('aria-expanded', 'false'); });
dds.forEach((dd) => {
const toggle = dd.querySelector('.am-dropdown-toggle');
if (!toggle) return;
toggle.addEventListener('click', (e) => {
e.stopPropagation();
const willOpen = !dd.classList.contains('open');
closeAll();
if (willOpen) { dd.classList.add('open'); toggle.setAttribute('aria-expanded', 'true'); }
});
dd.querySelectorAll('.am-dropdown-menu button').forEach((b) => b.addEventListener('click', closeAll));
});
document.addEventListener('click', closeAll);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeAll(); });
}
function pbExportIds() { return state.probeSelected.size ? Array.from(state.probeSelected) : null; }
async function pbExportAt() {
if (!state.probe.length) return toast('没有可导出的账号');
try {
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const count = await downloadPost('/api/probe/accounts/export-at', { ids: pbExportIds() }, `probe_at_export_${ts}.txt`);
toast(`已导出 ${count || 0} 个 AT(一行一个)`, 'ok');
} catch (e) { toast('导出失败:' + e.message, 'err'); }
}
async function pbExportSub() {
if (!state.probe.length) return toast('没有可导出的账号');
try {
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const count = await downloadPost('/api/probe/accounts/export-sub', { ids: pbExportIds() }, `sub2api_export_${ts}.json`);
toast(`已导出 ${count || 0} 个账号(SUB2API 格式)`, 'ok');
} catch (e) { toast('导出失败:' + e.message, 'err'); }
}
async function pbExportPw() {
if (!state.probe.length) return toast('没有可导出的账号');
try {
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const count = await downloadPost('/api/probe/accounts/export-pw', { ids: pbExportIds() }, `probe_pw_export_${ts}.txt`);
toast(`已导出 ${count || 0} 个账密(email----password`, 'ok');
} catch (e) { toast('导出失败:' + e.message, 'err'); }
}
function updateBatchLabel() {
const btn = $('#ddBatchToggle');
if (!btn) return;
const n = state.probeSelected.size;
btn.innerHTML = (n ? `批量操作(已选 ${n}` : '批量操作') + ' <span class="am-caret">▾</span>';
const rb = $('#btnPbRefetch');
if (rb) rb.textContent = n ? `重拿RT${n}` : '重拿RT';
const bp = $('#btnPbBindPhone');
if (bp) bp.textContent = n ? `批量绑手机拿RT${n}` : '批量绑手机拿RT';
}
async function toggleAutoRefresh(enabled) {
try {
const ar = await api('POST', '/api/probe/accounts/auto-refresh', { enabled: !!enabled });
state.probeAutoRefresh = ar;
renderAutoRefreshHint();
toast(ar.enabled ? '已开启自动刷新 TOKEN' : '已关闭自动刷新 TOKEN', 'ok');
} catch (e) { toast('切换失败:' + e.message, 'err'); renderAutoRefreshHint(); }
}
// ==========================================================================
// 设置弹窗(代理池 + 接码)
// ==========================================================================
function setSettingsTab(name) {
document.querySelectorAll('.am-set-tab').forEach(b => b.classList.toggle('active', b.dataset.setTab === name));
document.querySelectorAll('.am-set-panel').forEach(p => { p.hidden = p.dataset.setPanel !== name; });
}
let mbEntries = [];
function renderMbList(entries, count, unused) {
if (Array.isArray(entries)) mbEntries = entries;
const list = $('#mbList');
$('#setMbCount').textContent = `共 ${count != null ? count : mbEntries.length} 个(未使用 ${unused != null ? unused : mbEntries.filter(e => !e.used).length}`;
if (!mbEntries.length) { list.innerHTML = '<div class="am-mb-empty">邮箱池为空,粘贴后点「导入到列表」</div>'; return; }
list.innerHTML = mbEntries.map(e => `
<div class="am-mb-row" data-email="${escapeHtml(e.email)}">
<span class="am-mb-addr" title="${escapeHtml(e.email)}">${escapeHtml(e.email)}</span>
<span class="am-mb-badge ${e.used ? 'used' : 'free'}">${e.used ? '已使用' : '未使用'}</span>
<span class="am-mb-tag">${e.type === 'api' ? 'API' : 'IMAP'}</span>
<span class="am-mb-ops">
<button class="op-link" data-mb-test="${escapeHtml(e.email)}">测试</button>
<button class="op-link" data-mb-edit="${escapeHtml(e.email)}">编辑</button>
<button class="op-link danger" data-mb-del="${escapeHtml(e.email)}">删除</button>
</span>
</div>`).join('');
}
async function importMailbox() {
const text = $('#setMailbox').value.trim();
if (!text) { toast('请先粘贴 邮箱----凭证', 'err'); return; }
try {
const r = await api('POST', '/api/mailbox/import', { text });
renderMbList(r.entries, r.count, r.unused);
$('#setMailbox').value = '';
$('#mbImportBox').hidden = true;
toast(`已导入 ${r.added} 个(解析 ${r.parsed}),共 ${r.count} 个`, 'ok');
} catch (e) { toast('导入失败:' + e.message, 'err'); }
}
async function generateSplitAliases() {
// 防呆:分裂读的是「已入库的邮箱池」,不读粘贴框。若批量导入框里还有未导入文本,用户多半是
// 粘完直接点了分裂——此时分裂会加到旧池、粘贴内容丢失(历史「邮箱加错了」的根因)。先拦下提示。
const pending = ($('#setMailbox').value || '').trim();
if (pending) {
await confirmDialog('检测到「批量导入」框里有未导入的内容。请先点『导入到列表』把它们入库,再生成分裂别名。', {
title: '请先导入', okText: '知道了', cancelText: '', danger: false,
});
// 顺手展开导入框并聚焦,引导用户先导入。
$('#mbImportBox').hidden = false;
setTimeout(() => $('#setMailbox').focus(), 0);
return;
}
const n = Math.max(1, Math.min(200, Number($('#mbSplitCount').value) || 10));
try {
const r = await api('POST', '/api/mailbox/split-generate', { count: n });
renderMbList(r.entries, r.count, r.unused);
toast(`已生成 ${r.added} 个分裂邮箱`, 'ok');
} catch (e) { toast('生成失败:' + e.message, 'err'); }
}
async function clearAllMailbox() {
if (!await confirmDialog('确定清空邮箱池的全部条目?此操作不可恢复。', { title: '清空邮箱池', okText: '清空', danger: true })) return;
try {
const r = await api('POST', '/api/mailbox/clear', {});
renderMbList(r.entries || [], r.count != null ? r.count : 0, r.unused != null ? r.unused : 0);
toast('已清空邮箱池', 'ok');
} catch (e) { toast('清空失败:' + e.message, 'err'); }
}
let mbEditingEmail = '';
function editMailbox(email) {
const e = mbEntries.find(x => (x.email || '').toLowerCase() === (email || '').toLowerCase());
if (!e) { toast('邮箱不存在', 'err'); return; }
mbEditingEmail = e.email;
$('#mbEditEmail').textContent = e.email;
$('#mbEditCred').value = e.credential || '';
$('#mbEditUsed').checked = !!e.used;
$('#mbEditModal').hidden = false;
setTimeout(() => $('#mbEditCred').focus(), 0);
}
function closeMbEdit() { $('#mbEditModal').hidden = true; mbEditingEmail = ''; }
async function saveMbEdit() {
if (!mbEditingEmail) return;
const credential = $('#mbEditCred').value.trim();
const used = $('#mbEditUsed').checked;
try {
await api('POST', '/api/mailbox/update', { email: mbEditingEmail, credential, used });
// 本地同步并重算未使用数
const e = mbEntries.find(x => (x.email || '').toLowerCase() === mbEditingEmail.toLowerCase());
if (e) {
if (credential) { e.credential = credential; e.type = /^https?:\/\//i.test(credential) ? 'api' : 'imap'; }
e.used = used;
}
renderMbList(mbEntries);
closeMbEdit();
toast('已保存', 'ok');
} catch (e) { toast('更新失败:' + e.message, 'err'); }
}
async function deleteMailbox(email) {
if (!await confirmDialog(`确定删除邮箱「${email}」?`, { title: '删除邮箱', danger: true })) return;
try {
const r = await api('POST', '/api/mailbox/delete', { email });
renderMbList(r.entries, r.count, r.unused);
toast('已删除', 'ok');
} catch (e) { toast('删除失败:' + e.message, 'err'); }
}
// ==========================================================================
// 协议注册(后台任务 + 右下角进度浮层)
// ==========================================================================
let regTimer = null;
// 增量渲染去重用:记录已渲染日志的 key,避免每次轮询整体重绘(那是卡顿主因)
const regLogKeys = new Set();
const REG_LOG_MAX_LINES = 500;
function regLogKey(l) { return `${l.ts || 0}|${l.level || ''}|${l.account || ''}|${l.msg || ''}`; }
function appendRegLogs(logs) {
const box = $('#regLogs');
if (!box) return;
const atBottom = box.scrollTop + box.clientHeight >= box.scrollHeight - 28;
const frag = document.createDocumentFragment();
let added = 0;
for (const l of (logs || [])) {
const k = regLogKey(l);
if (regLogKeys.has(k)) continue;
regLogKeys.add(k);
const t = new Date(l.ts || Date.now()).toLocaleTimeString('zh-CN', { hour12: false });
const lv = String(l.level || 'info').replace(/[^a-z]/gi, '') || 'info';
const div = document.createElement('div');
div.className = 'reg-log-line lv-' + lv;
// 不再渲染账号邮箱(节省空间);后端日志对象仍带 account,仅前端不显示。
div.innerHTML = `<span class="reg-log-ts">${t}</span> <span class="reg-log-msg">${escapeHtml(l.msg || '')}</span>`;
frag.appendChild(div);
added++;
}
if (!added) return;
box.appendChild(frag);
while (box.childElementCount > REG_LOG_MAX_LINES) box.removeChild(box.firstElementChild);
if (atBottom) box.scrollTop = box.scrollHeight; // 用户上滚查看历史时不强制吸底
}
async function clearRegLogs() {
regLogKeys.clear();
const box = $('#regLogs');
if (box) box.innerHTML = '';
try { await api('POST', '/api/reg/logs/clear', {}); } catch (e) { /* 后端无此接口也无妨 */ }
}
// 注册方式切换:仅浏览器注册时显示「无头/有头」选择。
function syncRegMode() {
const isBrowser = $('#regMode') && $('#regMode').value === 'browser';
if ($('#regHeadlessWrap')) $('#regHeadlessWrap').hidden = !isBrowser;
}
async function openRegModal() {
$('#regUnused').textContent = '…';
syncRegMode();
$('#regModal').hidden = false;
try {
const s = await api('GET', '/api/reg/status');
$('#regUnused').textContent = s.unused != null ? s.unused : '?';
if (s.unused) $('#regCount').value = Math.min(Number($('#regCount').value) || 10, s.unused) || s.unused;
} catch (e) { $('#regUnused').textContent = '?'; }
}
function closeRegModal() { $('#regModal').hidden = true; }
async function startReg() {
const count = Math.max(1, Number($('#regCount').value) || 1);
const threads = Math.max(1, Math.min(20, Number($('#regThreads').value) || 1));
const mode = ($('#regMode') && $('#regMode').value) || 'protocol';
const headless = !($('#regHeadless') && $('#regHeadless').value === '0'); // 有头=false,其余无头
const label = mode === 'browser' ? '浏览器注册' : '协议注册';
try {
const r = await api('POST', '/api/reg/start', { count, threads, mode, headless });
closeRegModal();
toast(`已开始${label}${r.started} 个`, 'ok');
showRegPanel();
startRegPoll();
} catch (e) { toast('启动失败:' + e.message, 'err'); }
}
async function stopReg() {
try { await api('POST', '/api/reg/stop', {}); toast('已请求停止,将在进行中的账号完成后结束', 'ok'); }
catch (e) { toast('停止失败:' + e.message, 'err'); }
}
// 「重拿RT」:对勾选账号用 codex 客户端重新登录拿 RT(需要手机验证会自动接码),复用注册日志浮层。
async function refetchSelected() {
const ids = Array.from(state.probeSelected);
if (!ids.length) return toast('请先勾选要重拿 RT 的账号', 'err');
if (!(await confirmDialog(`确认对所选 ${ids.length} 个账号重拿 RT?将用 codex 重新登录(需要时自动接码)。`,
{ title: '重拿RT', okText: '开始' }))) return;
const btn = $('#btnPbRefetch');
if (btn) { btn.disabled = true; }
try {
const r = await api('POST', '/api/probe/refetch-rt', { ids });
toast(`已开始重拿 RT${r.started} 个`, 'ok');
showRegPanel();
startRegPoll();
} catch (e) {
toast('启动失败:' + e.message, 'err');
} finally {
if (btn) { btn.disabled = false; }
}
}
// 「批量绑手机拿RT」:对「跳过手机注册」的待绑手机账号(勾选优先;不选则后端自动挑全部 phonePending
// 补做手机验证并拿 RT。底层与重拿RT同一 codex 登录流程,成功后 SUB=是、清 phonePending。复用注册日志浮层。
async function bindPhoneSelected() {
const ids = Array.from(state.probeSelected);
const tip = ids.length
? `确认对所选 ${ids.length} 个账号补绑手机并拿 RT`
: '未勾选账号:将对库里全部「待绑手机(无RT)」账号批量补绑手机拿 RT,确认?';
if (!(await confirmDialog(tip + '\n将用邮箱+固定密码重新登录,需要时自动接码(多国轮试)。',
{ title: '批量绑手机拿RT', okText: '开始' }))) return;
const btn = $('#btnPbBindPhone');
if (btn) { btn.disabled = true; }
try {
const r = await api('POST', '/api/probe/bind-phone-rt', { ids });
toast(`已开始绑手机拿 RT${r.started} 个`, 'ok');
showRegPanel();
startRegPoll();
} catch (e) {
toast('启动失败:' + e.message, 'err');
} finally {
if (btn) { btn.disabled = false; }
}
}
function showRegPanel() { $('#regPanel').hidden = false; $('#regPanel').classList.remove('min'); }
function renderRegStatus(s) {
const total = s.total || 0, done = s.done || 0;
const pct = total ? Math.round(done / total * 100) : 0;
$('#regProgressBar').style.width = pct + '%';
$('#regPanelStat').textContent = `${done}/${total} · 成功 ${s.ok || 0} · 失败 ${s.fail || 0}${s.running ? '' : ' · 已结束'}`;
appendRegLogs(s.logs || []);
$('#regPanelStop').hidden = !s.running;
$('#regPanelClose').hidden = !!s.running;
}
function startRegPoll() {
if (regTimer) clearInterval(regTimer);
const tick = async () => {
try {
const s = await api('GET', '/api/reg/status');
renderRegStatus(s);
if (!s.running) { clearInterval(regTimer); regTimer = null; loadProbe(); }
} catch (e) { /* 忽略单次轮询错误 */ }
};
tick();
regTimer = setInterval(tick, 1500);
}
async function resumeRegPanel() {
// 页面加载/切换回来时,若后台有任务在跑或刚结束,恢复浮层
try {
const s = await api('GET', '/api/reg/status');
if (s.running || (s.total && s.logs && s.logs.length)) {
showRegPanel();
if (s.running) startRegPoll(); else renderRegStatus(s);
}
} catch (e) { /* ignore */ }
}
let smsAllCfg = {};
function fillSmsFields(v) {
v = v || {};
$('#setSmsApiKey').value = v.apiKey || '';
$('#setSmsBaseUrl').value = v.baseUrl || '';
$('#setSmsCountry').value = (v.country === 0 || v.country) ? v.country : '';
$('#setSmsMaxPrice').value = v.maxPrice || '';
}
async function testSmsBalance() {
const el = $('#smsBalanceResult');
el.className = 'am-sms-bal'; el.textContent = '查询中…';
try {
const r = await api('POST', '/api/sms/balance', {
provider: $('#setSmsProvider').value,
apiKey: $('#setSmsApiKey').value.trim(),
baseUrl: $('#setSmsBaseUrl').value.trim(),
});
if (r.ok) { el.className = 'am-sms-bal ok'; el.textContent = '余额:' + r.balance; }
else { el.className = 'am-sms-bal err'; el.textContent = '失败:' + (r.error || '未知错误'); }
} catch (e) { el.className = 'am-sms-bal err'; el.textContent = '失败:' + e.message; }
}
async function openSettings() {
try {
const cfg = await api('GET', '/api/account-config');
$('#setProxies').value = (cfg.proxies || []).join('\n');
$('#setProxyCount').textContent = `共 ${cfg.proxyCount || 0} 条`;
const sms = cfg.sms || {};
smsAllCfg = cfg.smsAll || {};
$('#setSmsProvider').value = sms.provider || 'smscode';
fillSmsFields(sms);
$('#setSmsPollTimeout').value = sms.pollTimeout || 180000;
$('#smsBalanceResult').textContent = '';
const mb = cfg.mailbox || {};
$('#setMailbox').value = '';
$('#mbImportBox').hidden = true;
$('#setMbSplit').checked = !!mb.split;
renderMbList(mb.entries || [], mb.count, mb.unused);
$('#mbTestResult').hidden = true; $('#mbTestResult').innerHTML = '';
const cloak = cfg.cloak || {};
if ($('#setCloakKey')) $('#setCloakKey').value = cloak.licenseKey || '';
if ($('#setRegPassword')) $('#setRegPassword').value = cloak.regPassword || '';
setSettingsTab('proxy');
$('#settingsModal').hidden = false;
} catch (e) { toast('读取设置失败:' + e.message, 'err'); }
}
function closeSettings() { $('#settingsModal').hidden = true; }
async function saveSettings() {
try {
const r = await api('POST', '/api/account-config', {
proxies: $('#setProxies').value,
sms: {
provider: $('#setSmsProvider').value,
apiKey: $('#setSmsApiKey').value.trim(),
baseUrl: $('#setSmsBaseUrl').value.trim(),
country: $('#setSmsCountry').value.trim(),
maxPrice: $('#setSmsMaxPrice').value.trim(),
pollTimeout: Number($('#setSmsPollTimeout').value) || 180000,
},
mailbox: {
split: $('#setMbSplit').checked,
},
cloak: {
licenseKey: ($('#setCloakKey') && $('#setCloakKey').value.trim()) || '',
regPassword: ($('#setRegPassword') && $('#setRegPassword').value.trim()) || '',
},
});
const bad = (r.proxyBad || []).length;
toast(`已保存${r.proxySaved ? `:代理 ${r.proxyCount} 条` : ''}${bad ? `(忽略无效 ${bad} 行)` : ''}${r.smsSaved ? ',接码已更新' : ''}${r.mailboxSaved ? ',邮箱池设置已更新' : ''}${r.cloakSaved ? ',浏览器KEY已更新' : ''}`, 'ok');
$('#setProxyCount').textContent = `共 ${r.proxyCount || 0} 条`;
closeSettings();
} catch (e) { toast('保存失败:' + e.message, 'err'); }
}
async function testMailbox(email) {
email = (email || '').trim();
if (!email) { toast('请选择一个邮箱', 'err'); return; }
const box = $('#mbTestResult');
box.hidden = false;
box.innerHTML = '<div class="am-mb-loading">正在测试收件…</div>';
try {
const r = await api('POST', '/api/mailbox/test', { email });
if (!r.ok) { box.innerHTML = `<div class="am-mb-empty">测试失败:${escapeHtml(r.error || '未知错误')}</div>`; return; }
const msgs = r.messages || [];
const head = `<div class="am-mb-head">✓ 连接成功(${escapeHtml(r.type === 'api' ? '取件API' : (r.host || 'IMAP'))}),最新 ${msgs.length} 封</div>`;
const rows = msgs.length ? msgs.map(m => `
<div class="am-mb-item">
<div class="am-mb-subj">${escapeHtml(m.subject || '(无主题)')}</div>
<div class="am-mb-meta">${escapeHtml(m.from || '')} ${m.date ? '· ' + escapeHtml(m.date) : ''}</div>
${m.code ? `<div class="am-mb-code">验证码 <b>${escapeHtml(m.code)}</b> <button class="op-link" data-copy="${escapeHtml(m.code)}">复制</button></div>` : ''}
</div>`).join('') : '<div class="am-mb-empty">收件箱暂无邮件</div>';
box.innerHTML = head + rows;
} catch (e) { box.innerHTML = `<div class="am-mb-empty">测试失败:${escapeHtml(e.message)}</div>`; }
}
// ==========================================================================
// PLUS 邮箱(保留原有:查收 / 拿RT / 删除封号)
// ==========================================================================
function plMatchFilter(a) {
switch (state.plFilter) {
case 'rt': return !!a.codexRt;
case 'nort': return !a.codexRt && !(a.banned === true || a.codexStatus === 'banned');
case 'banned': return a.banned === true || a.codexStatus === 'banned';
default: return true;
}
}
function plFiltered() {
const q = state.plSearch.trim().toLowerCase();
return state.plus.filter((a) => {
if (!plMatchFilter(a)) return false;
if (!q) return true;
return a.email.toLowerCase().includes(q) || domainOf(a.email).toLowerCase().includes(q) || (a.note || '').toLowerCase().includes(q);
});
}
function plPageItems() { return paginate(plFiltered(), state.plPage, state.plPageSize).items; }
async function loadPlus() {
try { const { accounts } = await api('GET', '/api/plus'); state.plus = accounts || []; renderPlus(); }
catch (e) { toast('加载失败:' + e.message, 'err'); }
}
function renderPlus() {
const list = plFiltered();
const pg = paginate(list, state.plPage, state.plPageSize);
state.plPage = pg.page;
const tbody = $('#plRows');
tbody.innerHTML = '';
$('#plEmpty').hidden = state.plus.length !== 0;
pg.items.forEach((a, i) => {
const key = a.email.toLowerCase();
const isSel = state.plSelected.has(key);
const showPw = state.plShowPw.has(key);
const absIdx = pg.startIdx + i + 1;
const rtHtml = a.codexRt
? `<span class="rt-chip has" data-pl-copy-codexrt="${escapeHtml(a.email)}" title="点击复制 Codex Refresh Token">✔ 有</span>`
: (a.banned === true || a.codexStatus === 'banned')
? '<span class="pb-status invalid">⛔ 已封号</span>'
: '<span class="rt-chip none">— 无</span>';
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="col-check"><input type="checkbox" data-pl-check="${escapeHtml(a.email)}" ${isSel ? 'checked' : ''}></td>
<td class="col-idx">${absIdx}</td>
<td>
<div class="email-cell"><span class="text-mono">${escapeHtml(a.email)}</span>
<button class="icon-btn" data-copy="${escapeHtml(a.email)}" title="复制邮箱">⧉</button></div>
${a.note ? `<div class="muted" style="font-size:12px">${escapeHtml(a.note)}</div>` : ''}
</td>
<td>
<div class="pw-cell">
<span class="${showPw ? 'pw-text' : 'pw-mask'}">${showPw ? escapeHtml(a.password) : '••••••••'}</span>
<button class="icon-btn" data-pl-toggle-pw="${escapeHtml(a.email)}" title="显示/隐藏">${showPw ? '🙈' : '👁'}</button>
<button class="icon-btn" data-pl-copy-pw="${escapeHtml(a.email)}" title="复制密码">⧉</button>
</div>
</td>
<td><span class="muted">—</span></td>
<td>${rtHtml}</td>
<td class="col-ops">
<div class="row-ops">
<button class="op-link" data-pl-inbox="${escapeHtml(a.email)}">查收</button>
<button class="op-link danger" data-pl-del="${escapeHtml(a.email)}">删除</button>
</div>
</td>`;
tbody.appendChild(tr);
});
$('#plPageInfo').textContent = `第 ${pg.page} / ${pg.pages} 页,共 ${pg.total} 条`;
$('#plCheckAll').checked = plPageItems().length > 0 && plPageItems().every((a) => state.plSelected.has(a.email.toLowerCase()));
renderStats();
}
function localParsePlus(text) {
const out = [], seen = new Set();
for (const raw of String(text).split(/\r?\n/)) {
const line = raw.trim(); if (!line) continue;
let parts = line.includes('----') ? line.split('----') : line.includes('\t') ? line.split('\t') : line.includes('|') ? line.split('|') : line.split(/\s+/);
parts = parts.map((s) => s.trim()).filter(Boolean);
if (parts.length < 4) continue;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(parts[0])) continue;
const k = parts[0].toLowerCase(); if (seen.has(k)) continue; seen.add(k); out.push(parts[0]);
}
return out;
}
function updatePlParsePreview() { $('#plParsePreview').textContent = `识别到 ${localParsePlus($('#plImportText').value).length} 个有效账号`; }
function openPlImport() { $('#plImportText').value = ''; updatePlParsePreview(); $('#plImportModal').hidden = false; }
function closePlImport() { $('#plImportModal').hidden = true; }
async function doPlImport() {
const text = $('#plImportText').value;
if (!localParsePlus(text).length) return toast('未识别到有效账号', 'err');
try { const r = await api('POST', '/api/plus/import', { text }); closePlImport(); await loadPlus(); toast(`导入完成:新增 ${r.added},更新 ${r.updated}`, 'ok'); }
catch (e) { toast('导入失败:' + e.message, 'err'); }
}
async function plDelete(emails, msg) {
if (!emails.length) return;
if (!(await confirmDialog(msg || `确认删除选中的 ${emails.length} 个账号?此操作不可恢复。`, { title: '删除账号', okText: '删除', danger: true }))) return;
try {
const r = await api('DELETE', '/api/plus', { emails });
emails.forEach((e) => { const k = e.toLowerCase(); state.plSelected.delete(k); state.plCodes.delete(k); state.plStatusMap.delete(k); });
await loadPlus();
toast(`已删除 ${r.removed} 个账号`, 'ok');
} catch (e) { toast('删除失败:' + e.message, 'err'); }
}
function plDeleteBanned() {
const banned = state.plus.filter((a) => a.banned === true || a.codexStatus === 'banned');
if (!banned.length) return toast('没有已封号的账号');
plDelete(banned.map((a) => a.email), `检测到 ${banned.length} 个已封号账号,确认全部删除?`);
}
let rtPoll = null;
async function plGetRT() {
const targets = state.plSelected.size ? state.plus.filter((a) => state.plSelected.has(a.email.toLowerCase())) : plFiltered();
if (!targets.length) return toast('没有可拿 RT 的账号');
if (!(await confirmDialog(`将对 ${targets.length} 个账号复刻 Codex OAuth 授权流程换取 refresh_token。是否开始?`, { title: '批量拿 RT', okText: '开始' }))) return;
try {
const r = await api('POST', '/api/plus/get-rt', { emails: targets.map((a) => a.email) });
$('#rtPanel').hidden = false; $('#rtLogs').innerHTML = '';
toast(`已开始拿 RT ${r.started} 个账号`, 'ok');
startRtPolling();
} catch (e) { toast('启动失败:' + e.message, 'err'); }
}
function startRtPolling() {
if (rtPoll) clearInterval(rtPoll);
let ticks = 0;
const tick = async () => {
ticks++;
try {
const s = await api('GET', '/api/plus/rt-status');
$('#rtSummary').textContent = `进度 ${s.done}/${s.total} · 成功 ${s.ok} · 封号 ${s.banned} · 失败 ${s.fail}`;
const box = $('#rtLogs');
box.innerHTML = (s.logs || []).map((l) => `<div class="am-rt-line ${escapeHtml(l.level)}">${escapeHtml(fmtTime(l.ts))} ${l.account ? '[' + escapeHtml(l.account) + '] ' : ''}${escapeHtml(l.msg)}</div>`).join('');
box.scrollTop = box.scrollHeight;
if (state.activeTab === 'plus') await loadPlus();
if (!s.running && ticks > 1) { clearInterval(rtPoll); rtPoll = null; }
} catch { /* ignore */ }
if (ticks > 300 && rtPoll) { clearInterval(rtPoll); rtPoll = null; }
};
tick(); rtPoll = setInterval(tick, 3000);
}
async function plExport() {
const base = state.plSelected.size ? state.plus.filter((a) => state.plSelected.has(a.email.toLowerCase())) : plFiltered();
const withRt = base.filter((a) => typeof a.codexRt === 'string' && a.codexRt);
if (!withRt.length) return toast('没有可导出的账号(需先批量拿RT)');
try {
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const count = await downloadPost('/api/plus/export', { emails: withRt.map((a) => a.email) }, `sub2api_export_${ts}.json`);
toast(`已导出 ${count || withRt.length} 个账号(SUB2API 格式)`, 'ok');
} catch (e) { toast('导出失败:' + e.message, 'err'); }
}
// 查收
let currentInboxEmail = '';
function openInbox(email) { currentInboxEmail = email; $('#inboxAddr').textContent = email; $('#inboxBody').innerHTML = '<div class="drawer-loading">正在查收邮件…</div>'; $('#inboxDrawer').hidden = false; refreshInbox(); }
function closeInbox() { $('#inboxDrawer').hidden = true; currentInboxEmail = ''; }
async function refreshInbox() {
const email = currentInboxEmail; if (!email) return;
$('#inboxBody').innerHTML = '<div class="drawer-loading">正在查收邮件…</div>';
try {
const { messages } = await api('POST', '/api/plus/inbox', { email });
renderInbox(messages);
const withCode = (messages || []).find((m) => m.extracted_code);
if (withCode) state.plCodes.set(email.toLowerCase(), withCode.extracted_code);
} catch (e) { $('#inboxBody').innerHTML = `<div class="drawer-empty">${escapeHtml(e.message)}</div>`; toast('查收失败:' + e.message, 'err'); }
}
function renderInbox(messages) {
const body = $('#inboxBody');
if (!messages || !messages.length) { body.innerHTML = '<div class="drawer-empty">当前收件箱暂无邮件。<br>请在目标平台触发验证码后点击「刷新」。</div>'; return; }
body.innerHTML = '';
messages.forEach((m) => {
const item = document.createElement('div'); item.className = 'msg-item';
const bodyText = m.body || m.snippet || '';
item.innerHTML = `
<div class="msg-from">${escapeHtml(m.from_addr || '(未知发送者)')}</div>
<div class="msg-subject">${escapeHtml(m.subject || '(无主题)')}</div>
<div class="msg-time">${escapeHtml(fmtTime(m.received_at))}</div>
${m.extracted_code ? `<div class="msg-code"><span class="code-chip" data-copy-code="${escapeHtml(m.extracted_code)}">${escapeHtml(m.extracted_code)} ⧉</span></div>` : ''}
${bodyText ? `<div class="msg-body">${escapeHtml(bodyText)}</div>` : ''}`;
body.appendChild(item);
});
}
// ==========================================================================
// 事件绑定
// ==========================================================================
function bind() {
$('#btnSettings').addEventListener('click', openSettings);
$('#settingsClose').addEventListener('click', closeSettings);
$('#settingsCancel').addEventListener('click', closeSettings);
$('#btnSaveSettings').addEventListener('click', saveSettings);
document.querySelectorAll('.am-set-tab').forEach(b => b.addEventListener('click', () => setSettingsTab(b.dataset.setTab)));
$('#setSmsProvider').addEventListener('change', (e) => {
fillSmsFields(smsAllCfg[e.target.value] || {});
$('#smsBalanceResult').textContent = '';
});
$('#smsBalanceBtn').addEventListener('click', testSmsBalance);
$('#mbSplitGen').addEventListener('click', generateSplitAliases);
$('#mbToggleImport').addEventListener('click', () => {
const box = $('#mbImportBox');
box.hidden = !box.hidden;
if (!box.hidden) setTimeout(() => $('#setMailbox').focus(), 0);
});
$('#mbImportBtn').addEventListener('click', importMailbox);
$('#mbClearAll').addEventListener('click', clearAllMailbox);
$('#mbList').addEventListener('click', (e) => {
const t = e.target.closest('[data-mb-test],[data-mb-edit],[data-mb-del]');
if (!t) return;
if (t.dataset.mbTest) testMailbox(t.dataset.mbTest);
else if (t.dataset.mbEdit) editMailbox(t.dataset.mbEdit);
else if (t.dataset.mbDel) deleteMailbox(t.dataset.mbDel);
});
$('#mbEditSave').addEventListener('click', saveMbEdit);
$('#mbEditCancel').addEventListener('click', closeMbEdit);
$('#mbEditCloseX').addEventListener('click', closeMbEdit);
$('#mbEditModal').addEventListener('mousedown', (e) => { if (e.target === $('#mbEditModal')) closeMbEdit(); });
$('#mbTestResult').addEventListener('click', (e) => {
const c = e.target.closest('[data-copy]');
if (c) copyText(c.dataset.copy, '已复制验证码');
});
// GPT toolbar
$('#pbAutoRefresh').addEventListener('change', (e) => toggleAutoRefresh(e.target.checked));
$('#pbOpenedOnly').addEventListener('change', (e) => { state.pbOpenedOnly = e.target.checked; state.pbPage = 1; renderProbe(); });
// 协议注册:占位功能,仅提示(不接入任何注册后端)
$('#btnPbRefetch').addEventListener('click', refetchSelected);
if ($('#btnPbBindPhone')) $('#btnPbBindPhone').addEventListener('click', bindPhoneSelected);
$('#btnProtocolReg').addEventListener('click', openRegModal);
if ($('#regMode')) $('#regMode').addEventListener('change', syncRegMode);
$('#regCloseX').addEventListener('click', closeRegModal);
$('#regCancel').addEventListener('click', closeRegModal);
$('#regStart').addEventListener('click', startReg);
$('#regPanelMin').addEventListener('click', () => $('#regPanel').classList.toggle('min'));
$('#regPanelStop').addEventListener('click', stopReg);
$('#regPanelClear').addEventListener('click', clearRegLogs);
$('#regPanelClose').addEventListener('click', () => { $('#regPanel').hidden = true; });
$('#btnPbImport').addEventListener('click', openPbImport);
$('#pbImportText').addEventListener('input', updatePbParsePreview);
$('#btnDoPbImport').addEventListener('click', doPbImport);
$('#pbImportClose').addEventListener('click', closePbImport);
$('#pbImportCancel').addEventListener('click', closePbImport);
$('#btnPbDetectBatch').addEventListener('click', pbDetectBatch);
$('#btnPbExportAt').addEventListener('click', pbExportAt);
$('#btnPbExportSub').addEventListener('click', pbExportSub);
$('#btnPbExportPw').addEventListener('click', pbExportPw);
$('#btnPbDelSel').addEventListener('click', pbDeleteSelected);
$('#btnPbDelAll').addEventListener('click', pbDeleteAll);
$('#btnPbDelInvalid').addEventListener('click', () => pbDeleteByFilter('invalid'));
$('#btnPbDelExpired').addEventListener('click', () => pbDeleteByFilter('expired'));
initDropdowns();
$('#pbPageSize').addEventListener('change', (e) => { state.pbPageSize = Number(e.target.value); state.pbPage = 1; renderProbe(); });
$('#pbPagePrev').addEventListener('click', () => { state.pbPage -= 1; renderProbe(); });
$('#pbPageNext').addEventListener('click', () => { state.pbPage += 1; renderProbe(); });
$('#pbCheckAll').addEventListener('change', (e) => {
const items = pbPageItems();
if (e.target.checked) items.forEach((a) => state.probeSelected.add(a.id));
else items.forEach((a) => state.probeSelected.delete(a.id));
renderProbe();
});
$('#pbRows').addEventListener('change', (e) => {
const cb = e.target.closest('[data-pb-check]'); if (!cb) return;
cb.checked ? state.probeSelected.add(cb.dataset.pbCheck) : state.probeSelected.delete(cb.dataset.pbCheck);
renderStats();
$('#pbCheckAll').checked = pbPageItems().every((a) => state.probeSelected.has(a.id));
});
$('#pbRows').addEventListener('click', (e) => {
const t = e.target.closest('[data-pb-detect],[data-pb-edit],[data-pb-refresh],[data-pb-del],[data-pb-copy-at],[data-pb-copy-rt],[data-pb-copy-email],[data-pb-toggle-pw],[data-pb-copy-pw]');
if (!t) return;
const find = (id) => state.probe.find((x) => String(x.id) === String(id));
if (t.dataset.pbDetect) return pbDetectOne(t.dataset.pbDetect, t);
if (t.dataset.pbEdit) return openPbEdit(t.dataset.pbEdit);
if (t.dataset.pbRefresh) return pbRefreshOne(t.dataset.pbRefresh);
if (t.dataset.pbDel) return pbDeleteOne(t.dataset.pbDel);
if (t.dataset.pbCopyAt) return copyText(find(t.dataset.pbCopyAt).accessToken, '已复制 Access Token');
if (t.dataset.pbCopyRt) return copyText(find(t.dataset.pbCopyRt).refreshToken, '已复制 Refresh Token');
if (t.dataset.pbCopyEmail) return copyText(find(t.dataset.pbCopyEmail).email, '已复制邮箱');
if (t.dataset.pbTogglePw) { const id = t.dataset.pbTogglePw; state.probeShowPw.has(id) ? state.probeShowPw.delete(id) : state.probeShowPw.add(id); return renderProbe(); }
if (t.dataset.pbCopyPw) return copyText(find(t.dataset.pbCopyPw).password, '已复制密码');
});
$('#pbEditClose').addEventListener('click', closePbEdit);
$('#pbEditCancel').addEventListener('click', closePbEdit);
$('#btnSavePbEdit').addEventListener('click', savePbEdit);
// PLUS toolbar
$('#plSearch').addEventListener('input', (e) => { state.plSearch = e.target.value; state.plPage = 1; renderPlus(); });
$('#plFilter').addEventListener('change', (e) => { state.plFilter = e.target.value; state.plPage = 1; renderPlus(); });
$('#plCheckAll').addEventListener('change', (e) => {
const items = plPageItems();
if (e.target.checked) items.forEach((a) => state.plSelected.add(a.email.toLowerCase()));
else items.forEach((a) => state.plSelected.delete(a.email.toLowerCase()));
renderPlus();
});
$('#plPageSize').addEventListener('change', (e) => { state.plPageSize = Number(e.target.value); state.plPage = 1; renderPlus(); });
$('#plPagePrev').addEventListener('click', () => { state.plPage -= 1; renderPlus(); });
$('#plPageNext').addEventListener('click', () => { state.plPage += 1; renderPlus(); });
$('#plRows').addEventListener('change', (e) => {
const cb = e.target.closest('[data-pl-check]'); if (!cb) return;
const k = cb.dataset.plCheck.toLowerCase();
cb.checked ? state.plSelected.add(k) : state.plSelected.delete(k);
renderStats();
});
$('#plRows').addEventListener('click', (e) => {
const t = e.target.closest('[data-copy],[data-pl-toggle-pw],[data-pl-copy-pw],[data-pl-copy-codexrt],[data-pl-inbox],[data-pl-del]');
if (!t) return;
const find = (email) => state.plus.find((x) => x.email.toLowerCase() === String(email).toLowerCase());
if (t.dataset.copy) return copyText(t.dataset.copy, '已复制邮箱');
if (t.dataset.plTogglePw) { const k = t.dataset.plTogglePw.toLowerCase(); state.plShowPw.has(k) ? state.plShowPw.delete(k) : state.plShowPw.add(k); return renderPlus(); }
if (t.dataset.plCopyPw) return copyText(find(t.dataset.plCopyPw).password, '已复制密码');
if (t.dataset.plCopyCodexrt) return copyText(find(t.dataset.plCopyCodexrt).codexRt, '已复制 Codex RT');
if (t.dataset.plInbox) return openInbox(t.dataset.plInbox);
if (t.dataset.plDel) return plDelete([t.dataset.plDel]);
});
$('#btnPlImport').addEventListener('click', openPlImport);
$('#plImportText').addEventListener('input', updatePlParsePreview);
$('#btnDoPlImport').addEventListener('click', doPlImport);
$('#plImportClose').addEventListener('click', closePlImport);
$('#plImportCancel').addEventListener('click', closePlImport);
$('#btnPlGetRT').addEventListener('click', plGetRT);
$('#btnPlExport').addEventListener('click', plExport);
$('#btnPlDelete').addEventListener('click', () => { if (!state.plSelected.size) return toast('请先选择账号'); plDelete(Array.from(state.plSelected)); });
$('#btnPlDeleteBanned').addEventListener('click', plDeleteBanned);
$('#inboxClose').addEventListener('click', closeInbox);
$('#inboxMask').addEventListener('click', closeInbox);
$('#inboxRefresh').addEventListener('click', refreshInbox);
$('#inboxBody').addEventListener('click', (e) => { const c = e.target.closest('[data-copy-code]'); if (c) copyText(c.dataset.copyCode, '已复制验证码'); });
$('#rtPanelClose').addEventListener('click', () => { $('#rtPanel').hidden = true; if (rtPoll) { clearInterval(rtPoll); rtPoll = null; } });
}
bind();
switchTab('gpt');
resumeRegPanel();
})();