Files
pk-lab-xhs/web/app.js
T
codex-bot 36aee09dfa
Verify mini program and deploy preview / verify-deploy (push) Successful in 50s
feat: add animated choice modes and bracket editor
2026-09-19 04:11:21 +08:00

80 lines
16 KiB
JavaScript

import { animate, stagger } from 'motion';
(function () {
'use strict';
var root = document.getElementById('app');
var apiBase = (window.PK_CONFIG.apiBase || '').replace(/\/$/, '');
var state = { tests: [], test: null, bracket: null, result: null, template: 'celebration', sticker: true, mode: 'single', bracketLayout: 'symmetric', bracketView: 'overview' };
function toast(message) { var el = document.getElementById('toast'); el.textContent = message; el.classList.add('show'); setTimeout(function () { el.classList.remove('show'); }, 2200); }
function escapeHtml(value) { return String(value).replace(/[&<>"']/g, function (char) { return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[char]; }); }
function request(path, options) {
options = options || {}; var token = localStorage.getItem('pk_token');
var headers = {'Content-Type':'application/json'}; if (token) headers.Authorization = 'Bearer ' + token;
return fetch(apiBase + path, {method:options.method || 'GET',headers:headers,body:options.data ? JSON.stringify(options.data) : undefined}).then(function (response) {
return response.json().catch(function () { return {}; }).then(function (data) { if (!response.ok) throw new Error(data.detail || '请求失败'); return data; });
});
}
function ensureLogin() {
if (localStorage.getItem('pk_token')) return Promise.resolve();
var id = localStorage.getItem('pk_preview_id') || (Date.now().toString(36) + Math.random().toString(36).slice(2)); localStorage.setItem('pk_preview_id', id);
return request('/api/v1/auth/xhs', {method:'POST',data:{app_key:'hub',code:'dev:web-' + id}}).then(function (data) { localStorage.setItem('pk_token', data.token); });
}
function loadHome() {
root.innerHTML = '<div class="loading">正在摆好擂台…</div>';
request('/api/v1/tests').then(function (tests) { state.tests = tests; renderHome(); }).catch(renderError);
}
function renderHome() {
root.innerHTML = '<div class="shell home-shell"><div class="mast"><div class="logo-mark">PK</div><strong>偏爱研究所</strong></div><label class="search"><span>⌕</span><input id="searchTests" placeholder="搜索测试"></label><section class="grid">' + state.tests.map(function (test) {
return '<button class="test-card" data-search="' + escapeHtml(test.title + ' ' + test.subtitle) + '" data-slug="' + test.slug + '"><img class="art" src="./assets/test-covers/' + test.slug + '.jpg" alt=""><div class="card-body"><h3>' + escapeHtml(test.title) + '</h3><p>' + escapeHtml(test.subtitle) + '</p><div class="meta"><span>' + test.candidate_count + ' 强</span><span>' + test.run_count + ' 人完成</span></div></div></button>';
}).join('') + '</section></div>';
root.querySelectorAll('.test-card').forEach(function (el) { el.addEventListener('click', function () { loadTest(el.dataset.slug); }); });
document.getElementById('searchTests').addEventListener('input', function (event) { var value = event.target.value.toLowerCase(); root.querySelectorAll('.test-card').forEach(function (card) { card.hidden = card.dataset.search.toLowerCase().indexOf(value) < 0; }); });
animate('.test-card', { opacity: [0, 1], y: [20, 0] }, { delay: stagger(.045), duration: .35 });
}
function buildMatches(pool) { var matches=[];for(var i=0;i<pool.length;i+=2)matches.push({left:pool[i],right:pool[i+1]||null,winner:pool[i+1]?null:pool[i]});return matches; }
function loadTest(slug) { root.innerHTML = '<div class="loading">正在抽签…</div>'; request('/api/v1/tests/' + slug).then(function (test) { state.test = test; state.bracket = {round:1,pool:test.candidates.slice(),matches:buildMatches(test.candidates),eliminated:[],decisions:[],offset:0}; renderPair(); }).catch(renderError); }
function pair() { var match=state.bracket.matches.find(function(item){return item.right&&!item.winner;});return match?[match.left,match.right]:null; }
function renderPair() {
var items = pair(), done = state.bracket.decisions.length, total = state.test.candidates.length - 1;
var tabs='<div class="mode-tabs"><button data-mode="single">一对</button><button data-mode="multi">多对</button><button data-mode="swipe">滑动</button></div>';
var content;
if(state.mode==='multi'){var block=state.bracket.matches.slice(state.bracket.offset,state.bracket.offset+4);content='<div class="multi-pairs">'+block.map(function(m,i){var mi=state.bracket.offset+i;return m.winner?'<div class="mini-pair winner">晋级<br><strong>'+escapeHtml(m.winner.name)+'</strong></div>':'<div class="mini-pair"><button data-match="'+mi+'" data-side="0">'+escapeHtml(m.left.name)+'</button><span>VS</span><button data-match="'+mi+'" data-side="1">'+escapeHtml(m.right.name)+'</button></div>';}).join('')+'</div>';}else{content='<div class="pair swipeable"><button class="choice" data-side="0"><span class="choice-letter">A</span><strong class="choice-name">'+escapeHtml(items[0].name)+'</strong><span class="choice-hint">'+(state.mode==='swipe'?'向左滑选它':'点它晋级')+'</span></button><span class="vs">VS</span><button class="choice" data-side="1"><span class="choice-letter">B</span><strong class="choice-name">'+escapeHtml(items[1].name)+'</strong><span class="choice-hint">'+(state.mode==='swipe'?'向右滑选它':'点它晋级')+'</span></button></div>';}
root.innerHTML = '<div class="shell arena"><div class="arena-top"><span>第 ' + state.bracket.round + ' 轮</span><span class="muted">' + done + ' / ' + total + '</span></div><div class="progress"><span style="width:' + Math.round(done/total*100) + '%"></span></div>'+tabs+content+'</div>';
root.querySelectorAll('[data-mode]').forEach(function(el){el.classList.toggle('active',el.dataset.mode===state.mode);el.onclick=function(){state.mode=el.dataset.mode;renderPair();};});
root.querySelectorAll('.choice').forEach(function (el) { el.addEventListener('click', function () { chooseAt(state.bracket.matches.findIndex(function(m){return m.right&&!m.winner;}),Number(el.dataset.side)); }); });
root.querySelectorAll('[data-match]').forEach(function(el){el.onclick=function(){chooseAt(Number(el.dataset.match),Number(el.dataset.side));};});
var swipe=root.querySelector('.swipeable'),startX;if(swipe){swipe.onpointerdown=function(e){startX=e.clientX;};swipe.onpointerup=function(e){var d=e.clientX-startX;if(state.mode==='swipe'&&Math.abs(d)>60)chooseAt(state.bracket.matches.findIndex(function(m){return m.right&&!m.winner;}),d<0?0:1);};}
}
function chooseAt(index,side) {
var b=state.bracket,m=b.matches[index];if(!m||m.winner)return;var winner=side?m.right:m.left,loser=side?m.left:m.right;m.winner=winner;b.eliminated.unshift(loser);b.decisions.push({round:b.round,left_id:m.left.id,right_id:m.right.id,winner_id:winner.id});
if(b.matches.every(function(item){return item.winner;})){b.pool=b.matches.map(function(item){return item.winner;});if(b.pool.length===1){complete();return;}b.round++;b.matches=buildMatches(b.pool);b.offset=0;}else if(state.mode==='multi'){var block=b.matches.slice(b.offset,b.offset+4);if(block.length&&block.every(function(item){return item.winner;}))b.offset+=4;}
renderPair();
}
function complete() {
var b = state.bracket, champion = b.pool[0], final = b.decisions[b.decisions.length - 1], runner = final.left_id === champion.id ? final.right_id : final.left_id;
root.innerHTML = '<div class="loading">正在保存你的冠军…</div>';
ensureLogin().then(function () { return request('/api/v1/tests/' + state.test.slug + '/runs',{method:'POST',data:{app_key:'hub',champion_id:champion.id,runner_up_id:runner,ranking_ids:b.pool.concat(b.eliminated).map(function(i){return i.id;}),decisions:b.decisions}}); }).then(function (result) { state.result = result; renderResult(); }).catch(renderError);
}
function renderResult() {
var r = state.result;
root.innerHTML = '<div class="shell result-shell editor-shell"><div class="editor-title"><div><small>分享图编辑器</small><h2>'+escapeHtml(r.test_title)+' · '+escapeHtml(r.champion_name)+'</h2></div><button id="savePoster" class="button">导出 PNG</button></div><div class="result-layout"><canvas id="poster" class="poster" width="750" height="1000"></canvas><aside class="control"><h3>晋级图</h3><div class="control-grid"><button class="theme active" data-layout="symmetric">对称</button><button class="theme" data-layout="single">单边</button><button class="theme" data-view="overview">总览</button><button class="theme" data-view="left">左半区</button><button class="theme" data-view="right">右半区</button><button class="theme" data-view="top16">16 强</button><button class="theme" data-view="top8">8 强</button><button class="theme" data-view="final">决赛</button></div><h3>编辑工具</h3><div class="editor-tools"><button>滤镜</button><button>文字</button><button id="stickerToggle">贴纸</button><button>裁剪</button><button>调整</button><button>布局</button><button>画笔</button></div><button class="theme active" data-theme="celebration">庆祝派对</button><button class="theme" data-theme="editorial">杂志封面</button><button class="theme" data-theme="sticker">贴纸手账</button><button id="replay" class="button outline wide">再测一次</button></aside></div></div>';
drawPoster(); root.querySelectorAll('[data-theme]').forEach(function (el) { el.addEventListener('click', function () { state.template = el.dataset.theme; root.querySelectorAll('[data-theme]').forEach(function(x){x.classList.remove('active');});el.classList.add('active');drawPoster(); }); });
root.querySelectorAll('[data-layout]').forEach(function(el){el.onclick=function(){state.bracketLayout=el.dataset.layout;drawPoster();};});
root.querySelectorAll('[data-view]').forEach(function(el){el.onclick=function(){state.bracketView=el.dataset.view;drawPoster();};});
document.getElementById('stickerToggle').addEventListener('click',function(){state.sticker=!state.sticker;this.textContent='手绘贴纸:'+(state.sticker?'开启':'关闭');drawPoster();});
document.getElementById('savePoster').addEventListener('click',function(){var a=document.createElement('a');a.download='我的PK冠军-'+r.champion_name+'.png';a.href=document.getElementById('poster').toDataURL('image/png');a.click();toast('分享图已保存');});
document.getElementById('replay').addEventListener('click',function(){loadTest(r.test_slug);});
}
function drawPoster() {
var canvas=document.getElementById('poster'),ctx=canvas.getContext('2d'),r=state.result,themes={celebration:['#ff6b35','#fffaf4','#25211f','#ffe568'],editorial:['#f3eadf','#201d1b','#fff','#285ee8'],sticker:['#c9f06b','#1e2810','#ff72a7','#fff']},t=themes[state.template];
ctx.fillStyle=t[0];ctx.fillRect(0,0,750,1000);ctx.fillStyle=t[1];ctx.font='700 24px system-ui';ctx.fillText('PK 研究所 / BRACKET',42,62);ctx.font='900 48px system-ui';ctx.fillText(r.test_title,42,125);ctx.fillStyle=t[2];ctx.fillRect(28,165,694,690);drawBracketWeb(ctx,r,t);if(state.sticker){ctx.fillStyle=t[3];ctx.beginPath();ctx.arc(660,100,48,0,Math.PI*2);ctx.fill();ctx.fillStyle=t[2];ctx.font='900 22px system-ui';ctx.fillText('WIN',635,108);}ctx.fillStyle=t[1];ctx.font='700 25px system-ui';ctx.fillText('冠军 '+r.champion_name,42,915);ctx.font='18px system-ui';ctx.fillText('保存分享图,看看朋友的晋级路线',42,962);
}
function drawBracketWeb(ctx,r,t){var ds=r.decisions||[],names=r.candidate_names||{};if(!ds.length){ctx.fillStyle=t[1];ctx.font='900 72px system-ui';ctx.fillText(r.champion_name,70,500);return;}var max=ds.reduce(function(m,d){return Math.max(m,d.round);},1),min=state.bracketView==='final'?max:state.bracketView==='top8'?Math.max(1,max-2):state.bracketView==='top16'?Math.max(1,max-3):1,visible=ds.filter(function(d,i){if(d.round<min)return false;if(state.bracketView==='left'||state.bracketView==='right')return d.round===max||i%2===(state.bracketView==='left'?0:1);return true;}),groups={},pos={};visible.forEach(function(d){(groups[d.round]||(groups[d.round]=[])).push(d);});Object.keys(groups).forEach(function(k){var round=Number(k),arr=groups[k];arr.forEach(function(d,i){var sym=state.bracketLayout==='symmetric',side=sym&&round<max&&i%2?1:-1,x=sym?375+side*Math.min(260,78+(max-round)*62):70+(round-min)*(580/Math.max(1,max-min)),y=230+(i+1)*(520/(arr.length+1));pos[d.winner_id+':'+d.round]={x:x,y:y,d:d};});});ctx.strokeStyle=t[3];ctx.lineWidth=3;Object.keys(pos).forEach(function(k){var p=pos[k],parent;visible.forEach(function(d){if(!parent&&d.round>p.d.round&&(d.left_id===p.d.winner_id||d.right_id===p.d.winner_id))parent=pos[d.winner_id+':'+d.round];});if(parent){ctx.beginPath();ctx.moveTo(p.x,p.y);ctx.lineTo((p.x+parent.x)/2,p.y);ctx.lineTo((p.x+parent.x)/2,parent.y);ctx.lineTo(parent.x,parent.y);ctx.stroke();}});Object.keys(pos).forEach(function(k){var p=pos[k],label=names[p.d.winner_id]||r.champion_name;ctx.fillStyle=p.d.round===max?t[3]:t[0];ctx.fillRect(p.x-56,p.y-20,112,40);ctx.fillStyle=t[1];ctx.font=(label.length>6?'13':'16')+'px system-ui';ctx.fillText(label.slice(0,8),p.x-47,p.y+6);});}
function loadHistory() { root.innerHTML='<div class="loading">正在翻档案…</div>';ensureLogin().then(function(){return request('/api/v1/me/results');}).then(renderHistory).catch(renderError); }
function loadDiscover() { root.innerHTML='<div class="loading">正在收集答案…</div>';request('/api/v1/results/discover').then(function(results){root.innerHTML='<div class="shell"><div class="mast"><div class="logo-mark">PK</div><strong>大家最近选了什么</strong></div><section class="grid">'+results.map(function(r){return '<article class="test-card"><img class="art" src="./assets/test-covers/'+r.test_slug+'.jpg" alt=""><div class="card-body"><p>'+escapeHtml(r.test_title)+'</p><h3>'+escapeHtml(r.champion_name)+'</h3></div></article>';}).join('')+'</section></div>';animate('.test-card',{opacity:[0,1],scale:[.96,1]},{delay:stagger(.04)});}).catch(renderError); }
function renderHistory(results) { root.innerHTML='<div class="shell history"><div class="hero"><div class="eyebrow">MY TASTE ARCHIVE</div><h1>我的偏爱档案</h1><p>每一次纠结,都算数。</p></div><div class="section-head"><h2>做过的测试</h2><span class="muted">'+results.length+' 条结果</span></div><div class="link-box"><strong>连接独立饭类版</strong><p class="muted">两个小程序需要一次确认才能合并身份。</p><button id="linkCode" class="button">生成 10 分钟绑定码</button><div id="code" class="link-code"></div></div><div id="rows">'+(results.length?results.map(function(r,i){return '<button class="result-row" data-i="'+i+'"><span class="medal">#1</span><span><small>'+escapeHtml(r.test_title)+'</small><strong>'+escapeHtml(r.champion_name)+'</strong></span><span>→</span></button>';}).join(''):'<div class="empty">还没有结果,去选第一个冠军吧。</div>')+'</div></div>';document.getElementById('linkCode').addEventListener('click',function(){request('/api/v1/me/link-code',{method:'POST'}).then(function(d){document.getElementById('code').textContent=d.code;}).catch(renderError);});root.querySelectorAll('.result-row').forEach(function(el){el.addEventListener('click',function(){state.result=results[Number(el.dataset.i)];renderResult();});}); }
function renderError(error) { root.innerHTML='<div class="shell empty"><h2>暂时没接上服务</h2><p>'+escapeHtml(error.message||'未知错误')+'</p><button class="button" id="retry">重新加载</button></div>';document.getElementById('retry').addEventListener('click',loadHome); }
document.getElementById('homeButton').addEventListener('click',loadHome); document.getElementById('discoverButton').addEventListener('click',loadDiscover); document.getElementById('meButton').addEventListener('click',loadHistory); loadHome();
}());