function formatPAN(pan){
const d=String(pan||'').replace(/\D/g,'');
if(!d) return '';
return d.replace(/(\d{4})(?=\d)/g,'$1 ');
}
function cardPAN(c){ return formatPAN(c.pan) || c.last4 || ''; }
function visibleCards(){ return (cache.cards||[]).filter(c=>!c.hidden); }
function cardLabel(c){
return (c.brand||c.scheme||'卡')+' '+cardPAN(c)+' '+String(c.exp_month).padStart(2,'0')+'/'+String(c.exp_year).slice(-2)+(c.bank?(' '+c.bank):'');
}
async function bind(ev,id){ const btn=clickBtn(ev);
const acc=(cache.accounts||[]).find(a=>a && a.id===id) || {};
if(!(cache.cards||[]).length){
const cards=await api('/api/cards');
if(Array.isArray(cards)) cache.cards=cards.filter(Boolean);
}
const cards=visibleCards();
const opts=cards.map(c=>'').join('');
const loginProxy=String(acc.effective_proxy||acc.proxy||meta.default_proxy||'').trim();
let groups=[];
try{ const st=await api('/api/settings'); groups=(st.proxy_groups||[]).filter(g=>g && (g.proxies||[]).length); }catch(e){}
const groupOpts=groups.map(g=>'').join('');
const root=$('#modalRoot');
root.innerHTML='
';
const finish=v=>{ root.innerHTML=''; return v; };
bindMaskClose(root, ()=>finish(false));
const sel=$('#bindVault');
const triple=$('#bindTriple');
if(triple){
const fillTriple=()=>{ const got=parseCardTriple(triple.value); if(!got) return; $('#bindPan').value=got.pan; $('#bindExp').value=got.exp; $('#bindCvc').value=got.cvc; };
triple.addEventListener('input', fillTriple);
triple.addEventListener('paste', ()=>setTimeout(fillTriple,0));
}
const sync=()=>{
const on=!!sel.value;
$('#bindPan').disabled=on; $('#bindExp').disabled=on; $('#bindCvc').disabled=on;
if(triple) triple.disabled=on;
};
sel.onchange=sync; sync();
const useLogin=$('#bindUseLogin');
if(useLogin) useLogin.onclick=()=>{ $('#bindProxy').value=loginProxy; };
const countryRows=()=>{
const g=groups.find(x=>(x.name||'')===(($('#bindPool')||{}).value||''));
const lines=(g&&g.proxies||[]).join('\n');
const counts=proxyCountryCounts(lines);
return Object.keys(counts).filter(k=>k!=='未识别').sort().map(k=>({cc:k,n:counts[k]}));
};
const paintCountries=()=>{
const rows=countryRows();
const q=String(($('#bindCCFilter')||{}).value||'').trim().toUpperCase();
const shown=q?rows.filter(r=>r.cc.indexOf(q)===0||r.cc.indexOf(q)>=0):rows;
const box=$('#bindCC');
const current=box.dataset.cc||'';
const pick=shown.some(r=>r.cc===current)?current:(shown[0]?shown[0].cc:'');
box.dataset.cc=pick;
box.innerHTML=shown.map(r=>'').join('') || '没有匹配的国家
';
box.querySelectorAll('[data-cc]').forEach(btn=>{ btn.onclick=()=>{ box.dataset.cc=btn.getAttribute('data-cc'); paintCountries(); }; });
$('#bindPoolHint').textContent=rows.length?(shown.length+' / '+rows.length+' 个国家,输入代码即可定位'):'代理用户名里没有 country-xx 或 region-xx';
};
const fillCountries=()=>{ const f=$('#bindCCFilter'); if(f) f.value=''; paintCountries(); };
$('#bindPool').onchange=fillCountries;
$('#bindCCFilter').addEventListener('input', paintCountries);
$('#bindProxyMode').onchange=()=>{
const pool=$('#bindProxyMode').value==='pool';
$('#bindPoolBox').hidden=!pool;
$('#bindManualBox').hidden=pool;
};
fillCountries();
const done=await new Promise(resolve=>{
$('#mOk').onclick=()=>resolve(true);
$('#mCancel').onclick=()=>resolve(false);
});
if(!done){ finish(); return; }
const okBtn=$('#mOk');
applyBtnBusy(okBtn, '检测出口');
let proxy='';
if($('#bindProxyMode').value==='pool'){
const g=($('#bindPool')||{}).value||'';
const cc=String(($('#bindCC')||{}).dataset.cc||'').trim().toUpperCase();
if(!g){ unmarkBtnBusy('', okBtn); return fail('无法绑卡','先选一个代理组'); }
if(!/^[A-Z]{2}$/.test(cc)){ unmarkBtnBusy('', okBtn); return fail('无法绑卡','先选出口国家代码'); }
const via=$('#bindPoolVia') && $('#bindPoolVia').checked;
proxy='pool:'+g+'|cc='+cc+'|egress='+(via?(meta.default_proxy||'http://127.0.0.1:10808'):'direct');
}else proxy=$('#bindProxy').value.trim();
if(!proxy || /^direct$/i.test(proxy) || proxy==='-'){
unmarkBtnBusy('', okBtn);
return fail('无法绑卡','必须填写代理 IP,绑卡出口会改账号国家');
}
const cardId=Number(sel.value||0);
let body={proxy, mode: (($('input[name=bindMode]:checked')||{}).value||'wallet')};
if(cardId){ body.card_id=cardId; }
else {
const pan=$('#bindPan').value.trim();
const exp=$('#bindExp').value.trim();
const cvc=$('#bindCvc').value.trim();
if(!pan){ unmarkBtnBusy('', okBtn); return fail('无法绑卡','请选择已保存的卡,或填写卡号'); }
const parts=(exp||'').split('/');
const mm=Number(parts[0]); let yy=Number(parts[1]); if(yy<100) yy+=2000;
body.pan=pan; body.exp_month=mm; body.exp_year=yy; body.cvc=cvc;
}
$('#bindGeoHint').textContent='正在检测出口 IP 归属地…';
const geo=await api('/api/accounts/'+id+'/egress',{method:'POST',body:JSON.stringify({proxy})});
if(geo.error){ unmarkBtnBusy('', okBtn); $('#bindGeoHint').textContent=geo.error; return fail('无法检测出口', geo.error); }
const eg=geo.egress||{};
const hits=(eg.hits||[]).map(h=>h.error? (h.source+': 失败') : (h.source+' '+((h.country||'-')+(h.city?('/'+h.city):'')))).join(';');
const accCC=String(geo.account_country||acc.play_country||'').toUpperCase();
const outCC=String(eg.country||'').toUpperCase();
const place=[outCC, eg.region, eg.city].filter(Boolean).join(' / ') || '未知';
const mismatch=!!geo.mismatch || (accCC && outCC && accCC!==outCC);
$('#bindGeoHint').textContent='出口 '+((eg.ip||'-')+' · '+place);
body.proxy=proxy;
body.egress_ip=eg.ip||'';
body.egress_country=outCC;
body.egress_city=eg.city||'';
body.egress_region=eg.region||'';
let msg='当前出口 IP '+(eg.ip||'-')+',归属地 '+place+(eg.agree?('('+eg.agree+' 家接口一致)'):'')+'。\n探测:'+hits;
if(mismatch){
msg='账号已有绑卡国家 '+countryLabel(accCC)+',本次出口是 '+countryLabel(outCC)+'。不一致会改账号地区,请确认是否继续。\n\n'+msg;
}
const ok=await modal({
stack:true,
title: mismatch ? '出口国家与账号不一致' : '确认绑卡出口',
message: msg,
okText: mismatch ? '仍要绑定' : '确认并绑定',
cancelText: '取消'
});
if(!ok){ unmarkBtnBusy('', okBtn); return; }
finish();
const j=await api('/api/accounts/'+id+'/instruments',{method:'POST',body:JSON.stringify(body)});
if(j.error) return fail('绑卡失败', j.error);
watchAccountJob(id, 'bind', j.job_id, '绑卡');
}
async function redeemGift(ev,id){
const acc=(cache.accounts||[]).find(a=>a && a.id===id) || {};
const root=$('#modalRoot');
root.innerHTML='兑换礼品卡
'+
'
'+
'
'+esc(acc.email||('#'+id))+'国家 '+esc(playCountry(acc))+' · Play 余额 读取中
'+
'
'+
'
先向 Google 确认面额和到账账号,确认后才真正兑换。
'+
'
'+
'
'+
'
';
const close=()=>{ document.removeEventListener('keydown', onKey); root.innerHTML=''; };
const onKey=e=>{ if(e.key==='Escape'){ e.preventDefault(); close(); } };
document.addEventListener('keydown', onKey);
bindMaskClose(root, close);
api('/api/accounts/'+id+'/balance').then(d=>{
const el=$('#redeemBal');
if(!el) return;
el.textContent=(d&&d.play_balance)?d.play_balance:'未知';
}).catch(()=>{ const el=$('#redeemBal'); if(el) el.textContent='未知'; });
const input=$('#redeemCode');
if(input){ input.focus(); input.addEventListener('keydown', e=>{ if(e.key==='Enter'){ e.preventDefault(); $('#mOk').click(); } }); }
$('#mCancel').onclick=close;
$('#mOk').onclick=async()=>{
const code=String(($('#redeemCode')||{}).value||'').trim();
if(!code){ $('#redeemOut').innerHTML='还没有码先填入礼品卡码
'; return; }
const okBtn=$('#mOk');
applyBtnBusy(okBtn, '兑换中');
$('#redeemOut').innerHTML='正在向 Google 确认这张卡…
';
const j=await api('/api/accounts/'+id+'/redeem',{method:'POST',body:JSON.stringify({code})});
if(j.error){
unmarkBtnBusy('', okBtn);
$('#redeemOut').innerHTML='没有提交'+esc(j.error)+'
';
return;
}
const t0=Date.now();
while(Date.now()-t0<15*60*1000){
const job=await api('/api/jobs/'+j.job_id);
const step=jobStepText(job);
if(step) applyBtnBusy(okBtn, step);
if(job && job.status==='challenge_required'){
unmarkBtnBusy('', okBtn);
$('#redeemOut').innerHTML='需要验证'+esc(job.challenge_hint||job.error||'请先完成账号验证')+'
';
return;
}
if(job && jobDone(job)){
unmarkBtnBusy('', okBtn);
const raw=job.result;
let res={};
if(typeof raw==='string'){ try{ res=JSON.parse(raw)||{}; }catch(e){ res={}; } }
else if(raw && typeof raw==='object') res=raw;
const ok=job.status==='succeeded';
const rows=[
['礼品卡', res.code||j.code||code],
['到账账号', res.email||acc.email||''],
['金额', res.amount||''],
['结果', ok?'已兑换':(res.message||job.error||'失败')]
].filter(r=>r[1]);
$('#redeemOut').innerHTML=''+(ok?'兑换成功':'兑换失败')+''+
rows.map(r=>'
'+esc(r[0])+''+esc(r[1])+'
').join('')+
'
';
okBtn.textContent='再兑一张';
return;
}
await new Promise(r=>setTimeout(r, 1200));
}
unmarkBtnBusy('', okBtn);
$('#redeemOut').innerHTML='仍在处理任务 #'+j.job_id+' 还没结束,可稍后在任务记录查看
';
};
}
function offerKindName(k){ return ({trial:'试用', intro:'优惠价', promo:'促销', base:'原价', one_time:'一次性'})[k]||k||'方案'; }
function offerIdentity(o){
return [o.offer_token,o.offer_id,o.base_plan_id,o.offer_kind,o.price,o.intro_price,o.billing_period,o.intro_period,o.intro_cycles].join('|');
}
function offerPlanName(o){
return o.offer_id||o.base_plan_id||'';
}
function offerLine(o){
const kind=offerKindName(o.offer_kind);
const dur=offerDuration(o);
const plan=offerPlanName(o);
return kind+' · '+offerPrice(o)+(dur?(' · '+dur):'')+(plan?(' · '+plan):'');
}
let fxRates=null;
function fxMoney(amount, digits){
const n=Number(amount);
if(!isFinite(n)) return '-';
return n.toLocaleString('zh-CN', {minimumFractionDigits:digits, maximumFractionDigits:digits});
}
function fxOf(o){
if(!fxRates || !o) return null;
const cur=String(o.currency||'').toUpperCase();
const per=fxRates[cur];
const micros=Number(o.price_amount_micros||0);
if(!per || !micros) return null;
const major=micros/1e6;
return {cny:major*per.cny, usd:major*per.usd};
}
function fxText(v){
if(!v) return '';
return '¥'+fxMoney(v.cny, 2)+' · $'+fxMoney(v.usd, 2)+'
';
}
function skuCard(s){
const offs=(s.offers||[]).filter((o,i,all)=>{
const key=offerIdentity(o);
return all.findIndex(x=>offerIdentity(x)===key && String(x.country||'')===String(o.country||''))===i;
});
let kids='';
if(!offs.length){
kids='暂无报价
';
}else{
const plans={};
offs.forEach(o=>{ const k=o.base_plan_id||'方案'; (plans[k]=plans[k]||[]).push(o); });
const byCountry=list=>{
const by={};
list.forEach(o=>{ const cc=String(o.country||'').toUpperCase(); if(!/^[A-Z]{2}$/.test(cc) || cc==='XX') return; if(!by[cc]) by[cc]=o; });
return by;
};
const planNames=Object.keys(plans).sort();
const tabs=''+planNames.map((plan,i)=>'').join('')+'
';
kids=tabs+planNames.map((plan,i)=>{
const list=plans[plan];
const base=byCountry(list.filter(o=>!o.offer_id));
const offers={};
list.filter(o=>o.offer_id).forEach(o=>{ (offers[o.offer_id]=offers[o.offer_id]||[]).push(o); });
const offerIds=Object.keys(offers).sort();
const ccs=[...new Set(Object.keys(base).concat(offerIds.flatMap(id=>Object.keys(byCountry(offers[id])))))];
ccs.sort((a,b)=>{
const av=fxOf(base[a]); const bv=fxOf(base[b]);
if(av && bv && av.cny!==bv.cny) return av.cny-bv.cny;
if(av && !bv) return -1;
if(!av && bv) return 1;
return a.localeCompare(b);
});
const head='国家 | 计费周期 | 方案价格 | '+offerIds.map(id=>''+esc(id)+' '+esc(offerKindName((offers[id][0]||{}).offer_kind))+' | ').join('');
const rows=ccs.map(cc=>{
const b=base[cc];
const cells=offerIds.map(id=>{
const o=(byCountry(offers[id]))[cc];
if(!o) return '- | ';
const intro=o.intro_price && o.intro_price!==o.price;
return ''+(intro?esc(o.intro_price)+' 之后 '+esc(offerPrice(o))+' ':esc(offerPrice(o)))+fxText(fxOf(o))+' | ';
}).join('');
return '| '+esc(countryLabel(cc))+' | '+esc(offerDuration(b||list[0])||'-')+' | '+(b?esc(offerPrice(b))+fxText(fxOf(b)):'-')+' | '+cells+'
';
}).join('');
return '';
}).join('');
}
const retired=String(s.note||'').indexOf('作废')>=0;
const name=retired?''+esc(s.title||s.sku)+'':esc(s.title||s.sku);
const sub=retired?''+esc(s.sku)+' · '+esc(s.product_type||'')+'':esc(s.sku)+' · '+esc(s.product_type||'');
return ''+name+''+sub+(s.note?(' · '+esc(s.note)):'')+'
'+kids+'
';
}
function toggleSkuPrices(id, btn){
const box=document.getElementById('skuPrices'+id);
if(!box) return;
box.hidden=!box.hidden;
if(btn) btn.classList.toggle('on', !box.hidden);
}
function appIdentity(pkg){ return (cache.apps||[]).find(a=>a.package===pkg)||null; }
function skuPriceCountries(list){
const ccs=new Set();
(list||[]).forEach(s=>(s.offers||[]).forEach(o=>{
const cc=String(o.country||'').trim().toUpperCase();
if(/^[A-Z]{2}$/.test(cc) && cc!=='XX') ccs.add(cc);
}));
return [...ccs];
}
function accountForCountry(cc){
const ready=(cache.accounts||[]).filter(a=>a && (a.status==='ready'||a.status==='authenticated'));
return ready.find(a=>String(a.play_country||'').toUpperCase()===cc) || null;
}
async function refreshSkuPrice(pkg){
const all=(cache.skus||[]).filter(s=>s && s.package===pkg && s.sku);
if(!all.length) return fail('无法更新','这个应用没有 SKU');
let ccs=skuPriceCountries(all);
if(!ccs.length){
const any=accountForCountry('') || (cache.accounts||[]).find(a=>a && (a.status==='ready'||a.status==='authenticated'));
const cc=String((any&&any.play_country)||'').toUpperCase();
if(!/^[A-Z]{2}$/.test(cc)) return fail('无法更新','没有已登录账号');
ccs=[cc];
}
const byType={};
all.filter(s=>String(s.note||'').indexOf('作废')<0).forEach(s=>{ const t=s.product_type||'subs'; (byType[t]=byType[t]||[]).push(s.sku); });
const missing=ccs.filter(cc=>!accountForCountry(cc));
if(missing.length) notice('部分国家没有账号', missing.join('、'), 'err');
const todo=ccs.filter(cc=>accountForCountry(cc));
if(!todo.length) return fail('无法更新','这些国家都没有已登录账号');
notice('正在更新价格', pkg+' · '+todo.join('、'));
let failed=0;
for(const cc of todo){
const acc=accountForCountry(cc);
for(const t of Object.keys(byType)){
const j=await api('/api/sku/details',{method:'POST',body:JSON.stringify({account_id:acc.id, package:pkg, product_type:t, skus:byType[t], refresh:true})});
if(j.error){ failed++; notice('无法更新', cc+' '+j.error, 'err'); continue; }
const done=await waitJob(j.job_id, '更新价格 '+cc);
if(!done || done.status!=='succeeded') failed++;
}
}
await loadAll(); render();
if(!failed) notice('价格已更新', pkg+' · '+todo.join('、'));
}
async function refreshApp(pkg){
const ready=(cache.accounts||[]).find(a=>a.status==='ready'||a.status==='authenticated');
if(!ready) return fail('无法更新','需要一个已登录账号');
const j=await api('/api/apps/refresh',{method:'POST',body:JSON.stringify({account_id:ready.id, package:pkg})});
if(j.error) return fail('无法更新', j.error);
notice('正在更新', pkg);
await waitJob(j.job_id, '更新应用');
await loadAll(); render();
}
function fxSummary(){
if(!fxRates) return '汇率未加载';
const cny=fxRates.USD&&fxRates.USD.cny;
const when=fxRates._at?new Date(fxRates._at).toLocaleString('zh-CN',{hour12:false}):'';
return '1 USD = ¥'+(cny?fxMoney(cny,4):'-')+(when?(' · '+when):'');
}
async function loadFx(){
try{
const saved=JSON.parse(localStorage.getItem('playpay_fx')||'null');
if(saved && saved.USD) fxRates=saved;
}catch(e){}
}
async function refreshFx(){
notice('正在刷新汇率', '');
let data=null;
try{
const res=await fetch('https://open.er-api.com/v6/latest/USD');
data=await res.json();
}catch(e){
return fail('无法刷新汇率', '网络不可用');
}
const usd=data&&data.rates;
if(!usd || !usd.CNY) return fail('无法刷新汇率', '没有返回汇率');
const rates={_at:Date.now()};
Object.keys(usd).forEach(cur=>{
const perUsd=Number(usd[cur]);
if(perUsd>0) rates[cur]={usd:1/perUsd, cny:usd.CNY/perUsd};
});
rates.USD={usd:1, cny:Number(usd.CNY)};
rates.CNY={usd:1/Number(usd.CNY), cny:1};
fxRates=rates;
localStorage.setItem('playpay_fx', JSON.stringify(rates));
notice('汇率已更新', fxSummary());
render(false);
}
function viewSkus(){
if(!fxRates) loadFx();
const pkgs=sortApps([...new Set((cache.skus||[]).map(s=>s.package).filter(Boolean))]);
const skuRetired=s=>String(s&&s.note||'').indexOf('作废')>=0;
const filtered=(cache.skus||[]).filter(s=>{
if(filters.skuPkg && s.package!==filters.skuPkg) return false;
return matchQ([s.title,s.package,s.sku,s.product_type,s.note,String(s.id)].concat((s.offers||[]).map(o=>[o.country,o.price,o.intro_price,o.offer_kind].join(' '))));
}).slice().sort((a,b)=>(skuRetired(a)-skuRetired(b)) || ((b.offers||[]).length>0)-((a.offers||[]).length>0) || String(a.title||a.sku).localeCompare(String(b.title||b.sku),'zh'));
const grouped={};
filtered.forEach(s=>{ const pkg=s.package||'(无包名)'; (grouped[pkg]=grouped[pkg]||[]).push(s); });
const pkgKeys=sortApps(Object.keys(grouped));
const info=paginate(pkgKeys,'skus');
const pageKeys=info.slice;
const blocks=pageKeys.map(pkg=>{
const items=grouped[pkg];
const app=appIdentity(pkg);
const ver=app&&Number(app.version_code)>0?('版本 '+app.version_code):'版本未更新';
const md5=app&&app.cert_md5?app.cert_md5:'签名未更新';
const sha=app&&app.cert_sha256?('SHA256 '+esc(app.cert_sha256)+'
'):'';
const tabs=items.map((s,i)=>{
const retired=String(s.note||'').indexOf('作废')>=0;
return '';
}).join('');
const panes=items.map((s,i)=>''+skuCard(s)+'
').join('');
const ack='';
return ''+esc(pkg)+' · '+items.length+' 个 SKU
'+esc(ver)+' · MD5 '+esc(md5)+'
'+sha+'
'+ack+'
'+tabs+'
'+panes+'
';
}).join('');
$('#app').innerHTML =
''+
''+
''+
''+fxSummary()+''+
'
'+
'
'+
filterSelect('包名', 'filters.skuPkg=this.value; pageNo.skus=1; render(false)', ''+pkgs.map(p=>'').join(''))+
'当前 '+filtered.length+' / '+(cache.skus||[]).length+''+
'
'+
(blocks||'
还没有 SKU。先添加,例如 ChatGPT Go。
')+
'
'+pager('skus', info);
document.querySelectorAll('.sku-tab').forEach(btn=>{
btn.onclick=()=>{
const pkg=btn.closest('.sku-pkg');
pkg.querySelectorAll('.sku-tab').forEach(b=>b.classList.toggle('on', b===btn));
pkg.querySelectorAll('.sku-pane').forEach(p=>p.hidden=p.getAttribute('data-sku-pane')!==btn.getAttribute('data-sku-tab'));
};
});
document.querySelectorAll('[data-auto-ack]').forEach(el=>{
el.onchange=async()=>{
const pkg=el.getAttribute('data-auto-ack');
const on=!!el.checked;
const j=await api('/api/apps/auto-ack',{method:'POST',body:JSON.stringify({package:pkg, auto_ack:on})});
if(j.error){ el.checked=!on; return fail('无法保存', j.error); }
const app=(cache.apps||[]).find(a=>a && a.package===pkg);
if(app) app.auto_ack=on;
else cache.apps=(cache.apps||[]).concat([{package:pkg, auto_ack:on}]);
notice(on?'已开启自动确认':'已关闭自动确认', pkg);
};
});
document.querySelectorAll('[data-plan-tab]').forEach(btn=>{
btn.onclick=()=>{
const box=btn.closest('.sku-kids');
box.querySelectorAll('[data-plan-tab]').forEach(b=>b.classList.toggle('on', b===btn));
box.querySelectorAll('[data-plan-pane]').forEach(p=>p.hidden=p.getAttribute('data-plan-pane')!==btn.getAttribute('data-plan-tab'));
};
});
}