let extendRoutes=[]; let extendDays=15; function defaultExtendRoutes(){ return [ {package:'ai.x.grok', from:'grok.lite', to:'x.subscription.premium', plan:'p1m'}, {package:'ai.x.grok', from:'grok.pro', to:'x.subscription.premium', plan:'p1m'}, {package:'ai.x.grok', from:'grok.ultra', to:'x.subscription.premium', plan:'p1m'}, {package:'ai.x.grok', from:'x.subscription.premium', to:'x.subscription.ultra', plan:'p1m'}, {package:'ai.x.grok', from:'x.subscription.ultra', to:'x.subscription.premium', plan:'p1m'} ]; } function routeFor(pkg, sku){ const rows=(extendRoutes||[]).filter(r=>r && r.package===pkg); const edges=[]; rows.forEach(r=>{ if(r.chain){ const steps=decodeChain(r.chain); if(steps.length>1 && steps[0].sku===sku) edges.push({from:steps[0].sku, to:steps[1].sku}); return; } if(r.from && r.to) edges.push({from:r.from, to:r.to}); }); return edges.filter(e=>e.from===sku && e.to); } function routeDest(pkg, sku){ const hit=routeFor(pkg, sku)[0]; return hit?hit.to:''; } function planKey(o){ return [o.base_plan_id||'', o.offer_id||'', o.offer_kind||''].join('|'); } function planLabel(o){ const name=o.offer_id||o.base_plan_id||'方案'; return name+' · '+(offerKindName(o.offer_kind)||'')+' · '+(offerPrice(o)||'-')+' · '+(offerDuration(o)||'-'); } function plansOf(pkg, sku){ const row=(cache.skus||[]).find(s=>s && s.package===pkg && s.sku===sku); const seen={}; return ((row&&row.offers)||[]).filter(o=>{ const cc=String(o.country||'').toUpperCase(); if(cc && cc!=='US' && cc!=='XX') return false; const k=planKey(o); if(seen[k]) return false; seen[k]=true; return true; }); } function extendDue(){ const now=Date.now(); const until=now+Number(extendDays||15)*86400000; const ready=new Set((cache.accounts||[]).filter(a=>a && (a.status==='ready'||a.status==='authenticated')).map(a=>a.id)); return (cache.subs||[]).filter(s=>{ if(!s || s.status!=='active' || !ready.has(s.account_id)) return false; if(!routeDest(s.package, s.sku)) return false; let exp=Number(s.expiry||0); if(!exp) return false; if(exp<1e11) exp*=1000; return exp<=until; }).sort((a,b)=>Number(a.expiry)-Number(b.expiry)); } function cardLast4Of(s){ const rec=(cache.purchases||[]).find(p=>p && p.account_id===s.account_id && p.order_id && p.order_id===s.order_id && p.card_last4); if(rec) return rec.card_last4; const acc=(cache.accounts||[]).find(a=>a && a.id===s.account_id); const card=((acc&&acc.cards)||[]).find(c=>c && c.last4); return card?card.last4:''; } function extendConcurrency(s){ const last4=cardLast4Of(s); const card=(cache.cards||[]).find(c=>c && String(c.last4||'')===String(last4||'')); if(!card || card.no_replay_concurrency) return 1; return 5; } function offerFor(pkg, sku, cc, plan){ const row=(cache.skus||[]).find(s=>s && s.package===pkg && s.sku===sku); const offs=(row&&row.offers)||[]; const want=String(plan||'p1m'); const match=offs.filter(o=>planKey(o)===want || (o.base_plan_id||'')===want || (o.offer_id||'')===want); const pool=match.length?match:offs.filter(o=>!o.offer_id && (o.base_plan_id==='p1m' || !o.base_plan_id)); const hit=pool.find(o=>String(o.country||'').toUpperCase()===cc) || pool.find(o=>String(o.country||'').toUpperCase()==='US') || pool[0]; return (hit&&(hit.offer_token||hit.token)) || (row&&row.offer_token) || ''; } let extendPkg=''; let extendChains={}; let flowPick=''; function skuLive(s){ return s && String(s.note||'').indexOf('作废')<0; } function skuOfPkg(pkg){ return (cache.skus||[]).filter(s=>s.package===pkg && skuLive(s)); } function flowPackages(){ const fromSku=[...new Set((cache.skus||[]).map(s=>s.package).filter(Boolean))]; const fromRoute=(extendRoutes||[]).map(r=>r.package).filter(Boolean); return [...new Set(fromSku.concat(fromRoute))].sort(); } function shortSku(sku){ return String(sku||'').replace(/^x\.subscription\./,'x.'); } function decodeChain(raw){ const parts=String(raw||'').split('\t'); if(!parts[0]) return []; const steps=[{sku:parts[0]}]; for(let i=1;i+2r && r.package===pkg && !r.chain && r.from && r.to); const chains=live.map(sku=>[{sku}]); const tail=i=>chains[i][chains[i].length-1].sku; edges.forEach(hop=>{ let idx=-1; chains.forEach((steps,i)=>{ if(idx<0 && tail(i)===hop.from && steps.length<12) idx=i; }); if(idx<0) return; chains[idx].push({sku:hop.to, plan:hop.plan||'', mode:Number(hop.mode||1)}); }); return chains; } function ensureChains(pkg){ if(extendChains[pkg]) return extendChains[pkg]; const live=skuOfPkg(pkg).map(s=>s.sku); const saved=(extendRoutes||[]).filter(r=>r && r.package===pkg && r.chain); const chains=saved.length ? saved.map(r=>decodeChain(r.chain)).filter(steps=>steps.length) : legacyChains(pkg, live); const have={}; chains.forEach(steps=>{ if(steps[0]) have[steps[0].sku]=true; }); live.forEach(sku=>{ if(!have[sku]) chains.push([{sku}]); }); extendChains[pkg]=chains; return chains; } function chainsToRoutes(){ const rows=[]; Object.keys(extendChains).forEach(pkg=>{ ensureChains(pkg).forEach(steps=>{ if(steps.length<2 || !steps[0] || !steps[0].sku) return; if(steps.some(st=>!st || !st.sku)) return; rows.push({package:pkg, chain:encodeChain(steps)}); }); }); return rows; } function skuUsd(pkg, sku){ let rates=null; try{ rates=JSON.parse(localStorage.getItem('playpay_fx')||'null'); }catch(e){} const row=(cache.skus||[]).find(s=>s && s.package===pkg && s.sku===sku)||{}; let best=Infinity; ((row.offers||[]).filter(o=>!o.offer_id)).forEach(o=>{ const per=rates && rates[String(o.currency||'').toUpperCase()]; const micros=Number(o.price_amount_micros||0); if(per && micros) best=Math.min(best, micros/1e6*per.usd); }); return best; } function flowPlanSelect(pkg, sku, cur){ const plans=plansOf(pkg, sku); if(!plans.length) return ''; return plans.map(o=>'').join(''); } function paintFlow(){ const board=document.getElementById('flowBoard'); if(!board) return; const pkg=extendPkg; const chains=ensureChains(pkg); const live=skuOfPkg(pkg).map(s=>s.sku); let maxX=640, maxY=28; const nodes=[], paths=[], tags=[]; chains.forEach((steps,ci)=>{ const y=28+ci*132; maxY=Math.max(maxY, y+120); steps.forEach((step,si)=>{ const placed=step.pos; const x=placed?placed.x:28+si*340; const yy=placed?placed.y:y; maxX=Math.max(maxX, x+230); maxY=Math.max(maxY, yy+120); const id=ci+':'+si; const plan=si>0?'
':''; nodes.push('
'+esc(shortSku(step.sku))+''+(si>0?'':'')+'
'+plan+(si===steps.length-1?'':'')+'
'); if(si>0){ const prev=steps[si-1].pos||{x:28+(si-1)*340, y:y}; const x1=prev.x+196, y1=prev.y+36, x2=x, y2=yy+36; const c=Math.max(36, Math.abs(x2-x1)/2); const midX=(x1+x2)/2, midY=(y1+y2)/2; paths.push(''); const mode=Number(step.mode||1); tags.push('
'); } }); }); if(flowPick){ const priced=live.map(sku=>({sku, usd:skuUsd(pkg, sku)})).sort((a,b)=>a.usd-b.usd || a.sku.localeCompare(b.sku)); const known=priced.filter(p=>p.usd{ const t=max>min && p.usd'+esc(shortSku(p.sku))+''; }).join('')+''; nodes.push(box); } const viewH=document.fullscreenElement===board ? window.innerHeight : Math.max(board.clientHeight||0, Math.round(window.innerHeight*0.72), 560); board.style.height=Math.max(maxY+40, viewH)+'px'; board.innerHTML=''+paths.join('')+''+nodes.join('')+tags.join(''); bindFlow(board); } function chainAt(id){ const parts=String(id||'').split(':'); const chains=ensureChains(extendPkg); const steps=chains[Number(parts[0])]; return {chains, steps, ci:Number(parts[0]), si:Number(parts[1])}; } function bindFlow(board){ board.querySelectorAll('[data-plan]').forEach(el=>{ el.onchange=()=>{ const at=chainAt(el.dataset.plan); if(at.steps && at.steps[at.si]) at.steps[at.si].plan=el.value; extendRoutes=chainsToRoutes(); }; el.onpointerdown=ev=>ev.stopPropagation(); }); board.querySelectorAll('[data-mode]').forEach(el=>{ el.onchange=()=>{ const at=chainAt(el.dataset.mode); if(at.steps && at.steps[at.si]) at.steps[at.si].mode=Number(el.value)||1; extendRoutes=chainsToRoutes(); }; el.onpointerdown=ev=>ev.stopPropagation(); }); board.querySelectorAll('[data-cut]').forEach(el=>{ el.onclick=ev=>{ ev.stopPropagation(); const at=chainAt(el.dataset.cut); if(at.steps) at.steps.splice(at.si); extendRoutes=chainsToRoutes(); paintFlow(); }; }); board.querySelectorAll('[data-link]').forEach(el=>{ el.onclick=ev=>{ ev.stopPropagation(); flowPick=flowPick===el.dataset.link?'':el.dataset.link; paintFlow(); }; }); board.querySelectorAll('.flow-node').forEach(node=>{ node.onpointerdown=ev=>{ if(ev.button!==0 || ev.target.closest('button,select')) return; ev.preventDefault(); const startX=ev.clientX, startY=ev.clientY; const origin={x:node.offsetLeft, y:node.offsetTop}; node.classList.add('drag'); try{ node.setPointerCapture(ev.pointerId); }catch(err){} const move=e=>{ node.style.left=Math.max(8, origin.x+(e.clientX-startX))+'px'; node.style.top=Math.max(8, origin.y+(e.clientY-startY))+'px'; }; const up=()=>{ document.removeEventListener('pointermove', move); document.removeEventListener('pointerup', up); node.classList.remove('drag'); const at=chainAt(node.dataset.id); if(at.steps && at.steps[at.si]) at.steps[at.si].pos={x:node.offsetLeft, y:node.offsetTop}; paintFlow(); }; document.addEventListener('pointermove', move); document.addEventListener('pointerup', up); }; }); board.querySelectorAll('[data-pick]').forEach(el=>{ el.onclick=ev=>{ ev.stopPropagation(); const sku=el.dataset.pick; const at=chainAt(flowPick); flowPick=''; if(sku && at.steps){ const plans=plansOf(extendPkg, sku); at.steps.push({sku, plan:plans[0]?planKey(plans[0]):'', mode:1}); extendRoutes=chainsToRoutes(); } paintFlow(); }; }); } async function viewExtend(){ const dock=document.getElementById('pagerDock'); if(dock) dock.innerHTML=''; if(!extendRoutes.length){ const st=await api('/api/settings'); extendRoutes=(st.extend_routes&&st.extend_routes.length)?st.extend_routes:defaultExtendRoutes(); } const pkgs=flowPackages(); if(!extendPkg || pkgs.indexOf(extendPkg)<0) extendPkg=pkgs.indexOf('ai.x.grok')>=0?'ai.x.grok':(pkgs[0]||''); const due=extendDue(); const em=emails(); const rows=due.map(s=>{ const dest=routeDest(s.package, s.sku); return ''+esc(em[s.account_id]||('#'+s.account_id))+''+esc(s.package)+''+esc(shortSku(s.sku))+''+esc(shortSku(dest))+''+fmtUnix(s.expiry, true)+''+extendConcurrency(s)+''; }).join(''); const pkgBtns=pkgs.map(p=>'').join(''); $('#app').innerHTML= '

加时升级路径

'+ '
每个有效套餐一开始都是一条链的起点。点最后一格的「加下一段」接上下一个套餐,同一个套餐可以在一条链里出现多次。线上改方案,点 × 删掉这一步和它后面的。
'+ '
'+pkgBtns+'
'+ '
'+ '

半个月内到期

'+ '
只列有路径、仍有效、半月内到期的订阅。卡不在本系统卡库或该卡禁止并发时,并发为 1。
'+ '
'+ (rows||'')+ '
账号应用当前升到到期并发
没有快到期的订阅。
'; document.querySelectorAll('[data-flow-pkg]').forEach(el=>{ el.onclick=()=>{ extendPkg=el.getAttribute('data-flow-pkg'); viewExtend(); }; }); paintFlow(); } function toggleFlowFull(){ const board=document.getElementById('flowBoard'); if(!board) return; if(document.fullscreenElement) document.exitFullscreen(); else board.requestFullscreen(); } async function saveExtendRoutes(){ const rows=chainsToRoutes().filter(r=>{ const live=new Set(skuOfPkg(r.package).map(s=>s.sku)); return decodeChain(r.chain).every(st=>live.has(st.sku)); }); const j=await api('/api/settings',{method:'PUT',body:JSON.stringify({extend_routes:rows})}); if(j.error) return fail('无法保存', j.error); extendRoutes=rows; notice('路径已保存', rows.length+' 条'); paintFlow(); } async function runExtend(){ const due=extendDue(); if(!due.length) return fail('没有可升级的', '没有匹配路径的快到期订阅'); const ok=await modal({title:'按路径加时升级', message:'将对 '+due.length+' 个订阅按剩余时间折算升级。失败的会在任务里继续重试。', okText:'开始', cancelText:'取消'}); if(!ok) return; let n=0, bad=0; for(const s of due){ const steps=(ensureChains(s.package).find(c=>c[0] && c[0].sku===s.sku && c.length>1)||[]).slice(); const first=steps[1]||{}; const dest=first.sku||''; const acc=(cache.accounts||[]).find(a=>a && a.id===s.account_id)||{}; const cc=String(acc.play_country||'US').toUpperCase(); const offer=offerFor(s.package, dest, cc, first.plan); const hops=steps.slice(1).map(st=>({to:st.sku, plan:st.plan||'', mode:String(Number(st.mode||1))})); const path=[{sku:steps[0].sku}].concat(hops); const body={account_id:s.account_id, package:s.package, sku:dest, product_type:'subs', offer_token:offer, old_purchase_token:s.purchase_token||'', old_package:s.package, old_sku:s.sku, old_order_id:s.order_id||'', replacement_mode:Number(first.mode||1), upgrade:true, replay_attempts:3000, replay_concurrency:extendConcurrency(s), extend_chain:hops.slice(1), extend_path:path, extend_step:1}; const j=await api('/api/purchases',{method:'POST',body:JSON.stringify(body)}); if(j.error) bad++; else n++; } notice('已入队', n+' 个'+(bad?(',失败 '+bad+' 个'):'')); refreshList(); }