app.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. const $ = id => document.getElementById(id);
  2. let evtSource = null;
  3. let accPage = 1;
  4. const accPageSize = 20;
  5. let wasRunning = false;
  6. function escapeHtml(value) {
  7. return String(value ?? '')
  8. .replace(/&/g, '&')
  9. .replace(/</g, '&lt;')
  10. .replace(/>/g, '&gt;')
  11. .replace(/"/g, '&quot;')
  12. .replace(/'/g, '&#39;');
  13. }
  14. function truncate(value, limit = 90) {
  15. const text = String(value || '');
  16. return text.length > limit ? text.slice(0, limit - 1) + '…' : text;
  17. }
  18. function setState(text, cls = 'neutral') {
  19. const el = $('state');
  20. el.textContent = text;
  21. el.className = 'chip ' + cls;
  22. }
  23. function fmtTime(ms) {
  24. if (!ms) return '';
  25. const date = new Date(Number(ms));
  26. if (Number.isNaN(date.getTime())) return '';
  27. const pad = n => String(n).padStart(2, '0');
  28. return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
  29. }
  30. function statusLabel(status) {
  31. return {
  32. registered: '已注册',
  33. paid: '已付款',
  34. plus: '已 Plus',
  35. cpa_uploaded: '已上传 CPA',
  36. cpa_skipped: 'CPA 跳过',
  37. failed: '失败',
  38. trial: '试用 / 待付款',
  39. plus_check_failed: 'Plus 校验失败',
  40. cpa_failed: 'CPA 上传失败',
  41. }[status] || status || '未知';
  42. }
  43. function statusTone(status) {
  44. if (['plus', 'cpa_uploaded'].includes(status)) return 'success';
  45. if (['registered', 'paid', 'trial', 'cpa_skipped'].includes(status)) return 'warning';
  46. if (['failed', 'plus_check_failed', 'cpa_failed'].includes(status)) return 'danger';
  47. return 'neutral';
  48. }
  49. function renderAccounts(accounts) {
  50. const tbody = $('accTable').querySelector('tbody');
  51. tbody.innerHTML = '';
  52. accounts.forEach((account, idx) => {
  53. const tr = document.createElement('tr');
  54. 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>`;
  55. tbody.appendChild(tr);
  56. });
  57. }
  58. async function refreshStatus() {
  59. try {
  60. const response = await fetch('/api/status');
  61. const data = await response.json();
  62. $('stageBox').textContent = data.stage || '空闲';
  63. renderAccounts(data.accounts || []);
  64. if (data.running) {
  65. setState('执行中', 'green');
  66. $('ssoGo').disabled = true;
  67. $('ssoStop').disabled = false;
  68. } else {
  69. $('ssoGo').disabled = false;
  70. $('ssoStop').disabled = true;
  71. if (data.state === 'done') setState('完成', 'green');
  72. else if (data.state === 'error') setState('异常', 'red');
  73. else if (data.state === 'stopped') setState('已停止', 'red');
  74. else setState('空闲', 'neutral');
  75. }
  76. if (wasRunning && !data.running) loadAccounts();
  77. wasRunning = !!data.running;
  78. } catch (_) {}
  79. }
  80. function startLogStream() {
  81. if (evtSource) evtSource.close();
  82. evtSource = new EventSource('/api/log');
  83. evtSource.onmessage = event => appendLog(event.data);
  84. }
  85. function appendLog(line) {
  86. if (!line) return;
  87. const log = $('log');
  88. log.textContent += line + '\n';
  89. log.scrollTop = log.scrollHeight;
  90. updateLogLineCount();
  91. }
  92. function updateLogLineCount() {
  93. const lines = $('log').textContent.split('\n').filter(Boolean).length;
  94. $('logLineCount').textContent = `${lines} 行`;
  95. }
  96. function showErrorPopover(target, text) {
  97. const popover = $('errorPopover');
  98. if (!text) return;
  99. popover.textContent = text;
  100. popover.hidden = false;
  101. const rect = target.getBoundingClientRect();
  102. const top = Math.min(window.innerHeight - 280, rect.bottom + 10);
  103. const left = Math.min(window.innerWidth - 540, Math.max(18, rect.left));
  104. popover.style.top = `${Math.max(18, top)}px`;
  105. popover.style.left = `${Math.max(18, left)}px`;
  106. }
  107. function hideErrorPopover() {
  108. $('errorPopover').hidden = true;
  109. }
  110. async function copyTextToClipboard(text) {
  111. if (navigator.clipboard && window.isSecureContext) {
  112. try {
  113. await navigator.clipboard.writeText(text);
  114. return;
  115. } catch (_) {}
  116. }
  117. const textarea = document.createElement('textarea');
  118. textarea.value = text;
  119. textarea.setAttribute('readonly', '');
  120. textarea.style.position = 'fixed';
  121. textarea.style.left = '-9999px';
  122. document.body.appendChild(textarea);
  123. textarea.select();
  124. document.execCommand('copy');
  125. document.body.removeChild(textarea);
  126. }
  127. async function loadAccounts(page) {
  128. if (page !== undefined) accPage = page;
  129. const status = $('accFilter').value || '';
  130. let url = `/api/accounts?page=${accPage}&pageSize=${accPageSize}`;
  131. if (status) url += '&status=' + encodeURIComponent(status);
  132. try {
  133. const response = await fetch(url);
  134. const data = await response.json();
  135. const total = data.total || 0;
  136. const totalPages = Math.max(1, Math.ceil(total / accPageSize));
  137. if (accPage > totalPages) accPage = totalPages;
  138. const list = $('accountList');
  139. const accounts = data.accounts || [];
  140. list.innerHTML = accounts.length ? '' : '<div class="account-empty">当前筛选条件下暂无账号</div>';
  141. accounts.forEach(account => list.appendChild(renderAccountCard(account)));
  142. bindAccountActions(list);
  143. renderPager(totalPages, total);
  144. } catch (error) {
  145. console.error(error);
  146. }
  147. }
  148. function renderAccountCard(account) {
  149. const card = document.createElement('article');
  150. const rawEmail = account.email || '';
  151. const email = escapeHtml(rawEmail);
  152. const cpa = escapeHtml(account.cpa_file_name || '');
  153. const lastError = String(account.last_error || '');
  154. const status = account.final_status || '';
  155. const updatedAt = fmtTime(account.updated_at || account.created_at) || '暂无';
  156. const detailBtn = `<button class="action-btn detail" data-email="${email}" data-action="detail">详情</button>`;
  157. 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>` : '';
  158. const failedStatuses = ['plus_check_failed','cpa_failed','failed','cpa_skipped','registered','paid'];
  159. const recheckBtn = failedStatuses.includes(account.final_status || '') ? `<button class="action-btn recheck" data-email="${email}" data-action="recheck">重新校验</button>` : '';
  160. const retryPayBtn = account.can_retry_payment ? `<button class="action-btn retry-pay" data-email="${email}" data-action="retry-pay">重新付款</button>` : '';
  161. const trialHtml = account.trial_eligible ? '<span class="mini-chip">试用</span>' : '';
  162. const errorHtml = lastError
  163. ? `<button class="error-summary" data-error="${escapeHtml(lastError)}" type="button">${escapeHtml(truncate(lastError, 72))}</button>`
  164. : '';
  165. card.className = `account-card${lastError ? ' has-error' : ''}`;
  166. card.innerHTML = `
  167. <div class="account-identity">
  168. <span class="account-email" title="${email}">${email}</span>
  169. <div class="account-badges">
  170. <span class="status-chip ${statusTone(status)}">${escapeHtml(statusLabel(status))}</span>
  171. ${trialHtml}
  172. </div>
  173. </div>
  174. <div class="account-meta">
  175. <div class="meta-item">
  176. <span class="meta-label">PLAN</span>
  177. <span class="meta-value">${escapeHtml(account.plan_type || '暂无')}</span>
  178. </div>
  179. <div class="meta-item">
  180. <span class="meta-label">更新时间</span>
  181. <span class="meta-value">${escapeHtml(updatedAt)}</span>
  182. </div>
  183. <div class="meta-item">
  184. <span class="meta-label">CPA 文件</span>
  185. <span class="meta-value" title="${cpa}">${cpa || '未生成'}</span>
  186. </div>
  187. </div>
  188. <div class="account-actions">${detailBtn}${recheckBtn}${retryPayBtn}${downloadBtn}</div>
  189. ${errorHtml ? `<div class="account-error">${errorHtml}</div>` : ''}
  190. `;
  191. return card;
  192. }
  193. function bindAccountActions(root) {
  194. root.querySelectorAll('button[data-action="detail"]').forEach(btn => btn.addEventListener('click', () => loadAccountDetail(btn.dataset.email)));
  195. root.querySelectorAll('button[data-action="recheck"]').forEach(btn => btn.addEventListener('click', () => triggerRecheck(btn)));
  196. root.querySelectorAll('button[data-action="retry-pay"]').forEach(btn => btn.addEventListener('click', () => triggerRetryPayment(btn)));
  197. root.querySelectorAll('.error-summary').forEach(btn => {
  198. btn.addEventListener('mouseenter', () => showErrorPopover(btn, btn.dataset.error));
  199. btn.addEventListener('focus', () => showErrorPopover(btn, btn.dataset.error));
  200. btn.addEventListener('click', event => {
  201. event.stopPropagation();
  202. showErrorPopover(btn, btn.dataset.error);
  203. });
  204. btn.addEventListener('mouseleave', hideErrorPopover);
  205. btn.addEventListener('blur', hideErrorPopover);
  206. });
  207. }
  208. function renderPager(totalPages, total) {
  209. const box = $('dbPager');
  210. box.innerHTML = '';
  211. const info = document.createElement('span');
  212. info.className = 'info';
  213. info.textContent = `共 ${total} 条 · 第 ${accPage}/${totalPages} 页`;
  214. box.appendChild(info);
  215. const addBtn = (label, page, disabled) => {
  216. const btn = document.createElement('button');
  217. btn.textContent = label;
  218. btn.disabled = disabled;
  219. if (page === accPage) btn.classList.add('active');
  220. if (!disabled) btn.addEventListener('click', () => loadAccounts(page));
  221. box.appendChild(btn);
  222. };
  223. addBtn('«', 1, accPage <= 1);
  224. addBtn('‹', accPage - 1, accPage <= 1);
  225. for (let i = Math.max(1, accPage - 2); i <= Math.min(totalPages, accPage + 2); i++) addBtn(String(i), i, false);
  226. addBtn('›', accPage + 1, accPage >= totalPages);
  227. addBtn('»', totalPages, accPage >= totalPages);
  228. }
  229. async function loadAccountDetail(email) {
  230. try {
  231. const response = await fetch('/api/account/' + encodeURIComponent(email));
  232. $('accDetail').textContent = JSON.stringify(await response.json(), null, 2);
  233. } catch (error) {
  234. $('accDetail').textContent = String(error);
  235. }
  236. }
  237. async function triggerRecheck(btn) {
  238. const email = btn.dataset.email;
  239. if (!email) return;
  240. const original = btn.textContent;
  241. btn.disabled = true;
  242. btn.textContent = '校验中...';
  243. try {
  244. const response = await fetch('/api/account/' + encodeURIComponent(email) + '/recheck', {method: 'POST'});
  245. const data = await response.json();
  246. btn.textContent = data.ok ? '✓ 已处理' : '✗ 失败';
  247. setTimeout(() => loadAccounts(), 1200);
  248. } catch (error) {
  249. alert(error.message || String(error));
  250. btn.disabled = false;
  251. btn.textContent = original;
  252. }
  253. }
  254. async function triggerRetryPayment(btn) {
  255. const email = btn.dataset.email;
  256. if (!email) return;
  257. if (!confirm(`确认为 ${email} 重新发起付款流程?`)) return;
  258. const original = btn.textContent;
  259. btn.disabled = true;
  260. btn.textContent = '提交中...';
  261. try {
  262. const response = await fetch('/api/account/' + encodeURIComponent(email) + '/retry_payment', {method: 'POST'});
  263. const data = await response.json();
  264. btn.textContent = data.ok ? `✓ 已提交 ${data.task_id || ''}` : `✗ ${data.error || '失败'}`;
  265. setTimeout(() => loadAccounts(), 1500);
  266. } catch (error) {
  267. alert(error.message || String(error));
  268. btn.disabled = false;
  269. btn.textContent = original;
  270. }
  271. }
  272. function setupNavigation() {
  273. document.querySelectorAll('.side-nav a').forEach(link => {
  274. link.addEventListener('click', () => {
  275. document.querySelectorAll('.side-nav a').forEach(item => item.classList.remove('active'));
  276. link.classList.add('active');
  277. });
  278. });
  279. }
  280. function setupLogDock() {
  281. $('toggleLogDock').addEventListener('click', () => {
  282. const dock = $('logDock');
  283. const collapsed = dock.classList.toggle('collapsed');
  284. $('toggleLogDock').textContent = collapsed ? '展开' : '收起';
  285. });
  286. $('copyLog').addEventListener('click', async () => {
  287. const text = $('log').textContent || '';
  288. await copyTextToClipboard(text);
  289. $('copyLog').textContent = '已复制';
  290. setTimeout(() => $('copyLog').textContent = '复制', 1200);
  291. });
  292. $('clearLog').addEventListener('click', () => {
  293. $('log').textContent = '';
  294. updateLogLineCount();
  295. });
  296. }
  297. function setupActions() {
  298. $('ssoSave').addEventListener('click', saveSsoConfig);
  299. $('ssoGo').addEventListener('click', startSsoRun);
  300. $('ssoStop').addEventListener('click', () => fetch('/api/stop', {method: 'POST'}));
  301. $('refreshAccounts').addEventListener('click', () => loadAccounts());
  302. $('accFilter').addEventListener('change', () => loadAccounts(1));
  303. document.addEventListener('click', event => {
  304. if (!event.target.closest('.error-summary')) hideErrorPopover();
  305. });
  306. }
  307. // ---------------------------------------------------------------------------
  308. // SSO 快捷注册
  309. // ---------------------------------------------------------------------------
  310. const SSO_CONFIG_KEY = 'ssoConfig';
  311. const SSO_FIELDS = [
  312. ['sso_account_count','account_count','int'],
  313. ['sso_mail_domain','sso_mail_domain','str'],
  314. ['sso_cpa_url','cpa_url','str'],
  315. ['sso_cpa_management_key','cpa_management_key','str'],
  316. ['sso_headless','headless','bool'],
  317. ['sso_proxy_url','proxy_url','str'],
  318. ];
  319. function getDefaultSsoConfig() {
  320. return {
  321. account_count: 1,
  322. sso_mail_domain: 'aef.claudeai.life',
  323. cpa_url: '',
  324. cpa_management_key: '',
  325. headless: false,
  326. proxy_url: '',
  327. };
  328. }
  329. function loadSsoConfigFromStorage() {
  330. try {
  331. const raw = localStorage.getItem(SSO_CONFIG_KEY);
  332. if (raw) return { ...getDefaultSsoConfig(), ...JSON.parse(raw) };
  333. } catch (_) {}
  334. return getDefaultSsoConfig();
  335. }
  336. function saveSsoConfigToStorage(cfg) {
  337. try {
  338. localStorage.setItem(SSO_CONFIG_KEY, JSON.stringify(cfg));
  339. } catch (_) {}
  340. }
  341. function fillSsoForm(cfg) {
  342. for (const [domId, key, kind] of SSO_FIELDS) {
  343. const el = $(domId);
  344. if (!el || cfg[key] === undefined) continue;
  345. el.value = kind === 'bool' ? (cfg[key] ? 'true' : 'false') : cfg[key];
  346. }
  347. }
  348. function readSsoForm() {
  349. const out = {};
  350. for (const [domId, key, kind] of SSO_FIELDS) {
  351. const el = $(domId);
  352. if (!el) continue;
  353. let value = el.value;
  354. if (kind === 'int') value = Number(value) || 0;
  355. else if (kind === 'bool') value = value === 'true' || value === true;
  356. out[key] = value;
  357. }
  358. return out;
  359. }
  360. function saveSsoConfig() {
  361. const cfg = { ...loadSsoConfigFromStorage(), ...readSsoForm() };
  362. saveSsoConfigToStorage(cfg);
  363. fillSsoForm(cfg);
  364. }
  365. async function startSsoRun() {
  366. saveSsoConfig();
  367. const cfg = loadSsoConfigFromStorage();
  368. const count = Number(cfg.account_count) || 0;
  369. if (count < 1) {
  370. alert('模板账号数量必须 >= 1');
  371. return;
  372. }
  373. if (!cfg.cpa_url || !cfg.cpa_management_key) {
  374. alert('CPA 地址和管理密钥为必填项');
  375. return;
  376. }
  377. $('log').textContent = '';
  378. updateLogLineCount();
  379. $('ssoGo').disabled = true;
  380. setState('启动中', 'neutral');
  381. try {
  382. const response = await fetch('/api/start-sso', {
  383. method: 'POST',
  384. headers: {'Content-Type': 'application/json'},
  385. body: JSON.stringify({
  386. account_count: count,
  387. sso_mail_domain: cfg.sso_mail_domain,
  388. cpa_url: cfg.cpa_url,
  389. cpa_management_key: cfg.cpa_management_key,
  390. headless: cfg.headless,
  391. proxy_url: cfg.proxy_url,
  392. }),
  393. });
  394. const data = await response.json();
  395. if (data.error) {
  396. alert(data.error);
  397. $('ssoGo').disabled = false;
  398. setState('空闲', 'neutral');
  399. }
  400. } catch (error) {
  401. alert(error.message || String(error));
  402. $('ssoGo').disabled = false;
  403. }
  404. }
  405. function boot() {
  406. setupNavigation();
  407. setupLogDock();
  408. setupActions();
  409. startLogStream();
  410. fillSsoForm(loadSsoConfigFromStorage());
  411. loadAccounts();
  412. refreshStatus();
  413. setInterval(refreshStatus, 1500);
  414. }
  415. document.addEventListener('DOMContentLoaded', boot);