function notice(title,message,kind){ const box=document.createElement('div'); box.className='toast'+(kind==='err'?' err':''); box.innerHTML='
'+esc(title)+'
'+(message?'
'+esc(message)+'
':''); $('#toasts').appendChild(box); setTimeout(()=>{ box.style.opacity='0'; box.style.transition='opacity .25s'; }, 2600); setTimeout(()=>box.remove(), 3000); } const busyMap=new Map(); const busyJob=new Map(); function clickBtn(ev){ const t=ev&&(ev.currentTarget||ev.target); if(!t||!t.closest) return null; return t.closest('button')||(t.tagName==='BUTTON'?t:null); } function applyBtnBusy(el, text){ if(!el) return; if(!el.dataset.label) el.dataset.label=el.textContent; el.disabled=true; el.classList.add('busy'); el.innerHTML=''+esc(text||el.dataset.label||'...'); } function markBtnBusy(el, text){ const key=(el&&el.getAttribute('data-busy'))||''; if(key) busyMap.set(key, text||'...'); applyBtnBusy(el, text); return key; } function unmarkBtnBusy(key, el){ if(key){ busyMap.delete(key); busyJob.delete(key); } const sel=key?'[data-busy="'+String(key).replace(/"/g,'')+'"]':''; const nodes=sel?document.querySelectorAll(sel):(el?[el]:[]); nodes.forEach(btn=>{ btn.disabled=false; btn.classList.remove('busy'); if(btn.dataset.label) btn.textContent=btn.dataset.label; }); } function restoreBusyButtons(){ busyMap.forEach((text,key)=>{ document.querySelectorAll('[data-busy="'+String(key).replace(/"/g,'')+'"]').forEach(el=>applyBtnBusy(el,text)); }); } function jobStepText(j){ if(!j) return ''; let raw=j.result; if(!raw) return j.status==='running'?'执行中':(j.status==='queued'?'排队':''); if(typeof raw==='string'){ try{ raw=JSON.parse(raw); }catch(e){ return ''; } } return (raw && (raw.step || raw.progress)) || ''; } function watchAccountJob(accountId, kind, jobId, title){ const key=kind+'-'+accountId; if(!jobId) return; busyMap.set(key, title||'...'); busyJob.set(key, jobId); restoreBusyButtons(); (async()=>{ const t0=Date.now(); try{ while(Date.now()-t0<15*60*1000){ const j=await api('/api/jobs/'+jobId); const step=jobStepText(j); if(step) busyMap.set(key, (title||'任务')+' · '+step); restoreBusyButtons(); if(j && j.status==='challenge_required' && (j.challenge_type==='otp' || j.challenge_type==='3ds' || j.challenge_type==='2sv' || j.challenge_type==='prompt')){ unmarkBtnBusy(key, null); await solve(jobId); return; } if(j && jobDone(j)){ unmarkBtnBusy(key, null); const ok=j.status==='succeeded'; notice(ok?((title||'任务')+'成功'):((title||'任务')+'失败'), j.error||('任务 #'+jobId), ok?'':'err'); render(); return; } await new Promise(r=>setTimeout(r, 1200)); } unmarkBtnBusy(key, null); notice('仍在处理', '任务 #'+jobId+' 未结束,可稍后在任务记录查看', 'err'); }catch(e){ unmarkBtnBusy(key, null); notice('等待失败', String(e), 'err'); } })(); } function watchVerifyButton(btn, jobId){ if(!btn || !jobId) return; (async()=>{ const t0=Date.now(); try{ while(Date.now()-t0<15*60*1000){ if(!btn.isConnected) return; const j=await api('/api/jobs/'+jobId); const step=jobStepText(j); applyBtnBusy(btn, step||'验证中'); if(j && j.status==='challenge_required' && (j.challenge_type==='otp' || j.challenge_type==='3ds' || j.challenge_type==='2sv' || j.challenge_type==='prompt')){ unmarkBtnBusy('', btn); await solve(jobId); return; } if(j && jobDone(j)){ unmarkBtnBusy('', btn); const ok=j.status==='succeeded'; notice(ok?'验证成功':'验证失败', j.error||('任务 #'+jobId), ok?'':'err'); render(); return; } await new Promise(r=>setTimeout(r, 1200)); } unmarkBtnBusy('', btn); notice('仍在处理', '任务 #'+jobId+' 未结束,可稍后在任务记录查看', 'err'); }catch(e){ unmarkBtnBusy('', btn); notice('等待失败', String(e), 'err'); } })(); } function jobDone(j){ const st=(j&&j.status)||''; return st==='succeeded'||st==='failed'||st==='cancelled'; } async function waitJob(id, title, el){ if(!id) return null; const key=markBtnBusy(el, title||'...'); const t0=Date.now(); try{ while(Date.now()-t0<15*60*1000){ const j=await api('/api/jobs/'+id); if(j && jobDone(j)){ unmarkBtnBusy(key, el); const ok=j.status==='succeeded'; notice(ok?((title||'任务')+'成功'):((title||'任务')+'失败'), j.error||('任务 #'+id), ok?'':'err'); render(); return j; } await new Promise(r=>setTimeout(r, 1200)); } unmarkBtnBusy(key, el); notice('仍在处理', '任务 #'+id+' 未结束,可稍后在任务记录查看', 'err'); return null; }catch(e){ unmarkBtnBusy(key, el); notice('等待失败', String(e), 'err'); return null; } } async function waitJobs(ids, title, el){ ids=(ids||[]).filter(Boolean); if(!ids.length) return []; if(ids.length===1) return [await waitJob(ids[0], title, el)]; const key=markBtnBusy(el, (title||'...')+' · '+ids.length); const t0=Date.now(); const left=new Set(ids); const out=[]; try{ while(left.size && Date.now()-t0<15*60*1000){ for(const id of [...left]){ const j=await api('/api/jobs/'+id); if(j && jobDone(j)){ left.delete(id); out.push(j); } } if(left.size) await new Promise(r=>setTimeout(r, 1500)); } unmarkBtnBusy(key, el); const ok=out.filter(j=>j.status==='succeeded').length; notice((title||'任务')+'完成', ok+' 成功 / '+(ids.length-ok)+' 未成功'); render(); return out; }catch(e){ unmarkBtnBusy(key, el); notice('等待失败', String(e), 'err'); return out; } } function topModal(){ const stack=$('#modalStack'); const stacked=stack && stack.querySelector('.modal-mask'); return stacked ? stack : $('#modalRoot'); } function bindMaskClose(root, close){ if(!root || !close) return; const mask=root.querySelector('.modal-mask'); if(!mask || mask.dataset.maskClose) return; mask.dataset.maskClose='1'; mask.addEventListener('mousedown', e=>{ if(e.target!==mask) return; if(root!==topModal()) return; close(); }); } function modal(opts){ return new Promise(resolve=>{ const root=opts.stack?$('#modalStack'):$('#modalRoot'); const okId=opts.stack?'sOk':'mOk'; const cancelId=opts.stack?'sCancel':'mCancel'; const fields=opts.fields||[]; root.innerHTML=''; const finish=v=>{ if(v && opts.onOk) opts.onOk(); root.innerHTML=''; resolve(v); }; const okBtn=root.querySelector('#'+okId); okBtn.onclick=ev=>{ if(ev){ ev.preventDefault(); ev.stopPropagation(); } if(!fields.length) return finish(true); const values=fields.map((_,i)=>$('#mf'+i).value); finish(fields.length===1?values[0]:values); }; const cancel=root.querySelector('#'+cancelId); if(cancel) cancel.onclick=ev=>{ if(ev){ ev.preventDefault(); ev.stopPropagation(); } finish(null); }; const qt=$('#quickTriple'); if(qt){ const fill=()=>{ const got=parseCardTriple(qt.value); if(!got) return; if($('#mf0')) $('#mf0').value=got.pan; if($('#mf1')) $('#mf1').value=got.exp; if($('#mf2')) $('#mf2').value=got.cvc; }; qt.addEventListener('input', fill); qt.addEventListener('paste', ()=>setTimeout(fill, 0)); qt.addEventListener('keydown', e=>{ if(e.key==='Enter'){ e.preventDefault(); fill(); } }); } const first=qt || root.querySelector('input,textarea'); if(first){ first.focus(); first.select?.(); first.addEventListener('keydown', e=>{ if(e.key==='Enter' && e.target!==qt && e.target.tagName!=='TEXTAREA') okBtn.click(); }); } const onKey=e=>{ if(e.key==='Escape'){ e.preventDefault(); e.stopPropagation(); document.removeEventListener('keydown', onKey); finish(null); } }; document.addEventListener('keydown', onKey); bindMaskClose(root, ()=>finish(null)); }); } function parseCardTriple(raw){ const m=String(raw||'').trim().match(/^\s*(\d{12,19})\s*(?:-{2,}|—+|–+|\s+)\s*(0?[1-9]|1[0-2])\s*\/\s*((?:20)?\d{2})\s*(?:-{2,}|—+|–+|\s+)\s*(\d{3,4})\s*$/); if(!m) return null; let yy=Number(m[3]); if(yy<100) yy+=2000; const mm=String(Number(m[2])).padStart(2,'0'); return {pan:m[1], exp:mm+'/'+String(yy).slice(-2), cvc:m[4]}; } const fail=(title,message)=>modal({title,message,okText:'知道了',cancelText:false,stack:!!$('#modalRoot .modal-mask')}); const ask=(title,label,value,placeholder,type)=>modal({title,fields:[{label,value:value||'',placeholder,type}],okText:'确定'}); async function api(p, opt={}){ const headers=Object.assign({'Content-Type':'application/json'}, opt.headers||{}); const r=await fetch(p, Object.assign({}, opt, {credentials:'same-origin', headers})); const text=await r.text(); let data={}; try{ data=text?JSON.parse(text):{}; }catch{ data={error:text||('HTTP '+r.status)}; } if(r.status===401 && !String(p).startsWith('/api/admin/login')){ await showLogin(); return data; } if(!r.ok && !data.error) data.error='HTTP '+r.status; return data; } function setPage(p, push){ if(!pagePaths[p]) p='accounts'; page=p; $('#pageTitle').textContent=titles[p]||p; document.querySelectorAll('.nav button, .mobile-nav button').forEach(b=>b.classList.toggle('on', b.dataset.p===p)); const path=pagePaths[p]; if(path && location.pathname!==path){ const url=path+(location.search||'')+(location.hash||''); if(push===false) history.replaceState({page:p}, '', url); else history.pushState({page:p}, '', url); } render(); } window.addEventListener('popstate', ()=>{ page=pageFromLocation(); $('#pageTitle').textContent=titles[page]||page; document.querySelectorAll('.nav button, .mobile-nav button').forEach(b=>b.classList.toggle('on', b.dataset.p===page)); render(); }); document.querySelectorAll('.nav button, .mobile-nav button').forEach(b=>{ if(!b.dataset.p) return; b.onclick=()=>setPage(b.dataset.p); }); function syncNav(){ const cur=pageFromLocation(); page=cur; const title=document.getElementById('pageTitle'); if(title) title.textContent=titles[cur]||cur; document.querySelectorAll('.nav button, .mobile-nav button').forEach(b=>b.classList.toggle('on', b.dataset.p===cur)); } syncNav(); function onSearch(){ q=$('#q').value.trim().toLowerCase(); pageNo[page]=1; render(false); } function matchQ(parts){ if(!q) return true; return parts.join(' ').toLowerCase().includes(q); } function updateSel(){ $('#selHint').textContent = selected.size ? ('已选 '+selected.size+' 个账号') : '未选账号'; } function tog(id,on){ if(on) selected.add(id); else selected.delete(id); updateSel(); } async function ensureToken(){ const me=await fetch('/api/admin/me',{credentials:'same-origin'}).then(r=>r.ok?r.json():null).catch(()=>null); if(!me || !me.username){ await showLogin(); return false; } window._me=me; document.getElementById('consoleApp').hidden=false; $('#tokhint').textContent=me.username+(me.role==='admin'?' · 管理员':''); document.querySelectorAll('[data-p="users"]').forEach(b=>b.hidden=me.role!=='admin'); return true; } function showLogin(){ return new Promise(resolve=>{ const root=$('#modalRoot'); root.innerHTML='

PlayPay

验证码
'; const loadCaptcha=()=>{ $('#loginCaptchaImg').src='/api/admin/captcha?ts='+Date.now(); }; loadCaptcha(); $('#loginCaptchaImg').onclick=loadCaptcha; const go=async()=>{ const res=await fetch('/api/admin/login',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:$('#loginUser').value.trim(),password:$('#loginPass').value,captcha:$('#loginCaptcha').value.trim()})}); if(!res.ok){ $('#loginErr').textContent='账号、密码或验证码错误'; loadCaptcha(); return; } const me=await fetch('/api/admin/me',{credentials:'same-origin'}).then(r=>r.ok?r.json():null).catch(()=>null); if(!me || !me.username){ $('#loginErr').textContent='登录未保持,请重试'; loadCaptcha(); return; } root.innerHTML=''; resolve(true); window._me=me; document.getElementById('consoleApp').hidden=false; $('#tokhint').textContent=me.username+(me.role==='admin'?' · 管理员':''); document.querySelectorAll('[data-p="users"]').forEach(b=>b.hidden=me.role!=='admin'); render(); }; $('#loginForm').addEventListener('submit', e=>{ e.preventDefault(); go(); }); }); } async function logoutUser(){ await fetch('/api/admin/logout',{method:'POST',credentials:'same-origin'}); location.reload(); } async function resetToken(){ localStorage.removeItem('playpay_token'); token=''; await ensureToken(); render(); } const pageNeeds={ accounts:['accounts','jobs','purchases','subs','skus','cards','meta'], jobs:['jobs','purchases','cards','accounts','meta'], buy:['purchases','accounts','meta'], subs:['subs','accounts','jobs','meta'], extend:['subs','accounts','skus','cards','meta'], cards:['cards','accounts','meta'], skus:['skus','apps','accounts','meta'], proxies:['meta'], stats:['accounts','subs','meta'], devices:[], countries:[], settings:['meta'], redeem:['skus'] }; let loadGen=0; async function loadAll(only){ const gen=++loadGen; const need=only || Object.keys(pageNeeds).reduce((a,k)=>a.concat(pageNeeds[k]),[]); const want=new Set(need); const jobQuery=filters.jobType==='rotate'?'rotate_device':(filters.jobType==='extend'?'':filters.jobType); const jobsPath='/api/jobs'+(jobQuery?('?type='+encodeURIComponent(jobQuery)):''); const reqs=[]; const take=(key, path)=>{ if(want.has(key)) reqs.push(api(path).then(v=>[key,v]).catch(()=>[key,{error:'load'}])); }; take('accounts','/api/accounts'); take('jobs', jobsPath); take('subs','/api/subscriptions'); take('purchases','/api/purchases'); take('cards','/api/cards'); take('skus','/api/skus'); take('apps','/api/apps'); take('meta','/api/meta'); const got=await Promise.all(reqs); if(gen!==loadGen) return false; const bag=Object.fromEntries(got); if(want.has('accounts')){ if(bag.accounts && bag.accounts.error) throw new Error(bag.accounts.error); cache.accounts=Array.isArray(bag.accounts)?bag.accounts.filter(Boolean):[]; } const list=(key, field)=>{ if(!want.has(key)) return; const v=bag[key]; cache[field]=(v&&!v.error&&Array.isArray(v))?v.filter(Boolean):[]; }; list('jobs','jobs'); list('subs','subs'); list('purchases','purchases'); list('cards','cards'); list('skus','skus'); list('apps','apps'); if(want.has('meta') && bag.meta && !bag.meta.error){ meta=bag.meta; const h=$('#metaHint'); if(h) h.textContent='默认代理 '+(bag.meta.default_proxy||'http://127.0.0.1:10808'); } return true; } function counts(){ const a=cache.accounts||[]; return { total:a.length, ready:a.filter(x=>x.status==='ready'||x.status==='authenticated').length, wait:a.filter(x=>x.status==='pending_login'||x.status==='challenge_required'||x.status==='reauth_required').length, run:(cache.jobs||[]).filter(j=>j.status==='running'||j.status==='queued'||j.status==='challenge_required').length }; } function listShape(){ if(page==='accounts') return (cache.accounts||[]).map(a=>a&&a.id).join(','); if(page==='jobs') return (cache.jobs||[]).map(j=>j&&(j.id+':'+j.status+':'+((jobResult(j)||{}).sent||0)+':'+((jobResult(j)||{}).failed||0))).join(','); if(page==='subs'){ const jobs=(cache.jobs||[]).filter(j=>j && (j.status==='running'||j.status==='queued')).map(j=>j.id+':'+j.account_id).join(','); return (cache.subs||[]).map(s=>s&&[s.id,s.status,s.sku,s.expiry,s.auto_renewing,s.purchase_token].join(':')).join(',')+'|'+jobs; } if(page==='buy') return (cache.purchases||[]).map(p=>p&&(p.id+':'+purchaseStatus(p))).join(','); if(page==='extend') return (cache.subs||[]).map(s=>s&&(s.id+':'+s.status+':'+s.sku+':'+s.expiry)).join(','); return ''; } function patchLive(){ if(page!=='accounts' && page!=='jobs' && page!=='subs') return false; const root=document.getElementById('app'); if(!root || !root.querySelector('[data-row]')) return false; if(patchLive._shape!==listShape()) return false; if(page==='accounts'){ (cache.accounts||[]).forEach(a=>{ const tr=root.querySelector('[data-row="acc-'+a.id+'"]'); if(!tr) return; const px=tr.querySelector('[data-live="proxy"] .clip'); if(px){ const text=String(a.effective_proxy||a.proxy||(typeof meta!=='undefined'&&meta.default_proxy)||'default'); if(px.textContent!==text) px.textContent=text; } const st=tr.querySelector('[data-live="status"]'); if(st){ const html=pill(a.status); if(st.dataset.html!==html){ st.dataset.html=html; st.innerHTML=html; } } const running=(cache.jobs||[]).filter(j=>j && j.account_id===a.id && (j.type==='purchase'||j.type==='sub_renew'||j.type==='extend'||j.type==='sub_refresh') && (j.status==='running'||j.status==='queued')).sort((x,y)=>y.id-x.id)[0]; const prog=running?((jobProgressLine(running)||jobStepText(running)||statusName(running.status))):(busyMap.get('refresh-'+a.id)||busyMap.get('buy-'+a.id)); const progJob=running?running.id:(busyJob.get('refresh-'+a.id)||busyJob.get('buy-'+a.id)||0); let box=tr.querySelector('[data-live="progress"]'); const email=tr.querySelector('.mono'); if(prog){ const html=''+esc(prog)+''; if(!box && email && email.parentNode){ box=document.createElement('div'); box.className='acc-progress'; box.setAttribute('data-live','progress'); email.parentNode.appendChild(box); } if(box && box.dataset.html!==html){ box.dataset.html=html; box.innerHTML=html; } if(box) box.onclick=progJob?(ev)=>{ ev.stopPropagation(); showJobReceipt(progJob); }:null; }else if(box) box.remove(); }); const run=root.querySelector('.stats .stat:nth-child(4) b'); if(run) run.textContent=String(counts().run); return true; } if(page==='subs'){ (cache.subs||[]).forEach(s=>{ const tr=root.querySelector('[data-row="sub-'+s.id+'"]'); if(!tr) return; const act=tr.querySelector('[data-live="act"]'); if(act){ const html=subAction(s.account_id, s); if(act.dataset.html!==html){ act.dataset.html=html; act.innerHTML=html; } } const st=tr.querySelector('[data-live="status"]'); if(st){ const html=pill(s.status); if(st.dataset.html!==html){ st.dataset.html=html; st.innerHTML=html; } } }); return true; } (cache.jobs||[]).forEach(j=>{ const tr=root.querySelector('[data-row="job-'+j.id+'"]'); if(!tr) return; const st=tr.querySelector('[data-live="status"]'); if(st){ const html=pill(j.status); if(st.dataset.html!==html){ st.dataset.html=html; st.innerHTML=html; } } const sum=tr.querySelector('[data-live="summary"]'); if(sum){ const html=jobSummaryHTML(j); if(sum.dataset.html!==html){ sum.dataset.html=html; sum.innerHTML=html; } } const when=tr.querySelector('[data-live="when"]'); if(when){ const html='创建 '+fmtTime(j.created_at)+'
开始 '+fmtTime(j.started_at)+'
完成 '+fmtTime(j.finished_at); if(when.dataset.html!==html){ when.dataset.html=html; when.innerHTML=html; } } const act=tr.querySelector('[data-live="act"]'); if(act){ const html=jobActions(j); if(act.dataset.html!==html){ act.dataset.html=html; act.innerHTML=html; } } }); return true; } function anyModal(){ return !!document.querySelector('#modalRoot .modal-mask, #modalStack .modal-mask'); } function textSelecting(){ const sel=window.getSelection && window.getSelection(); if(sel && !sel.isCollapsed && String(sel).length) return true; const el=document.activeElement; if(el && (el.tagName==='INPUT' || el.tagName==='TEXTAREA')){ const a=el.selectionStart, b=el.selectionEnd; if(typeof a==='number' && typeof b==='number' && a!==b) return true; } return false; } function paint(){ patchLive._shape=listShape(); if(textSelecting()) return; const app=document.getElementById('app'); const top=app?app.scrollTop:0; const left=app?app.scrollLeft:0; const focus=document.activeElement; const focusId=focus && focus.id && app && app.contains(focus) ? focus.id : ''; const pos=focus && focus.selectionStart; const before=app?app.innerHTML:''; if(page==='accounts') viewAccounts(); else if(page==='jobs') viewJobs(); else if(page==='buy') viewBuy(); else if(page==='subs') viewSubs(); else if(page==='extend') viewExtend(); else if(page==='cards') viewCards(); else if(page==='skus') viewSkus(); else if(page==='proxies') viewProxies(); else if(page==='stats') viewStats(); else if(page==='devices') viewDeviceModels(); else if(page==='countries') viewCountries(); else if(page==='redeem') viewRedeem(); else if(page==='settings') viewSettings(); else if(page==='users') viewUsers(); if(app && app.innerHTML===before){ restoreBusyButtons(); return; } if(app){ app.scrollTop=top; app.scrollLeft=left; } if(focusId){ const el=document.getElementById(focusId); if(el){ el.focus(); if(typeof pos==='number'){ try{ el.setSelectionRange(pos, pos); }catch(e){} } } } restoreBusyButtons(); } async function render(reload=true){ updateSel(); const fresh=!cache.accounts.length && page!=='settings' && page!=='proxies'; if(!reload){ paint(); return; } if(anyModal()) return; try{ const ok=await loadAll(pageNeeds[page]); if(ok===false) return; }catch(e){ if(fresh && !anyModal()) $('#app').innerHTML='
加载失败:'+esc(e.message)+'
'; return; } if(anyModal() || textSelecting()) return; if(!render._force && patchLive()) return; render._force=false; paint(); } async function refreshList(){ const gen=++loadGen; try{ await loadAll(pageNeeds[page]); } catch(e){ notice('列表没刷新', e.message||String(e), 'err'); return; } if(gen!==loadGen) return; render._force=false; paint(); } function paginate(list, key){ const total=list.length; const pages=Math.max(1, Math.ceil(total/pageSize)); if(pageNo[key]>pages) pageNo[key]=pages; if(pageNo[key]<1) pageNo[key]=1; const from=(pageNo[key]-1)*pageSize; return {slice:list.slice(from, from+pageSize), total, pages, from: total?from+1:0, to:Math.min(from+pageSize,total)}; } function pager(key, info){ const sizes=[100,200,500,1000]; const sizeBtns=sizes.map(n=>'').join(''); const html='
'+ '共 '+info.total+' 条 · 第 '+pageNo[key]+' / '+info.pages+' 页 · 显示 '+info.from+'-'+info.to+''+ ''+ '每页 '+sizeBtns+''+ ''+ ''+ '
'; const dock=document.getElementById('pagerDock'); if(dock) dock.innerHTML=html; return ''; } function goPage(key, d){ pageNo[key]+=d; render(false); } function setPageSize(n, key){ pageSize=n; if(key) pageNo[key]=1; render(false); } function filterSelect(label, onchange, optionsHtml){ return ''; } function filterInput(label, value, oninput, placeholder, id){ return ''; } function keepFilterFocus(id){ const el=document.getElementById(id); if(!el) return; const pos=keepFilterFocus._pos||el.value.length; el.focus(); try{ el.setSelectionRange(pos, pos); }catch(e){} } function prettyJSON(v){ if(!v) return ''; try{ return JSON.stringify(JSON.parse(v), null, 2); }catch{ return String(v); } } function parseJobJSON(v){ if(!v) return null; if(typeof v==='object') return v; try{ return JSON.parse(v); }catch{ return null; } } function offerTypeName(k){ return ({trial:'试用', intro:'优惠价', promo:'促销', base:'原价', one_time:'一次性'})[k]||k||''; } function upgradeTypeName(k){ return ({upgrade:'升级', downgrade:'降级', renew:'续订', replacement:'替换'})[k]||k||''; } function jobPayload(j){ return parseJobJSON(j&&j.payload)||{}; } function jobResult(j){ return parseJobJSON(j&&j.result)||{}; } function firstOffer(j){ const r=jobResult(j); if(Array.isArray(r) && r.length) return r[0]||{}; if(r && typeof r==='object'){ if(Array.isArray(r.offers) && r.offers.length) return r.offers[0]||{}; return r; } return {}; } function jobMoney(j){ const r=firstOffer(j); const price=r.price||r.intro_price||r.formatted_price||''; const cur=r.currency||''; if(price) return cur && !String(price).includes(cur) ? (price+' '+cur) : price; const micros=r.price_amount_micros||r.intro_price_micros; if(micros){ const n=Number(micros)/1e6; return (cur?cur+' ':'')+n; } return ''; } function jobOffer(j){ const r=firstOffer(j), p=jobPayload(j); const kind=r.offer_type||r.offer_kind||''; const id=r.offer_id||p.offer_id||''; const plan=r.base_plan_id||p.base_plan_id||''; const label=offerTypeName(kind); if(!label && !id && !plan) return ''; const bits=[]; if(label) bits.push(label); if(id) bits.push(id); else if(plan) bits.push(plan); if(r.regular_price && r.price && r.regular_price!==r.price) bits.push('原价 '+r.regular_price); return bits.join(' · '); } function jobUpgrade(j){ const r=jobResult(j); const kind=r.upgrade_type||''; if(!kind) return ''; const from=r.upgrade_from_sku||''; return upgradeTypeName(kind)+(from?(' ← '+from):''); } function recordMoney(p){ const price=(p&& (p.price||p.intro_price))||''; const cur=(p&&p.currency)||''; if(price) return cur && !String(price).includes(cur) ? (price+' '+cur) : price; const micros=p&&(p.price_amount_micros||p.intro_price_micros); if(micros){ const n=Number(micros)/1e6; return (cur?cur+' ':'')+n; } return ''; } function recordOffer(p){ const kind=(p&&(p.offer_type||p.offer_kind))||''; const id=(p&&p.offer_id)||''; const plan=(p&&p.base_plan_id)||''; const label=offerTypeName(kind); if(!label && !id && !plan) return ''; const bits=[]; if(label) bits.push(label); if(id) bits.push(id); else if(plan) bits.push(plan); if(p && p.regular_price && p.price && p.regular_price!==p.price) bits.push('原价 '+p.regular_price); return bits.join(' · '); } function recordUpgrade(p){ const kind=(p&&p.upgrade_type)||''; if(!kind) return ''; const from=(p&&p.upgrade_from_sku)||''; return upgradeTypeName(kind)+(from?(' ← '+from):''); } function showRecordReceipt(kind, id){ let rec=null, title='票据'; if(kind==='buy'){ rec=(cache.purchases||[]).find(x=>Number(x.id)===Number(id)); if(!rec){ const acc=(cache.accounts||[]).find(a=>a && (a.purchases||[]).some(x=>Number(x.id)===Number(id))); rec=acc && (acc.purchases||[]).find(x=>Number(x.id)===Number(id)); } title='购买票据 #'+id; }else if(kind==='job'){ return showJobReceipt(id); } if(!rec) return fail('无法打开票据','找不到记录 #'+id); const em=emails(); const row=(k,v)=> v ? ''+esc(k)+''+esc(v)+'' : ''; const html='
'+ row('账号', (em[rec.account_id]||'')+' #'+rec.account_id)+ row('商品', [rec.package, rec.sku, rec.product_type].filter(Boolean).join(' / '))+ row('支付卡', rec.card)+ row('金额', recordMoney(rec))+ row('优惠', recordOffer(rec))+ row('基础方案', rec.base_plan_id)+ row('计费周期', rec.billing_period)+ row('变更', recordUpgrade(rec))+ row('时间', purchaseTime(rec))+ row('状态', statusName(purchaseStatus(rec)))+ row('订单号', rec.order_id)+ row('支付卡', cardUsed(rec))+ row('票据 / token', rec.purchase_token)+ '
'+ '
'+(esc(prettyJSON(rec.raw_json))||'-')+'
'; const stacked=!!$('#modalRoot .modal-mask'); const root=stacked?($('#modalStack')||$('#modalRoot')):$('#modalRoot'); const layer=document.createElement('div'); layer.className='modal-mask'+(stacked?' stack':''); layer.innerHTML=''; if(stacked){ root.innerHTML=''; root.appendChild(layer); } else { $('#modalRoot').innerHTML=''; $('#modalRoot').appendChild(layer); } const close=()=>{ document.removeEventListener('keydown', onEsc); layer.remove(); if(stacked) root.innerHTML=''; }; const onEsc=e=>{ if(e.key==='Escape'){ e.preventDefault(); close(); } }; document.addEventListener('keydown', onEsc); bindMaskClose(layer.parentElement, close); layer.querySelector('#mOk').onclick=close; } function buyClick(ev,id){ if(ev.target.closest('button,input,a,select')) return; showRecordReceipt('buy', id); } function jobReceipt(j){ j=j||{}; const p=jobPayload(j), r=jobResult(j), o=firstOffer(j); return { type:j.type||'', status:j.status||'', email:(j&&j.email)||'', package:p.package||o.package||'', sku:p.sku||o.product_id||o.sku||'', product_type:p.product_type||o.type||'', order_id:o.order_id||'', purchase_token:o.purchase_token||'', price:jobMoney(j), offer:jobOffer(j), offer_id:o.offer_id||'', base_plan_id:o.base_plan_id||'', billing_period:o.billing_period||'', upgrade:jobUpgrade(j), upgrade_from:o.upgrade_from_sku||'', card:jobCard(j), replay:jobReplay(j), error:j.error||(r&&r.debug_message)||'' }; } function jobReplay(j){ const r=jobResult(j)||{}; const sent=Number(r.replay_sent||r.sent||0); const attempts=Number(r.replay_attempts||r.attempts||0); if(!sent) return ''; const failed=Number(r.replay_failed||r.failed||0); const via=r.replay_proxy?(' · '+r.replay_proxy):''; return '第 '+sent+' 次成功'+(attempts?(' / '+attempts):'')+(failed?(' · 失败 '+failed):'')+via; } function jobCard(j){ const p=jobPayload(j)||{}; const r=jobResult(j)||{}; const id=Number(r.card_id||p.instrument_id||0); const lists=[cache.cards||[]].concat((cache.accounts||[]).map(a=>a.cards||a.instruments||[])); let hit=null; if(id){ for(const list of lists){ hit=(list||[]).find(c=>Number(c.id)===id); if(hit) break; } } let brand=r.card_brand||(hit&&hit.brand)||p.card_brand||p.brand||''; let last4=r.card_last4||(hit&&hit.last4)||p.card_last4||p.last4||''; if(!last4 && j.type!=='bind_instrument'){ const order=String(p.old_order_id||p.order_id||''); const rec=(cache.purchases||[]).find(x=>x && Number(x.account_id)===Number(j.account_id) && order && String(x.order_id||'')===order && x.card_last4); if(rec){ brand=brand||rec.card_brand||''; last4=rec.card_last4; } } if(!last4){ const acc=(cache.accounts||[]).find(a=>a && Number(a.id)===Number(j.account_id)); const card=((acc&&acc.cards)||[]).find(c=>c && c.last4); if(card){ brand=brand||card.brand||''; last4=card.last4; } } if(!brand && !last4 && !id) return ''; return [brand, last4?('尾号 '+last4):'', id?('#'+id):''].filter(Boolean).join(' · '); } function extendPlanName(plan){ const parts=String(plan||'').split('|'); const bits=[parts[0], offerTypeName(parts[2])].filter(Boolean); return bits.join(' · ')||'原方案'; } function extendModeName(mode){ const hit=(replacementModes||[]).find(m=>m.id===Number(mode||1)); return hit?(hit.id+' '+hit.name):('方式 '+Number(mode||1)); } function extendHop(step){ if(!step) return null; if(step.to) return {sku:step.to, plan:step.plan||'', mode:Number(step.mode||1)||1}; if(step.sku) return {sku:step.sku, plan:step.plan||'', mode:Number(step.mode||1)||1}; return null; } function extendRoute(j){ const p=jobPayload(j); const chain=Array.isArray(p.extend_chain)?p.extend_chain:[]; const saved=Array.isArray(p.extend_path)?p.extend_path.map(extendHop).filter(Boolean):[]; if(!saved.length && !chain.length && !p.old_sku) return null; let steps=saved; if(!steps.length){ steps=[{sku:p.old_sku||''}]; if(p.sku) steps.push({sku:p.sku, plan:'', mode:Number(p.replacement_mode||1)||1}); chain.forEach(st=>{ const hop=extendHop(st); if(hop) steps.push(hop); }); } let at=Number(p.extend_step||0); if(!at){ const idx=steps.findIndex(st=>st.sku===p.sku); at=idx>0?idx:(steps.length>1?1:0); } if(at<0) at=0; if(at>steps.length-1) at=Math.max(0, steps.length-1); return {steps, at}; } function extendRouteText(route){ if(!route) return ''; return route.steps.map((st,i)=>{ const name=shortSku(st.sku); const mark=i0?(' '+extendPlanName(st.plan)+' / '+extendModeName(st.mode)):''; return mark+name+plan; }).join(' → '); } function extendRouteHTML(route){ if(!route) return ''; const bits=route.steps.map((st,i)=>{ const cls=i0?'
'+esc(extendPlanName(st.plan))+' · '+esc(extendModeName(st.mode))+'
':'
起点
'; return '
'+esc(shortSku(st.sku))+''+plan+'
'; }); return '
'+bits.join('→')+'
'; } function jobProgress(j){ const r=jobResult(j); if(!r) return ''; if(r.step) return r.step; if(r.sent && r.attempts) return '并发重试 '+r.sent+'/'+r.attempts; return ''; } function jobSummary(j){ const bits=[]; if(jobIsRotate(j)) bits.push(j.type==='login'?'轮换后重新登录':'重置设备身份并重新登录'); const rec=jobReceipt(j); if(rec.sku) bits.push((rec.package?rec.package+' / ':'')+rec.sku); const progress=jobProgress(j); if(progress && (j.status==='running'||j.status==='queued')) bits.push(progress); if(j.error) bits.push(j.error); else if(j.challenge_type) bits.push('验证: '+j.challenge_type+(j.challenge_hint?(' / '+j.challenge_hint):'')); return bits.join(String.fromCharCode(10)) || '-'; } function jobFailText(j){ const r=jobResult(j); const fails=r&&r.failures; if(!fails || typeof fails!=='object') return ''; return Object.keys(fails).map(k=>[k,Number(fails[k])||0]).filter(x=>x[1]>0).sort((a,b)=>b[1]-a[1]).map(x=>x[0]+' '+x[1]).join('\n'); } function jobProgressLine(j){ const r=jobResult(j)||{}; const sent=Number(r.sent||0), attempts=Number(r.attempts||0); if(sent && attempts) return '并发重试 '+sent+'/'+attempts; const step=String(r.step||''); const cut=step.indexOf(' · '); return cut>0?step.slice(0,cut):step; } function jobStepHistory(j){ const r=jobResult(j)||{}; const steps=Array.isArray(r.logs)&&r.logs.length?r.logs.map(s=>String(s||'').trim()).filter(Boolean):(Array.isArray(r.steps)?r.steps.map(s=>String(s||'').trim()).filter(Boolean):[]); if(!steps.length){ const cur=String(r.step||'').trim(); return cur?cur:''; } return steps.map((s,i)=>(i+1)+'. '+s).join('\n'); } function jobSummaryLines(j){ const lines=[]; if(jobIsRotate(j)) lines.push(j.type==='login'?'轮换后重新登录':'重置设备身份并重新登录'); const rec=jobReceipt(j); const route=extendRoute(j); if(route) lines.push('加时 '+(route.at+1)+'/'+route.steps.length+' · '+extendRouteText(route)); else if(rec.sku) lines.push((rec.package?rec.package+' / ':'')+rec.sku); else if(j.type==='redeem_gift'){ const code=String((jobPayload(j)||{}).code||(jobResult(j)||{}).code||''); if(code) lines.push(code); } const progress=jobProgressLine(j); if(progress && (j.type==='login'||j.type==='redeem_gift'||j.status==='running'||j.status==='queued'||j.status==='failed'||j.status==='cancelled')) lines.push(progress); const fails=jobFailText(j); if(fails) fails.split('\n').forEach(s=>{ if(s) lines.push(s); }); else if(j.error) lines.push(j.error); else if(j.challenge_type) lines.push('验证: '+j.challenge_type+(j.challenge_hint?(' / '+j.challenge_hint):'')); return lines; } function jobSummaryHTML(j){ const lines=jobSummaryLines(j); if(!lines.length) return '-'; const shown=lines.slice(0,3); if(lines.length>3) shown[2]=shown[2]+' …+'+(lines.length-3); return '
'+shown.map(s=>'
'+esc(s)+'
').join('')+'
'; } function jobCell(label, text){ text=String(text||'').trim(); if(!text) return ''; return '
'+esc(label)+' '+esc(text)+'
'; } function jobEgressText(j){ const p=jobPayload(j)||{}; const r=jobResult(j)||{}; const proxy=String(p.proxy||r.proxy||'').trim(); const cc=String(p.egress_country||r.egress_country||'').toUpperCase(); const ip=String(p.egress_ip||r.egress_ip||'').trim(); const bits=[]; if(proxy) bits.push(proxy); if(ip||cc) bits.push([ip, cc].filter(Boolean).join(' · ')); return bits.join(' · '); } function bindEgressText(j){ const p=jobPayload(j)||{}; const r=jobResult(j)||{}; const proxy=String(p.proxy||''); const cc=String(p.egress_country||r.egress_country||'').toUpperCase(); const ip=String(p.egress_ip||r.egress_ip||''); const place=[cc, p.egress_region||r.egress_region, p.egress_city||r.egress_city].filter(Boolean).join(' / '); const pool=proxy.indexOf('pool:')===0?proxy.slice(5).split('|')[0]:''; const picked=((proxy.match(/cc=([A-Za-z]{2})/)||[])[1]||'').toUpperCase(); return [pool?('代理池 '+pool):'', picked?('选择 '+picked):'', ip, place].filter(Boolean).join(' · '); } function jobDetail(j){ const bits=[]; if(j.error) bits.push(j.error); if(j.challenge_type) bits.push('验证: '+j.challenge_type+(j.challenge_hint?(' / '+j.challenge_hint):'')); try{ const r=jobResult(j); if(r && r.step) bits.push('进度: '+r.step); }catch(e){} const payload=prettyJSON(j.payload); const result=prettyJSON(j.result); if(payload) bits.push('请求:\n'+payload); if(result) bits.push('结果:\n'+result); return bits.join('\n\n') || '-'; } function showJobReceipt(id){ const j=(cache.jobs||[]).find(x=>Number(x.id)===Number(id)); if(!j) return fail('无法打开详情','找不到任务 #'+id); let timer=0; const draw=(cur)=>{ const rec=jobReceipt(cur); const row=(k,v)=> v ? ''+esc(k)+''+esc(v)+'' : ''; const prog=jobProgress(cur); const html='
'+ row('任务','#'+cur.id+' · '+jobTypeLabel(cur)+' · '+statusName(cur.status))+ row('账号', rec.email||('- #'+cur.account_id))+ row('代理出口', jobEgressText(cur))+ row('商品', [rec.package, rec.sku, rec.product_type].filter(Boolean).join(' / '))+ (extendRoute(cur)?'加时路径'+extendRouteHTML(extendRoute(cur))+'':'')+ row('支付卡', rec.card)+ (cur.type==='bind_instrument'?row('出口', bindEgressText(cur)):'')+ row('重试次数', rec.replay)+ ((cur.type==='purchase'||cur.type==='sub_renew'||cur.type==='extend'||cur.type==='redeem_gift')?row('并发请求数', String((jobPayload(cur)||{}).replay_concurrency ?? (jobResult(cur)||{}).concurrency ?? '')):'')+ ((cur.type==='purchase'||cur.type==='sub_renew'||cur.type==='extend')?'出口代理组'+esc(String((jobPayload(cur)||{}).proxy_group||'').trim()||'无')+'':'')+ row('成功出口', (jobResult(cur)||{}).replay_proxy||'')+ row('基础方案', rec.base_plan_id)+ row('计费周期', rec.billing_period)+ row('订单号', rec.order_id)+ row('Purchase token', rec.purchase_token)+ row('验证', cur.challenge_type ? (cur.challenge_type+(cur.challenge_hint?(' / '+cur.challenge_hint):'')) : '')+ (rec.error?'错误'+esc(rec.error)+'':'')+ row('重试进度', jobProgressLine(cur))+ (jobStepHistory(cur)?'过程'+esc(jobStepHistory(cur))+'':'')+ (jobFailText(cur)?'失败'+esc(jobFailText(cur))+'':'')+ '
'+ '
'+(esc(prettyJSON(cur.payload))||'-')+'
'+ '
'+(esc(prettyJSON(cur.result))||'-')+'
'; const body=document.querySelector('#jobReceiptBody'); if(body) body.innerHTML=html; }; const root=$('#modalRoot'); root.innerHTML=''; draw(j); const close=()=>{ if(timer) clearInterval(timer); document.removeEventListener('keydown', onEsc); root.innerHTML=''; }; const onEsc=e=>{ if(e.key==='Escape'){ e.preventDefault(); close(); } }; document.addEventListener('keydown', onEsc); bindMaskClose(root, close); $('#mOk').onclick=close; if(j.status==='running'||j.status==='queued'||j.status==='challenge_required'){ timer=setInterval(async()=>{ if(!document.querySelector('#jobReceiptBody')){ clearInterval(timer); return; } const fresh=await api('/api/jobs/'+id); if(!fresh || fresh.error) return; const i=(cache.jobs||[]).findIndex(x=>Number(x.id)===Number(id)); if(i>=0) cache.jobs[i]=Object.assign({}, cache.jobs[i], fresh); draw(i>=0?cache.jobs[i]:fresh); if(fresh.status && fresh.status!=='running' && fresh.status!=='queued' && fresh.status!=='challenge_required') clearInterval(timer); }, 2000); } } function jobClick(ev,id){ if(ev.target.closest('button,input,a,select')) return; const j=(cache.jobs||[]).find(x=>Number(x.id)===Number(id)); if(j && j.status==='challenge_required' && (j.challenge_type==='otp' || j.challenge_type==='3ds' || j.challenge_type==='2sv' || j.challenge_type==='prompt' || !j.challenge_type)){ solve(id); return; } showJobReceipt(id); } function emails(){ const m={}; (cache.accounts||[]).forEach(a=>{ if(a) m[a.id]=a.email; }); return m; } function accClick(ev,id){ if(ev.target.closest('button,input,a')) return; showAccount(id); } function purchaseMeta(p){ if(!p) return null; let meta=p.meta||p.meta_json; if(!meta) return null; if(typeof meta==='string'){ try{ meta=JSON.parse(meta); }catch(e){ return null; } } return meta; } function currentPackageSubs(subs){ const best=new Map(); for(const s of subs||[]){ if(!s) continue; const key=(s.account_id||0)+'|'+String(s.package||''); const prev=best.get(key); const time=Number(s.purchase_time||0); const prevTime=prev?Number(prev.purchase_time||0):0; const live=subCurrent(s), prevLive=prev?subCurrent(prev):false; if(!prev || (live && !prevLive) || (live===prevLive && (time>prevTime || (time===prevTime && Number(s.id||0)>Number(prev.id||0))))) best.set(key,s); } return [...best.values()]; } function mergeDeferredSubs(subs){ subs=(subs||[]).filter(Boolean); const used=new Set(); const merged=[]; const chains=[]; for(const p of (cache.purchases||[])){ const meta=purchaseMeta(p)||{}; const fromSku=p.upgrade_from_sku||meta.upgrade_from_sku; const upgradeType=p.upgrade_type||meta.upgrade_type; const repl=Number(p.replacement_mode||meta.replacement_mode||0); if(!fromSku || upgradeType==='new') continue; if(repl!==5) continue; const time=purchaseTimeUnix(p); chains.push({p, fromSku, toSku:p.sku||'', repl, time}); } chains.sort((a,b)=>a.time-b.time); const byKey=new Map(); for(const step of chains){ const key=step.p.account_id+'|'+step.p.package; let chain=byKey.get(key); if(!chain){ chain={steps:[], from:null, to:null}; byKey.set(key, chain); } const from=subs.find(s=>s.account_id===step.p.account_id && s.package===step.p.package && s.sku===step.fromSku); const to=subs.find(s=>s.account_id===step.p.account_id && s.package===step.p.package && s.sku===step.toSku) || subs.find(s=>s.account_id===step.p.account_id && s.package===step.p.package && s.purchase_token && s.purchase_token===purchaseTokenOf(step.p) && s.sku!==step.fromSku); if(!from || !to || from===to) continue; if(!chain.from) chain.from=from; chain.to=to; chain.steps.push(step); chain.repl=step.repl; used.add(from); used.add(to); } for(const chain of byKey.values()){ if(!chain.from || !chain.to || !chain.steps.length) continue; const skus=[chain.from.sku]; chain.steps.forEach(s=>{ if(s.toSku && skus[skus.length-1]!==s.toSku) skus.push(s.toSku); }); merged.push(Object.assign({}, chain.to, { sku_label: skus.join(' → '), status_label: '到期后切换', acknowledged: true, purchase_time: chain.from.purchase_time||chain.to.purchase_time, purchase_token: chain.to.purchase_token||chain.from.purchase_token, order_id: chain.to.order_id||chain.from.order_id, auto_renewing: !!(chain.to.auto_renewing||chain.from.auto_renewing) })); } return subs.filter(s=>!used.has(s)).concat(merged); } function subCurrent(x){ if(!x) return false; if(x.status==='cancelled' || x.status==='replaced') return false; return x.status==='active' || !!x.expiry; } function runningUpgrade(aid){ return (cache.jobs||[]).filter(j=>j && Number(j.account_id)===Number(aid) && (j.type==='sub_renew'||j.type==='extend'||j.type==='purchase') && (j.status==='running'||j.status==='queued')).sort((a,b)=>b.id-a.id)[0]; } function upgradeButton(aid, tok, pkg, sku){ const job=runningUpgrade(aid); if(!job) return ''; const text=jobProgressLine(job)||jobStepText(job)||statusName(job.status); return ''; } function subAction(aid,x){ const pkg=esc(x.package||''), sku=esc(x.sku||''), tok=esc(x.purchase_token||''); const up=upgradeButton(aid, tok, pkg, sku); if(x.status==='cancelled' || x.auto_renewing===false) return up+''; return up+''; }