async function showAccount(id){ const d=await api('/api/accounts/'+id); if(d.error) return fail('无法加载账号', d.error); if(Array.isArray(d.purchases)){ const by=new Map((cache.purchases||[]).map(p=>[p.id,p])); d.purchases.forEach(p=>by.set(p.id, Object.assign({}, by.get(p.id)||{}, p))); cache.purchases=[...by.values()]; } const tokList=d.tokens||[]; const tokenKindName=k=>({master:'Master',play_oauth:'Play',wallet_oauth:'钱包',sierra_oauth:'Sierra 付款',sid:'SID',lsid:'LSID'})[k]||k; const tokExp=tokList.filter(t=>t.expired).length; const tokHint=tokList.length?(tokList.length+' 个'+(tokExp?(' · '+tokExp+' 过期'):'')):'无'; const tokens=tokList.map((t,i)=>{ const val=t.value||''; return ''+esc(tokenKindName(t.kind))+''+esc(t.preview||'-')+''+(t.expired?'过期':pill('ready'))+''+fmtTime(t.expiry)+''; }).join('') || '还没有 token,请先登录'; const subs=currentPackageSubs(mergeDeferredSubs((d.subscriptions||[]).map(x=>Object.assign({account_id:id}, x||{})))).filter(subCurrent).map(x=>''+esc(x.package)+''+esc(x.sku_label||x.sku)+''+pill(x.status)+(x.status_label?'
'+esc(x.status_label)+'
':'')+''+ackCell(x)+''+renewPill(x.auto_renewing)+'买于 '+fmtUnix(x.purchase_time, true)+'
下次续费 '+fmtUnix(x.expiry, true)+(x.renew_price?(' · '+esc(x.renew_price)):'')+''+esc(x.order_id||'-')+'
'+esc(x.purchase_token||'-')+'
'+sourcePill(x.source)+''+subAction(id,x)+'').join('') || '暂无当前有效订阅'; const buys=sortPurchasesDesc(d.purchases||[]).map(x=>''+appIcoCell(x.package)+''+purchaseTime(x)+''+esc(x.sku)+'
'+esc(x.package)+'
'+esc(recordMoney(x)||'-')+''+pill(purchaseStatus(x))+''+sourcePill(x.source)+'').join('') || '暂无消费记录'; const html='
'+esc(d.email)+' · '+pill(d.status)+' · 代理 '+esc(d.effective_proxy||d.proxy||'')+(d.has_password?' ':'')+' '+'
'+ (d.device?'
androidId '+esc(d.device.android_id)+'
':'
尚未 checkin,没有 androidId
')+ '
运营商'+esc(d.mccmnc||'随出口')+'出口 '+(d.egress_country?esc(d.egress_country):'未知')+'
'+ '
Play 定区'+esc(playRegion(d))+'
'+ '
Play 余额读取中
'+ '
最近保活 '+fmtTime(d.last_keepalive_at)+' · Play/钱包下次续期 '+fmtTime(d.next_keepalive_due||d.next_keepalive_at)+'
'+ '
SID/LSID 按会话单独续期,过期时间与 Play token 不同
'+ '
'+tokens+'
类型预览状态过期
'+ '
'+subs+'
包名SKU状态已确认续订时间订单来源
'+ '
'+buys+'
时间商品金额状态来源
'; const root=$('#modalRoot'); root.innerHTML=''; const closeAcc=()=>{ document.removeEventListener('keydown', onEscAcc); root.innerHTML=''; }; const onEscAcc=e=>{ if(e.key==='Escape'){ e.preventDefault(); closeAcc(); } }; document.addEventListener('keydown', onEscAcc); bindMaskClose(root, closeAcc); setupCarrier(id, d); api('/api/accounts/'+id+'/balance').then(b=>{ const el=document.getElementById('accBalance'); if(!el) return; el.textContent=(b&&b.play_balance)?b.play_balance:'未知'; }).catch(()=>{ const el=document.getElementById('accBalance'); if(el) el.textContent='未知'; }); root.querySelectorAll('[data-verify-instrument]').forEach(btn=>{ btn.onclick=ev=>{ ev.preventDefault(); ev.stopPropagation(); verifyCard(id, btn.getAttribute('data-verify-instrument'), btn); }; }); const tokFold=root.querySelector('#tokFold'), tokBody=root.querySelector('#tokBody'); if(tokFold&&tokBody) tokFold.onclick=()=>{ tokBody.classList.toggle('open'); tokFold.classList.toggle('open'); }; const pwEdit=root.querySelector('#accPwEdit'); if(pwEdit) pwEdit.onclick=async()=>{ const next=await ask('修改密码', '新密码', '', d.email, 'password'); if(next===null) return; const pw=String(next||'').trim(); if(!pw) return fail('无法修改','密码不能为空'); const j=await api('/api/accounts/'+id+'/password',{method:'POST',body:JSON.stringify({password:pw})}); if(j.error) return fail('无法修改', j.error); d.password=pw; d.has_password=true; notice('密码已更新', d.email); const again=await modal({title:'密码已更新', message:'要用新密码重新登录吗?', okText:'登录', cancelText:'先不登'}); if(again) doLogin(null, id); }; const pwBtn=root.querySelector('#accPwToggle'); if(pwBtn){ let shown=false, pwNode=null; pwBtn.onclick=()=>{ const pw=d.password||''; if(!pw) return fail('无法查看','这个账号没有保存密码'); shown=!shown; if(shown){ if(!pwNode){ pwNode=document.createElement('span'); pwNode.className='mono'; pwBtn.before(pwNode); } pwNode.textContent=pw+' '; pwBtn.textContent='隐藏'; }else{ if(pwNode) pwNode.textContent=''; pwBtn.textContent='查看密码'; } }; } root.querySelectorAll('[data-copy]').forEach(btn=>{ btn.onclick=()=>{ const i=Number(btn.getAttribute('data-copy')); const val=((d.tokens||[])[i]||{}).value||''; if(!val) return fail('无法复制','这个 token 是空的'); copyText(val).then(()=>notice('已复制', (d.tokens[i].kind||'token'))).catch(()=>fail('复制失败','浏览器不允许写入剪贴板')); }; }); } function playRegion(a){ const c=String((a&&a.play_country)||'').trim().toUpperCase(); if(/^[A-Z]{2}$/.test(c)) return c+' '+(COUNTRY_ZH[c]||''); return '未定区'; } async function setupCarrier(id, d){ const q=document.getElementById('carrierQ'); const list=document.getElementById('carrierList'); const now=document.getElementById('carrierNow'); if(!q||!list) return; let rows=[]; try{ rows=await api('/api/carriers'); }catch(e){ rows=[]; } if(!Array.isArray(rows)) rows=[]; const current=()=>rows.find(r=>d.mccmnc && r.mccmnc===d.mccmnc); const label=r=>r?(r.country+' '+(r.name||'')+' · '+r.mccmnc):(d.mccmnc||''); const paint=()=>{ const s=q.value.trim().toLowerCase(); const cur=current(); const filtering=document.activeElement===q && s && s!==label(cur).toLowerCase(); const shown=(document.activeElement===q?rows:[]).filter(r=>!filtering || (r.country||'').toLowerCase().includes(s) || (r.name||'').toLowerCase().includes(s) || String(r.mccmnc||'').includes(s)); list.hidden=!shown.length; list.innerHTML=shown.map(r=>'').join(''); list.querySelectorAll('[data-mcc]').forEach(btn=>{ btn.onmousedown=ev=>ev.preventDefault(); btn.onclick=async()=>{ const j=await api('/api/accounts/'+id+'/carrier',{method:'POST',body:JSON.stringify({country:btn.getAttribute('data-cc'), mccmnc:btn.getAttribute('data-mcc')})}); if(j.error) return fail('无法切换', j.error); d.mccmnc=j.mccmnc; const hit=rows.find(r=>r.mccmnc===j.mccmnc); if(now) now.textContent=j.mccmnc; q.value=label(hit); list.hidden=true; notice('运营商号已切换', q.value); }; }); }; const cur=current(); if(cur) q.value=label(cur); const clear=document.getElementById('carrierClear'); if(clear) clear.onclick=async()=>{ const j=await api('/api/accounts/'+id+'/carrier',{method:'POST',body:JSON.stringify({mccmnc:''})}); if(j.error) return fail('无法清空', j.error); d.mccmnc=''; if(now) now.textContent='随出口'; q.value=''; list.hidden=true; notice('运营商已清空', '之后按出口 IP 的国家使用'); }; q.addEventListener('focus', paint); q.addEventListener('click', paint); q.addEventListener('input', paint); q.addEventListener('blur', ()=>{ list.hidden=true; const cur=current(); if(cur) q.value=label(cur); }); } async function togglePass(btn){ const wrap=btn&&btn.closest?btn.closest('.pw-wrap'):null; const el=wrap?wrap.querySelector('input'):document.getElementById('accPass'); if(!el) return; const show=el.type==='password'; el.type=show?'text':'password'; btn.textContent=show?'隐藏':'查看'; } async function copyText(text){ if(navigator.clipboard && window.isSecureContext){ await navigator.clipboard.writeText(text); return; } const ta=document.createElement('textarea'); ta.value=text; ta.setAttribute('readonly',''); ta.style.position='fixed'; ta.style.left='-9999px'; document.body.appendChild(ta); ta.select(); const ok=document.execCommand('copy'); document.body.removeChild(ta); if(!ok) throw new Error('copy'); } function accCards(a){ const last4=a.card_last4||[]; const st=a.card_status||[]; if(!last4.length) return '-'; return last4.map((n,i)=>{ const k=String(st[i]||''); const pending=!k || k==='pending' || k==='needs_verification'; return '
'+esc(n)+(pending?' '+pill('needs_verification'):' 已验证')+'
'; }).join(''); } function accCardMatch(a){ const f=filters.accCard||''; if(!f) return true; const last4=a.card_last4||[]; const src=a.card_sources||[]; if(f==='yes') return last4.length>0; if(f==='no') return last4.length===0; if(f==='system') return src.includes('system'); if(f==='remote') return src.includes('remote'); return true; } function viewAccounts(){ const c=counts(); const accCountries=[...new Set((cache.accounts||[]).map(playCountry).filter(cc=>cc && cc!=='-'))].sort(); const all=(cache.accounts||[]).filter(a=>{ if(!a) return false; if(filters.accStatus && a.status!==filters.accStatus) return false; if(filters.accCountry && playCountry(a)!==filters.accCountry) return false; if(!accCardMatch(a)) return false; const em=(filters.accEmail||'').trim().toLowerCase(); if(em){ const hay=[a.email,String(a.id)].concat(a.card_last4||[]).join(' ').toLowerCase(); if(!hay.includes(em)) return false; } return matchQ([a.email,a.status,a.effective_proxy,a.proxy,playCountry(a),String(a.id)].concat(a.card_last4||[])); }); const info=paginate(all,'accounts'); const rows=info.slice .map(a=>{ const off=a.status==="disabled"; const dis=off?" disabled":""; 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); return ''+ ''+ '#'+a.id+''+ '
'+esc(a.email)+'
'+(a.has_password?'
有密码
':'')+(prog?'
'+esc(prog)+'
':'')+''+ ''+pill(a.status)+''+ ''+esc(playCountry(a))+''+ '
'+esc(a.effective_proxy||a.proxy||meta.default_proxy||'default')+'
'+(a.has_master_token?'已有 master':'无 master')+'
'+ '上次 '+fmtTime(a.last_keepalive_at)+'
下次 '+fmtTime(a.next_keepalive_due||a.next_keepalive_at)+''+ '
'+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ (off?'':'')+ '
'; }).join(''); const disabled=selected.size?'':'disabled'; $('#app').innerHTML = '
'+ '
账号'+c.total+'
'+ '
就绪'+c.ready+'
'+ '
待处理'+c.wait+'
'+ '
进行中任务'+c.run+'
'+ '
'+ '
'+ ''+ ''+ '登录后即可购买。卡使用 Google 账号里已有的支付方式。'+ '
'+ '
'+ filterInput('邮箱账号', filters.accEmail, 'filters.accEmail=this.value; keepFilterFocus._pos=this.selectionStart; pageNo.accounts=1; render(false); keepFilterFocus(\'accEmailFilter\')', '邮箱', 'accEmailFilter')+ filterSelect('登录状态', 'filters.accStatus=this.value; pageNo.accounts=1; render(false)', ''+['ready','authenticated','pending_login','token_expired','reauth_required','challenge_required','disabled'].map(t=>'').join(''))+ filterSelect('国家', 'filters.accCountry=this.value; pageNo.accounts=1; render(false)', ''+accCountries.map(cc=>'').join(''))+ '当前 '+all.length+' / '+c.total+''+ '
'+ '
'+ '已选 '+selected.size+''+ ''+ ''+ ''+ ''+ ''+ '
'+ '
'+ (rows||'')+ '
ID邮箱登录状态国家代理最近保活操作
还没有账号。先添加或导入。
'+pager('accounts', info); } async function addAccDlg(){ const root=$('#modalRoot'); root.innerHTML=''; const ok=await new Promise(resolve=>{ $('#mOk').onclick=()=>resolve(true); $('#mCancel').onclick=()=>resolve(false); bindMaskClose(root, ()=>resolve(false)); }); if(!ok){ root.innerHTML=''; return; } const email=($('#accEmail')&&$('#accEmail').value||'').trim(); const password=$('#accPass')?$('#accPass').value:''; const master_token=$('#accMaster')?$('#accMaster').value:''; const proxy=$('#accProxy')?$('#accProxy').value:''; root.innerHTML=''; if(!email) return fail('无法添加','请填写邮箱'); const j=await api('/api/accounts',{method:'POST',body:JSON.stringify({email,password,master_token,proxy})}); if(j.error) return fail('入库失败', j.error); notice('已入库', (j.account&&j.account.email||email)+' · 登录 #'+(j.job_id||'')); refreshList(); } async function addAccFromForm(){ return addAccDlg(); } async function impDlg(){ const v=await modal({title:'批量导入', message:'每行顺序:email、password、master、proxy。分隔符可用逗号、--- 或 ----。', fields:[{label:'账号列表', type:'textarea', placeholder:'a@x.com----pass----master----proxy'}]}); if(v===null) return; const j=await api('/api/accounts/import',{method:'POST',body:v}); if(j.error) return fail('导入失败', j.error); const dups=j.duplicates||[]; const n=Number(j.count||0); if(!n && !dups.length) fail('没有导入', '没有解析出账号。每行用逗号、--- 或 ---- 分开邮箱、密码、master、代理。'); else if(dups.length) fail('导入有重复', '已写入 '+n+' 个。以下账号已存在:'+dups.join('、')); else notice('导入完成', '已写入 '+n+' 个账号'); if(n){ pageNo.accounts=1; filters.accStatus=''; filters.accCountry=''; filters.accCard=''; filters.accEmail=''; q=''; const box=$('#q'); if(box) box.value=''; } refreshList(); } async function disableAcc(ev,id){ const acc=(cache.accounts||[]).find(a=>a && a.id===id); const ok=await modal({title:'停用账号', message:'停用后不再自动登录/保活。账号:'+(acc&&acc.email||('#'+id)), okText:'停用', cancelText:'取消'}); if(!ok) return; const j=await api('/api/accounts/'+id+'/disable',{method:'POST',body:'{}'}); if(j.error) return fail('无法停用', j.error); notice('已停用', acc&&acc.email||('账号 #'+id)); render(); } async function enableAcc(ev,id){ const acc=(cache.accounts||[]).find(a=>a && a.id===id); const ok=await modal({title:'恢复账号', message:'恢复后可重新登录、保活和购买。账号:'+(acc&&acc.email||('#'+id)), okText:'恢复', cancelText:'取消'}); if(!ok) return; const j=await api('/api/accounts/'+id+'/enable',{method:'POST',body:'{}'}); if(j.error) return fail('无法恢复', j.error); notice('已恢复', acc&&acc.email||('账号 #'+id)); render(); } async function syncAllAccounts(ev){ const btn=clickBtn(ev); const ready=(cache.accounts||[]).filter(a=>a && (a.status==='ready'||a.status==='authenticated')); const pending=(cache.accounts||[]).filter(a=>a && a.status==='pending_login'); if(!ready.length && !pending.length) return fail('没有可同步的账号','没有有效或待登录的账号'); const ok=await modal({title:'全量同步', message:'将刷新 '+ready.length+' 个有效账号,并登录 '+pending.length+' 个待登录账号。', okText:'开始', cancelText:'取消'}); if(!ok) return; let refreshIds=[]; if(ready.length){ const j=await api('/api/subscriptions/refresh',{method:'POST',body:JSON.stringify({account_ids:ready.map(a=>a.id)})}); if(j.error) return fail('无法刷新', j.error); refreshIds=j.job_ids||[]; } const loginIds=[]; for(const a of pending){ const j=await api('/api/accounts/'+a.id+'/login',{method:'POST',body:'{}'}); if(!j.error && j.job_id) loginIds.push(j.job_id); } notice('已开始全量同步', '刷新 '+refreshIds.length+' · 登录 '+loginIds.length); refreshList(); } async function doLogin(ev,id){ const j=await api('/api/accounts/'+id+'/login',{method:'POST',body:'{}'}); if(j.error) return fail('无法登录', j.error); notice('正在登录', '走协议登录,完成后会自动更新状态'); watchAccountJob(id, 'login', j.job_id, '登录'); } async function bulkLogin(ev){ const btn=clickBtn(ev); const ids=[...selected]; if(!ids.length) return fail('无法登录','请先勾选账号'); const jobIds=[]; for(const id of ids){ const j=await api('/api/accounts/'+id+'/login',{method:'POST',body:'{}'}); if(!j.error && j.job_id) jobIds.push(j.job_id); } if(!jobIds.length) return fail('无法登录','入队失败'); await waitJobs(jobIds, '登录', btn); } async function bulkKeepalive(ev){ const btn=clickBtn(ev); const ids=[...selected]; if(!ids.length) return fail('无法保活','请先勾选账号'); const jobIds=[]; for(const id of ids){ const j=await api('/api/accounts/'+id+'/keepalive',{method:'POST',body:'{}'}); if(!j.error && j.job_id) jobIds.push(j.job_id); } if(!jobIds.length){ notice('保活完成','无任务'); render(); return; } await waitJobs(jobIds, '保活', btn); } async function refreshToken(ev,id){ const j=await api('/api/accounts/'+id+'/keepalive',{method:'POST',body:'{}'}); if(j.error) return fail('刷新失败', j.error); if(j.job_id) watchAccountJob(id, 'keep', j.job_id, '保活'); else { notice('Token 已刷新', (j.account&&j.account.email||'')+' · '+statusName(j.account&&j.account.status)); render(); } } async function setProxy(id){ const acc=(cache.accounts||[]).find(a=>a && a.id===id)||{}; const v=await proxyChoiceDialog(acc.proxy||''); if(!v) return; const j=await api('/api/accounts/'+id+'/proxy',{method:'POST',body:JSON.stringify({proxy:v.proxy})}); if(j.error) return fail('设置失败', j.error); const accRow=(cache.accounts||[]).find(a=>a && a.id===id); if(accRow){ accRow.proxy=j.proxy!=null?j.proxy:v.proxy; if(j.effective_proxy!=null) accRow.effective_proxy=j.effective_proxy; if(j.proxy_source) accRow.proxy_source=j.proxy_source; } const cell=document.querySelector('[data-row="acc-'+id+'"] [data-live="proxy"] .clip'); if(cell) cell.textContent=(j.effective_proxy||j.proxy||(typeof meta!=='undefined'&&meta.default_proxy)||'default'); notice('代理已更新'); } async function proxyChoiceDialog(current){ let groups=[]; try{ const st=await api('/api/settings'); groups=(st.proxy_groups||[]).filter(g=>g && (g.proxies||[]).length); }catch(e){} const cur=String(current||''); const pool=cur.indexOf('pool:')===0; const body=pool?cur.slice(5):''; const bits=body.split('|'); const curName=pool?(bits[0]||''):''; const curCC=((bits.find(s=>s.indexOf('cc=')===0)||'').slice(3)||'').toUpperCase(); const curEgress=(bits.find(s=>s.indexOf('egress=')===0)||'').slice(7); return new Promise(resolve=>{ const root=$('#modalRoot'); const rows=groups.map(g=>{ const n=(g.proxies||[]).length; const on=g.name===curName?'checked':''; return ''; }).join(''); root.innerHTML=''; const show=()=>{ const mode=(root.querySelector('input[name=accProxyMode]:checked')||{}).value||'default'; document.getElementById('accProxyCustom').hidden=mode!=='custom'; document.getElementById('accProxyPool').hidden=mode!=='pool'; }; const paintPoolCC=()=>{ const name=(root.querySelector('input[name=accPool]:checked')||{}).value||''; const g=groups.find(x=>(x.name||'')===name); const counts=proxyCountryCounts((g&&g.proxies||[]).join('\n')); const keys=Object.keys(counts).filter(k=>k!=='未识别').sort(); const input=$('#accPoolCC'); const q=String((input||{}).value||'').trim().toUpperCase(); const shown=q?keys.filter(k=>k.indexOf(q)===0):keys; const list=$('#accPoolCCList'); if(!list) return; const pick=shown.indexOf(q)>=0?q:(shown[0]||''); list.innerHTML=shown.map(k=>'').join('') || '
没有匹配的国家
'; list.querySelectorAll('[data-cc]').forEach(btn=>{ btn.onclick=()=>{ input.value=btn.getAttribute('data-cc'); paintPoolCC(); }; }); }; root.querySelectorAll('input[name=accPool]').forEach(el=>el.addEventListener('change', paintPoolCC)); const ccInput=$('#accPoolCC'); if(ccInput) ccInput.addEventListener('input', paintPoolCC); paintPoolCC(); root.querySelectorAll('input[name=accProxyMode]').forEach(el=>el.addEventListener('change', show)); const finish=v=>{ root.innerHTML=''; resolve(v); }; bindMaskClose(root, ()=>finish(null)); $('#mCancel').onclick=()=>finish(null); $('#mOk').onclick=()=>{ const mode=(root.querySelector('input[name=accProxyMode]:checked')||{}).value||'default'; if(mode==='default') return finish({proxy:''}); if(mode==='direct') return finish({proxy:'direct'}); if(mode==='custom') return finish({proxy:$('#accProxyText').value.trim()}); const g=(root.querySelector('input[name=accPool]:checked')||{}).value||''; if(!g) return fail('无法保存','先选一个代理组'); const cc=$('#accPoolCC').value.trim().toUpperCase(); const via=$('#accPoolVia').checked; const egress=via?(meta.default_proxy||'http://127.0.0.1:10808'):'direct'; let proxy='pool:'+g+'|egress='+egress; if(cc) proxy+='|cc='+cc; finish({proxy:proxy}); }; }); }