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
')+
''+
'最近保活 '+fmtTime(d.last_keepalive_at)+' · Play/钱包下次续期 '+fmtTime(d.next_keepalive_due||d.next_keepalive_at)+'
'+
'SID/LSID 按会话单独续期,过期时间与 Play token 不同
'+
'可用 Token '+esc(tokHint)+'
'+
'订阅
| 包名 | SKU | 状态 | 已确认 | 续订 | 时间 | 订单 | 来源 | |
'+subs+'
'+
'消费记录
';
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+''+
''+
''+
''+
''+
''+
'
'+
' | ID | 邮箱 | 登录状态 | 国家 | 代理 | 最近保活 | 操作 |
'+
(rows||'| 还没有账号。先添加或导入。 |
')+
'
'+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});
};
});
}