| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451 |
- const $ = id => document.getElementById(id);
- let evtSource = null;
- let accPage = 1;
- const accPageSize = 20;
- let wasRunning = false;
- function escapeHtml(value) {
- return String(value ?? '')
- .replace(/&/g, '&')
- .replace(/</g, '<')
- .replace(/>/g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
- }
- function truncate(value, limit = 90) {
- const text = String(value || '');
- return text.length > limit ? text.slice(0, limit - 1) + '…' : text;
- }
- function setState(text, cls = 'neutral') {
- const el = $('state');
- el.textContent = text;
- el.className = 'chip ' + cls;
- }
- function fmtTime(ms) {
- if (!ms) return '';
- const date = new Date(Number(ms));
- if (Number.isNaN(date.getTime())) return '';
- const pad = n => String(n).padStart(2, '0');
- return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
- }
- function statusLabel(status) {
- return {
- registered: '已注册',
- paid: '已付款',
- plus: '已 Plus',
- cpa_uploaded: '已上传 CPA',
- cpa_skipped: 'CPA 跳过',
- failed: '失败',
- trial: '试用 / 待付款',
- plus_check_failed: 'Plus 校验失败',
- cpa_failed: 'CPA 上传失败',
- }[status] || status || '未知';
- }
- function statusTone(status) {
- if (['plus', 'cpa_uploaded'].includes(status)) return 'success';
- if (['registered', 'paid', 'trial', 'cpa_skipped'].includes(status)) return 'warning';
- if (['failed', 'plus_check_failed', 'cpa_failed'].includes(status)) return 'danger';
- return 'neutral';
- }
- function renderAccounts(accounts) {
- const tbody = $('accTable').querySelector('tbody');
- tbody.innerHTML = '';
- accounts.forEach((account, idx) => {
- const tr = document.createElement('tr');
- tr.innerHTML = `<td>${idx + 1}</td><td>${escapeHtml(account.email)}</td><td>${escapeHtml(account.stage)}</td><td>${escapeHtml(account.planType)}</td><td>${escapeHtml(account.cpaFile)}</td><td>${escapeHtml(truncate(account.error, 90))}</td>`;
- tbody.appendChild(tr);
- });
- }
- async function refreshStatus() {
- try {
- const response = await fetch('/api/status');
- const data = await response.json();
- $('stageBox').textContent = data.stage || '空闲';
- renderAccounts(data.accounts || []);
- if (data.running) {
- setState('执行中', 'green');
- $('ssoGo').disabled = true;
- $('ssoStop').disabled = false;
- } else {
- $('ssoGo').disabled = false;
- $('ssoStop').disabled = true;
- if (data.state === 'done') setState('完成', 'green');
- else if (data.state === 'error') setState('异常', 'red');
- else if (data.state === 'stopped') setState('已停止', 'red');
- else setState('空闲', 'neutral');
- }
- if (wasRunning && !data.running) loadAccounts();
- wasRunning = !!data.running;
- } catch (_) {}
- }
- function startLogStream() {
- if (evtSource) evtSource.close();
- evtSource = new EventSource('/api/log');
- evtSource.onmessage = event => appendLog(event.data);
- }
- function appendLog(line) {
- if (!line) return;
- const log = $('log');
- log.textContent += line + '\n';
- log.scrollTop = log.scrollHeight;
- updateLogLineCount();
- }
- function updateLogLineCount() {
- const lines = $('log').textContent.split('\n').filter(Boolean).length;
- $('logLineCount').textContent = `${lines} 行`;
- }
- function showErrorPopover(target, text) {
- const popover = $('errorPopover');
- if (!text) return;
- popover.textContent = text;
- popover.hidden = false;
- const rect = target.getBoundingClientRect();
- const top = Math.min(window.innerHeight - 280, rect.bottom + 10);
- const left = Math.min(window.innerWidth - 540, Math.max(18, rect.left));
- popover.style.top = `${Math.max(18, top)}px`;
- popover.style.left = `${Math.max(18, left)}px`;
- }
- function hideErrorPopover() {
- $('errorPopover').hidden = true;
- }
- async function copyTextToClipboard(text) {
- if (navigator.clipboard && window.isSecureContext) {
- try {
- await navigator.clipboard.writeText(text);
- return;
- } catch (_) {}
- }
- const textarea = document.createElement('textarea');
- textarea.value = text;
- textarea.setAttribute('readonly', '');
- textarea.style.position = 'fixed';
- textarea.style.left = '-9999px';
- document.body.appendChild(textarea);
- textarea.select();
- document.execCommand('copy');
- document.body.removeChild(textarea);
- }
- async function loadAccounts(page) {
- if (page !== undefined) accPage = page;
- const status = $('accFilter').value || '';
- let url = `/api/accounts?page=${accPage}&pageSize=${accPageSize}`;
- if (status) url += '&status=' + encodeURIComponent(status);
- try {
- const response = await fetch(url);
- const data = await response.json();
- const total = data.total || 0;
- const totalPages = Math.max(1, Math.ceil(total / accPageSize));
- if (accPage > totalPages) accPage = totalPages;
- const list = $('accountList');
- const accounts = data.accounts || [];
- list.innerHTML = accounts.length ? '' : '<div class="account-empty">当前筛选条件下暂无账号</div>';
- accounts.forEach(account => list.appendChild(renderAccountCard(account)));
- bindAccountActions(list);
- renderPager(totalPages, total);
- } catch (error) {
- console.error(error);
- }
- }
- function renderAccountCard(account) {
- const card = document.createElement('article');
- const rawEmail = account.email || '';
- const email = escapeHtml(rawEmail);
- const cpa = escapeHtml(account.cpa_file_name || '');
- const lastError = String(account.last_error || '');
- const status = account.final_status || '';
- const updatedAt = fmtTime(account.updated_at || account.created_at) || '暂无';
- const detailBtn = `<button class="action-btn detail" data-email="${email}" data-action="detail">详情</button>`;
- const downloadBtn = account.cpa_file_name ? `<a class="action-btn download" href="/api/account/${encodeURIComponent(rawEmail)}/cpa.json" target="_blank" rel="noopener" download>下载 CPA</a>` : '';
- const failedStatuses = ['plus_check_failed','cpa_failed','failed','cpa_skipped','registered','paid'];
- const recheckBtn = failedStatuses.includes(account.final_status || '') ? `<button class="action-btn recheck" data-email="${email}" data-action="recheck">重新校验</button>` : '';
- const retryPayBtn = account.can_retry_payment ? `<button class="action-btn retry-pay" data-email="${email}" data-action="retry-pay">重新付款</button>` : '';
- const trialHtml = account.trial_eligible ? '<span class="mini-chip">试用</span>' : '';
- const errorHtml = lastError
- ? `<button class="error-summary" data-error="${escapeHtml(lastError)}" type="button">${escapeHtml(truncate(lastError, 72))}</button>`
- : '';
- card.className = `account-card${lastError ? ' has-error' : ''}`;
- card.innerHTML = `
- <div class="account-identity">
- <span class="account-email" title="${email}">${email}</span>
- <div class="account-badges">
- <span class="status-chip ${statusTone(status)}">${escapeHtml(statusLabel(status))}</span>
- ${trialHtml}
- </div>
- </div>
- <div class="account-meta">
- <div class="meta-item">
- <span class="meta-label">PLAN</span>
- <span class="meta-value">${escapeHtml(account.plan_type || '暂无')}</span>
- </div>
- <div class="meta-item">
- <span class="meta-label">更新时间</span>
- <span class="meta-value">${escapeHtml(updatedAt)}</span>
- </div>
- <div class="meta-item">
- <span class="meta-label">CPA 文件</span>
- <span class="meta-value" title="${cpa}">${cpa || '未生成'}</span>
- </div>
- </div>
- <div class="account-actions">${detailBtn}${recheckBtn}${retryPayBtn}${downloadBtn}</div>
- ${errorHtml ? `<div class="account-error">${errorHtml}</div>` : ''}
- `;
- return card;
- }
- function bindAccountActions(root) {
- root.querySelectorAll('button[data-action="detail"]').forEach(btn => btn.addEventListener('click', () => loadAccountDetail(btn.dataset.email)));
- root.querySelectorAll('button[data-action="recheck"]').forEach(btn => btn.addEventListener('click', () => triggerRecheck(btn)));
- root.querySelectorAll('button[data-action="retry-pay"]').forEach(btn => btn.addEventListener('click', () => triggerRetryPayment(btn)));
- root.querySelectorAll('.error-summary').forEach(btn => {
- btn.addEventListener('mouseenter', () => showErrorPopover(btn, btn.dataset.error));
- btn.addEventListener('focus', () => showErrorPopover(btn, btn.dataset.error));
- btn.addEventListener('click', event => {
- event.stopPropagation();
- showErrorPopover(btn, btn.dataset.error);
- });
- btn.addEventListener('mouseleave', hideErrorPopover);
- btn.addEventListener('blur', hideErrorPopover);
- });
- }
- function renderPager(totalPages, total) {
- const box = $('dbPager');
- box.innerHTML = '';
- const info = document.createElement('span');
- info.className = 'info';
- info.textContent = `共 ${total} 条 · 第 ${accPage}/${totalPages} 页`;
- box.appendChild(info);
- const addBtn = (label, page, disabled) => {
- const btn = document.createElement('button');
- btn.textContent = label;
- btn.disabled = disabled;
- if (page === accPage) btn.classList.add('active');
- if (!disabled) btn.addEventListener('click', () => loadAccounts(page));
- box.appendChild(btn);
- };
- addBtn('«', 1, accPage <= 1);
- addBtn('‹', accPage - 1, accPage <= 1);
- for (let i = Math.max(1, accPage - 2); i <= Math.min(totalPages, accPage + 2); i++) addBtn(String(i), i, false);
- addBtn('›', accPage + 1, accPage >= totalPages);
- addBtn('»', totalPages, accPage >= totalPages);
- }
- async function loadAccountDetail(email) {
- try {
- const response = await fetch('/api/account/' + encodeURIComponent(email));
- $('accDetail').textContent = JSON.stringify(await response.json(), null, 2);
- } catch (error) {
- $('accDetail').textContent = String(error);
- }
- }
- async function triggerRecheck(btn) {
- const email = btn.dataset.email;
- if (!email) return;
- const original = btn.textContent;
- btn.disabled = true;
- btn.textContent = '校验中...';
- try {
- const response = await fetch('/api/account/' + encodeURIComponent(email) + '/recheck', {method: 'POST'});
- const data = await response.json();
- btn.textContent = data.ok ? '✓ 已处理' : '✗ 失败';
- setTimeout(() => loadAccounts(), 1200);
- } catch (error) {
- alert(error.message || String(error));
- btn.disabled = false;
- btn.textContent = original;
- }
- }
- async function triggerRetryPayment(btn) {
- const email = btn.dataset.email;
- if (!email) return;
- if (!confirm(`确认为 ${email} 重新发起付款流程?`)) return;
- const original = btn.textContent;
- btn.disabled = true;
- btn.textContent = '提交中...';
- try {
- const response = await fetch('/api/account/' + encodeURIComponent(email) + '/retry_payment', {method: 'POST'});
- const data = await response.json();
- btn.textContent = data.ok ? `✓ 已提交 ${data.task_id || ''}` : `✗ ${data.error || '失败'}`;
- setTimeout(() => loadAccounts(), 1500);
- } catch (error) {
- alert(error.message || String(error));
- btn.disabled = false;
- btn.textContent = original;
- }
- }
- function setupNavigation() {
- document.querySelectorAll('.side-nav a').forEach(link => {
- link.addEventListener('click', () => {
- document.querySelectorAll('.side-nav a').forEach(item => item.classList.remove('active'));
- link.classList.add('active');
- });
- });
- }
- function setupLogDock() {
- $('toggleLogDock').addEventListener('click', () => {
- const dock = $('logDock');
- const collapsed = dock.classList.toggle('collapsed');
- $('toggleLogDock').textContent = collapsed ? '展开' : '收起';
- });
- $('copyLog').addEventListener('click', async () => {
- const text = $('log').textContent || '';
- await copyTextToClipboard(text);
- $('copyLog').textContent = '已复制';
- setTimeout(() => $('copyLog').textContent = '复制', 1200);
- });
- $('clearLog').addEventListener('click', () => {
- $('log').textContent = '';
- updateLogLineCount();
- });
- }
- function setupActions() {
- $('ssoSave').addEventListener('click', saveSsoConfig);
- $('ssoGo').addEventListener('click', startSsoRun);
- $('ssoStop').addEventListener('click', () => fetch('/api/stop', {method: 'POST'}));
- $('refreshAccounts').addEventListener('click', () => loadAccounts());
- $('accFilter').addEventListener('change', () => loadAccounts(1));
- document.addEventListener('click', event => {
- if (!event.target.closest('.error-summary')) hideErrorPopover();
- });
- }
- // ---------------------------------------------------------------------------
- // SSO 快捷注册
- // ---------------------------------------------------------------------------
- const SSO_CONFIG_KEY = 'ssoConfig';
- const SSO_FIELDS = [
- ['sso_account_count','account_count','int'],
- ['sso_mail_domain','sso_mail_domain','str'],
- ['sso_cpa_url','cpa_url','str'],
- ['sso_cpa_management_key','cpa_management_key','str'],
- ['sso_headless','headless','bool'],
- ['sso_proxy_url','proxy_url','str'],
- ];
- function getDefaultSsoConfig() {
- return {
- account_count: 1,
- sso_mail_domain: 'aef.claudeai.life',
- cpa_url: '',
- cpa_management_key: '',
- headless: false,
- proxy_url: '',
- };
- }
- function loadSsoConfigFromStorage() {
- try {
- const raw = localStorage.getItem(SSO_CONFIG_KEY);
- if (raw) return { ...getDefaultSsoConfig(), ...JSON.parse(raw) };
- } catch (_) {}
- return getDefaultSsoConfig();
- }
- function saveSsoConfigToStorage(cfg) {
- try {
- localStorage.setItem(SSO_CONFIG_KEY, JSON.stringify(cfg));
- } catch (_) {}
- }
- function fillSsoForm(cfg) {
- for (const [domId, key, kind] of SSO_FIELDS) {
- const el = $(domId);
- if (!el || cfg[key] === undefined) continue;
- el.value = kind === 'bool' ? (cfg[key] ? 'true' : 'false') : cfg[key];
- }
- }
- function readSsoForm() {
- const out = {};
- for (const [domId, key, kind] of SSO_FIELDS) {
- const el = $(domId);
- if (!el) continue;
- let value = el.value;
- if (kind === 'int') value = Number(value) || 0;
- else if (kind === 'bool') value = value === 'true' || value === true;
- out[key] = value;
- }
- return out;
- }
- function saveSsoConfig() {
- const cfg = { ...loadSsoConfigFromStorage(), ...readSsoForm() };
- saveSsoConfigToStorage(cfg);
- fillSsoForm(cfg);
- }
- async function startSsoRun() {
- saveSsoConfig();
- const cfg = loadSsoConfigFromStorage();
- const count = Number(cfg.account_count) || 0;
- if (count < 1) {
- alert('模板账号数量必须 >= 1');
- return;
- }
- if (!cfg.cpa_url || !cfg.cpa_management_key) {
- alert('CPA 地址和管理密钥为必填项');
- return;
- }
- $('log').textContent = '';
- updateLogLineCount();
- $('ssoGo').disabled = true;
- setState('启动中', 'neutral');
- try {
- const response = await fetch('/api/start-sso', {
- method: 'POST',
- headers: {'Content-Type': 'application/json'},
- body: JSON.stringify({
- account_count: count,
- sso_mail_domain: cfg.sso_mail_domain,
- cpa_url: cfg.cpa_url,
- cpa_management_key: cfg.cpa_management_key,
- headless: cfg.headless,
- proxy_url: cfg.proxy_url,
- }),
- });
- const data = await response.json();
- if (data.error) {
- alert(data.error);
- $('ssoGo').disabled = false;
- setState('空闲', 'neutral');
- }
- } catch (error) {
- alert(error.message || String(error));
- $('ssoGo').disabled = false;
- }
- }
- function boot() {
- setupNavigation();
- setupLogDock();
- setupActions();
- startLogStream();
- fillSsoForm(loadSsoConfigFromStorage());
- loadAccounts();
- refreshStatus();
- setInterval(refreshStatus, 1500);
- }
- document.addEventListener('DOMContentLoaded', boot);
|