feat: share choice modes and bracket editor
Verify mini program and deploy preview / verify-deploy (push) Successful in 25s
Verify mini program and deploy preview / verify-deploy (push) Successful in 25s
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
FROM nginx:1.27-alpine
|
||||
COPY web /usr/share/nginx/html
|
||||
COPY assets /usr/share/nginx/html/assets
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=5 CMD wget -q -O- http://127.0.0.1:8080/health || exit 1
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 554 KiB |
+1
-2
@@ -1,2 +1 @@
|
||||
{"name":"rice-pk-xhs","version":"0.1.0","private":true,"scripts":{"check":"node scripts/check.mjs"}}
|
||||
|
||||
{"name":"rice-pk-xhs","version":"0.1.0","private":true,"scripts":{"check":"node scripts/check.mjs","test":"node --test test/*.test.cjs"}}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<view xhs:elif="{{error}}" class="empty">{{error}}</view>
|
||||
<block xhs:else>
|
||||
<view class="hero">
|
||||
<view class="label">ONLY RICE · 48 强</view><view class="bowl">🍚</view>
|
||||
<view class="label">ONLY RICE · 48 强</view><image class="cover" style="width:100%;height:360rpx;margin-top:24rpx" src="/assets/test-covers/rice.jpg" mode="aspectFill" />
|
||||
<view class="hero-title"><view>本命饭</view><view>终极 PK</view></view><view class="hero-copy">蛋炒饭、煲仔饭、咖喱饭……每轮二选一,看看谁能成为你唯一的那碗饭。</view>
|
||||
<button class="start" bindtap="start">开始选饭 →</button>
|
||||
</view>
|
||||
|
||||
+24
-1
@@ -1 +1,24 @@
|
||||
.kicker{text-align:center;color:#ff6b35;font-size:22rpx;letter-spacing:6rpx;font-weight:900}.headline{margin:12rpx 0 28rpx;text-align:center;font-size:48rpx;font-weight:900}.champion{position:relative;overflow:hidden;padding:54rpx 32rpx;text-align:center;border-radius:38rpx;background:#25211f;color:#fff}.crown{position:absolute;right:22rpx;top:0;color:#ffe568;font-size:100rpx;transform:rotate(13deg)}.muted-light{opacity:.6;font-size:23rpx}.winner{margin-top:16rpx;font-size:72rpx;font-weight:900}.runner{margin-top:20rpx;color:#ded4ce;font-size:23rpx}.poster-head{display:flex;justify-content:space-between;margin:46rpx 4rpx 20rpx;font-size:30rpx;font-weight:900}.poster{width:100%;height:925rpx;border-radius:30rpx;box-shadow:0 16rpx 40rpx rgba(50,30,20,.12)}.themes{display:flex;margin-top:24rpx;overflow-x:auto}.themes button{flex:none;margin-right:12rpx;padding:0 24rpx;height:66rpx;line-height:66rpx;border-radius:99rpx;background:#fff;color:#635951;font-size:22rpx}.themes .active{background:#25211f;color:#fff}.sticker{display:flex;justify-content:space-between;margin-top:24rpx;padding:24rpx;border-radius:22rpx;background:#fff}.action{margin-top:20rpx;font-size:28rpx}
|
||||
.editor { min-height: 100vh; padding: 20rpx 22rpx 40rpx; background: #151515; color: #fff; }
|
||||
.editor-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 18rpx; }
|
||||
.editor-head text { font-size: 31rpx; font-weight: 900; }
|
||||
.editor-head view view { margin-top: 4rpx; color: #888; font-size: 19rpx; }
|
||||
.export { margin: 0; padding: 0 30rpx; min-width: 0; height: 64rpx; line-height: 64rpx; border-radius: 99rpx; background: #ff3156; color: #fff; font-size: 23rpx; font-weight: 800; }
|
||||
.poster { width: 100%; height: 880rpx; background: #242424; }
|
||||
.strip, .media-strip, .tool-strip { white-space: nowrap; }
|
||||
.strip-row, .media-row, .tool-row { display: flex; align-items: center; }
|
||||
.strip { margin-top: 18rpx; }
|
||||
.chip { flex: none; margin-right: 10rpx; padding: 0 22rpx; min-width: 0; height: 54rpx; line-height: 54rpx; border-radius: 8rpx; background: #292929; color: #aaa; font-size: 20rpx; }
|
||||
.chip.active { background: #fff; color: #111; }
|
||||
.media-strip { margin-top: 22rpx; }
|
||||
.media-thumb, .add-media { flex: none; display: flex; flex-direction: column; align-items: center; justify-content: center; width: 112rpx; height: 112rpx; margin-right: 12rpx; border: 2rpx solid #444; border-radius: 10rpx; background: #252525; color: #fff; font-size: 20rpx; }
|
||||
.bracket-thumb { border-color: #fff; background: #f3eadf; color: #111; font-weight: 800; }
|
||||
.add-media text { margin-top: 5rpx; color: #999; font-size: 16rpx; }
|
||||
.tool-strip { margin-top: 25rpx; }
|
||||
.tool { flex: none; width: 104rpx; text-align: center; color: #888; font-size: 19rpx; }
|
||||
.tool.active { color: #fff; }
|
||||
.tool-icon { display: grid; place-items: center; width: 50rpx; height: 50rpx; margin: 0 auto 9rpx; border: 3rpx solid currentColor; border-radius: 12rpx; font-size: 18rpx; }
|
||||
.tool-panel { display: flex; margin-top: 22rpx; padding: 18rpx; overflow: hidden; background: #222; }
|
||||
.theme-chip { flex: 1; margin: 0 5rpx; padding: 0; height: 58rpx; line-height: 58rpx; border-radius: 6rpx; background: #333; color: #aaa; font-size: 19rpx; }
|
||||
.theme-chip.active { background: #fff; color: #111; }
|
||||
.setting-row { display: flex; width: 100%; justify-content: space-between; font-size: 22rpx; }
|
||||
.hint { color: #777; font-size: 20rpx; }
|
||||
|
||||
+160
-18
@@ -1,20 +1,162 @@
|
||||
var api=require('../../utils/api');
|
||||
var THEMES={celebration:{bg:'#ff6b35',ink:'#fffaf4',card:'#25211f',accent:'#ffe568'},editorial:{bg:'#f3eadf',ink:'#201d1b',card:'#fff',accent:'#285ee8'},sticker:{bg:'#c9f06b',ink:'#1e2810',card:'#ff72a7',accent:'#fff'}};
|
||||
var api = require('../../utils/api');
|
||||
var posterEditor = require('../../vendor/poster-editor.cjs');
|
||||
|
||||
var THEMES = {
|
||||
celebration: { bg: '#ff6b35', ink: '#fffaf4', card: '#25211f', accent: '#ffe568', label: '庆祝派对' },
|
||||
editorial: { bg: '#f3eadf', ink: '#201d1b', card: '#ffffff', accent: '#285ee8', label: '杂志封面' },
|
||||
sticker: { bg: '#c9f06b', ink: '#1e2810', card: '#ff72a7', accent: '#ffffff', label: '贴纸手账' }
|
||||
};
|
||||
|
||||
Page({
|
||||
data:{result:null,template:'celebration',sticker:true,saving:false},
|
||||
onLoad:function(options){this.runId=options.id;var cached=xhs.getStorageSync('rice_last_result');if(cached&&cached.id===options.id)this.setData({result:cached});else this.load();},
|
||||
onReady:function(){if(this.data.result)this.draw();},
|
||||
load:function(){var self=this;api.ensureLogin().then(function(){return api.request('/api/v1/me/results/'+self.runId);}).then(function(result){self.setData({result:result});self.draw();}).catch(function(error){xhs.showModal({title:'读取失败',content:error.message||'请重试',showCancel:false});});},
|
||||
pick:function(event){this.setData({template:event.currentTarget.dataset.name});this.draw();},
|
||||
toggle:function(){this.setData({sticker:!this.data.sticker});this.draw();},
|
||||
draw:function(){
|
||||
var r=this.data.result;if(!r)return;
|
||||
var t=THEMES[this.data.template],isCelebration=this.data.template==='celebration',showSticker=this.data.sticker;
|
||||
xhs.createSelectorQuery().select('.poster').boundingClientRect(function(rect){
|
||||
var ctx=xhs.createCanvasContext('poster');if(rect)ctx.scale(rect.width/750,rect.height/1000);
|
||||
ctx.setFillStyle(t.bg);ctx.fillRect(0,0,750,1000);ctx.setFillStyle(t.ink);ctx.setFontSize(24);ctx.fillText('RICE PK / MY FINAL PICK',54,72);ctx.setFontSize(58);ctx.fillText('我的本命饭',54,156);ctx.setFillStyle(t.card);ctx.fillRect(46,205,658,440);ctx.setFillStyle(isCelebration?'#fffaf4':t.ink);ctx.setFontSize(26);ctx.fillText('48 种饭 · 一路二选一',84,270);ctx.setFontSize(r.champion_name.length>7?62:82);ctx.fillText(r.champion_name,84,405);ctx.setFillStyle(t.accent);ctx.fillRect(84,440,Math.min(500,r.champion_name.length*80),18);ctx.setFillStyle(isCelebration?'#fffaf4':t.ink);ctx.setFontSize(24);ctx.fillText('今天就吃它,不接受反驳。',84,525);ctx.setFillStyle(t.ink);ctx.setFontSize(25);ctx.fillText('我的 TOP 4',54,720);(r.ranking_names||[]).slice(0,4).forEach(function(name,index){ctx.setFontSize(26);ctx.fillText((index+1)+'. '+name,64,774+index*48);});if(showSticker){ctx.setFillStyle(t.accent);ctx.beginPath();ctx.arc(645,125,58,0,Math.PI*2);ctx.fill();ctx.setFillStyle(t.card);ctx.setFontSize(28);ctx.fillText('开饭!',607,134);ctx.setFontSize(52);ctx.fillText('✦',640,700);ctx.fillText('♡',550,900);}ctx.setFillStyle(t.ink);ctx.setFontSize(20);ctx.fillText('保存分享图,看看朋友会选哪一碗',54,956);ctx.draw();
|
||||
}).exec();
|
||||
},
|
||||
save:function(){var self=this;this.setData({saving:true});xhs.canvasToTempFilePath({canvasId:'poster',destWidth:750,destHeight:1000,success:function(file){xhs.saveImageToPhotosAlbum({filePath:file.tempFilePath,success:function(){xhs.showToast({title:'已保存到相册'});},fail:function(){xhs.showToast({title:'请检查相册权限',icon:'none'});},complete:function(){self.setData({saving:false});}});},fail:function(){self.setData({saving:false});xhs.showToast({title:'生成失败',icon:'none'});}});},
|
||||
replay:function(){xhs.redirectTo({url:'/pages/test/test'});}
|
||||
data: {
|
||||
result: null,
|
||||
template: 'celebration',
|
||||
themeList: [
|
||||
{ name: 'celebration', label: '庆祝派对' },
|
||||
{ name: 'editorial', label: '杂志封面' },
|
||||
{ name: 'sticker', label: '贴纸手账' }
|
||||
],
|
||||
sticker: true,
|
||||
bracketLayout: 'symmetric',
|
||||
bracketView: 'overview',
|
||||
activeTool: 'filter',
|
||||
backgroundPath: '',
|
||||
tools: [
|
||||
{ name: 'filter', label: '滤镜' }, { name: 'text', label: '文字' },
|
||||
{ name: 'sticker', label: '贴纸' }, { name: 'crop', label: '裁剪' },
|
||||
{ name: 'adjust', label: '调整' }, { name: 'layout', label: '布局' },
|
||||
{ name: 'brush', label: '画笔' }
|
||||
],
|
||||
saving: false
|
||||
},
|
||||
onLoad: function (options) {
|
||||
this.posterDocument = posterEditor.createDocument({ id: 'run-' + options.id, seed: options.id, width: 750, height: 1000, meta: { sourceType: 'pk-run', sourceId: options.id } });
|
||||
var cached = xhs.getStorageSync('rice_last_result');
|
||||
this.runId = options.id;
|
||||
if (cached && cached.id === options.id) this.setData({ result: cached });
|
||||
else this.load();
|
||||
},
|
||||
onReady: function () { if (this.data.result) this.drawPoster(); },
|
||||
load: function () {
|
||||
var self = this;
|
||||
api.ensureLogin().then(function () { return api.request('/api/v1/me/results/' + self.runId); }).then(function (result) {
|
||||
self.setData({ result: result }); self.drawPoster();
|
||||
}).catch(function (error) { xhs.showModal({ title: '读取失败', content: error.message || '请稍后重试', showCancel: false }); });
|
||||
},
|
||||
pickTemplate: function (event) {
|
||||
this.setData({ template: event.currentTarget.dataset.name });
|
||||
this.drawPoster();
|
||||
},
|
||||
toggleSticker: function () { this.setData({ sticker: !this.data.sticker }); this.drawPoster(); },
|
||||
pickBracketLayout: function (event) { this.setData({ bracketLayout: event.currentTarget.dataset.layout }); this.drawPoster(); },
|
||||
pickBracketView: function (event) { this.setData({ bracketView: event.currentTarget.dataset.view }); this.drawPoster(); },
|
||||
pickTool: function (event) { this.setData({ activeTool: event.currentTarget.dataset.tool }); },
|
||||
chooseBackground: function () {
|
||||
var self = this;
|
||||
xhs.chooseImage({
|
||||
count: 1,
|
||||
success: function (result) {
|
||||
var path = result.tempFilePaths && result.tempFilePaths[0];
|
||||
if (path) { self.setData({ backgroundPath: path }); self.drawPoster(); }
|
||||
}
|
||||
});
|
||||
},
|
||||
drawPoster: function () {
|
||||
var result = this.data.result;
|
||||
if (!result) return;
|
||||
var theme = THEMES[this.data.template];
|
||||
var isCelebration = this.data.template === 'celebration';
|
||||
var showSticker = this.data.sticker;
|
||||
var layout = this.data.bracketLayout;
|
||||
var view = this.data.bracketView;
|
||||
var backgroundPath = this.data.backgroundPath;
|
||||
xhs.createSelectorQuery().select('.poster').boundingClientRect(function (rect) {
|
||||
var ctx = xhs.createCanvasContext('poster');
|
||||
if (rect) ctx.scale(rect.width / 750, rect.height / 1000);
|
||||
ctx.setFillStyle(theme.bg); ctx.fillRect(0, 0, 750, 1000);
|
||||
if (backgroundPath) {
|
||||
ctx.drawImage(backgroundPath, 0, 0, 750, 1000);
|
||||
ctx.setGlobalAlpha(.82); ctx.setFillStyle(theme.bg); ctx.fillRect(0, 0, 750, 1000); ctx.setGlobalAlpha(1);
|
||||
}
|
||||
ctx.setFillStyle(theme.ink); ctx.setFontSize(24); ctx.fillText('PK 研究所 / MY PICK', 54, 72);
|
||||
ctx.setFontSize(58); ctx.fillText('我的冠军是', 54, 156);
|
||||
ctx.setFillStyle(theme.card); ctx.fillRect(30, 190, 690, 610);
|
||||
ctx.setFillStyle(isCelebration ? '#fffaf4' : theme.ink);
|
||||
ctx.setFontSize(22); ctx.fillText(result.test_title, 54, 235);
|
||||
drawBracket(ctx, result, layout, view, theme, isCelebration);
|
||||
if (showSticker) {
|
||||
ctx.setFillStyle(theme.accent); ctx.beginPath(); ctx.arc(645, 125, 58, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.setFillStyle(theme.card); ctx.setFontSize(24); ctx.fillText('WIN', 615, 134);
|
||||
}
|
||||
ctx.setFillStyle(theme.ink); ctx.setFontSize(20); ctx.fillText('长按保存,把答案分享给同频的人', 54, 956);
|
||||
ctx.draw();
|
||||
}).exec();
|
||||
},
|
||||
savePoster: function () {
|
||||
var self = this;
|
||||
this.setData({ saving: true });
|
||||
xhs.canvasToTempFilePath({
|
||||
canvasId: 'poster', destWidth: 750, destHeight: 1000,
|
||||
success: function (file) {
|
||||
xhs.saveImageToPhotosAlbum({
|
||||
filePath: file.tempFilePath,
|
||||
success: function () { xhs.showToast({ title: '已保存到相册' }); },
|
||||
fail: function () { xhs.showToast({ title: '保存失败,请检查相册权限', icon: 'none' }); },
|
||||
complete: function () { self.setData({ saving: false }); }
|
||||
});
|
||||
},
|
||||
fail: function () { self.setData({ saving: false }); xhs.showToast({ title: '海报生成失败', icon: 'none' }); }
|
||||
});
|
||||
},
|
||||
replay: function () { xhs.redirectTo({ url: '/pages/test/test?slug=' + this.data.result.test_slug }); }
|
||||
});
|
||||
|
||||
function drawBracket(ctx, result, layout, view, theme, isCelebration) {
|
||||
var decisions = result.decisions || [];
|
||||
var names = result.candidate_names || {};
|
||||
if (!decisions.length) {
|
||||
ctx.setFillStyle(isCelebration ? '#fffaf4' : theme.ink);
|
||||
ctx.setFontSize(72);
|
||||
ctx.fillText(result.champion_name, 70, 480);
|
||||
return;
|
||||
}
|
||||
var maxRound = decisions.reduce(function (max, item) { return Math.max(max, item.round); }, 1);
|
||||
var minimumRound = view === 'final' ? maxRound : view === 'top8' ? Math.max(1, maxRound - 2) : view === 'top16' ? Math.max(1, maxRound - 3) : 1;
|
||||
var visible = decisions.filter(function (item) { return item.round >= minimumRound; });
|
||||
if (view === 'left' || view === 'right') {
|
||||
visible = visible.filter(function (item, index) { return item.round === maxRound || index % 2 === (view === 'left' ? 0 : 1); });
|
||||
}
|
||||
var grouped = {};
|
||||
visible.forEach(function (item) { (grouped[item.round] || (grouped[item.round] = [])).push(item); });
|
||||
var positions = {};
|
||||
Object.keys(grouped).forEach(function (roundKey) {
|
||||
var round = Number(roundKey), items = grouped[round], count = items.length;
|
||||
items.forEach(function (item, index) {
|
||||
var centered = layout === 'symmetric';
|
||||
var side = centered && round < maxRound && index % 2 ? 1 : -1;
|
||||
var depth = maxRound - round;
|
||||
var x = centered ? 375 + side * Math.min(250, 82 + depth * 68) : 64 + (round - minimumRound) * (590 / Math.max(1, maxRound - minimumRound));
|
||||
var y = 300 + (index + 1) * (440 / (count + 1));
|
||||
positions[item.winner_id + ':' + item.round] = { x: x, y: y, side: side, item: item };
|
||||
});
|
||||
});
|
||||
ctx.setLineWidth(3);
|
||||
ctx.setStrokeStyle(theme.accent);
|
||||
Object.keys(positions).forEach(function (key) {
|
||||
var pos = positions[key], parent = null;
|
||||
visible.forEach(function (candidate) {
|
||||
if (!parent && candidate.round > pos.item.round && (candidate.left_id === pos.item.winner_id || candidate.right_id === pos.item.winner_id)) parent = positions[candidate.winner_id + ':' + candidate.round];
|
||||
});
|
||||
if (parent) {
|
||||
ctx.beginPath(); ctx.moveTo(pos.x, pos.y); ctx.lineTo((pos.x + parent.x) / 2, pos.y); ctx.lineTo((pos.x + parent.x) / 2, parent.y); ctx.lineTo(parent.x, parent.y); ctx.stroke();
|
||||
}
|
||||
});
|
||||
Object.keys(positions).forEach(function (key) {
|
||||
var pos = positions[key], label = names[pos.item.winner_id] || result.champion_name;
|
||||
ctx.setFillStyle(pos.item.round === maxRound ? theme.accent : theme.bg);
|
||||
ctx.fillRect(pos.x - 58, pos.y - 21, 116, 42);
|
||||
ctx.setFillStyle(theme.ink); ctx.setFontSize(label.length > 6 ? 14 : 17);
|
||||
ctx.fillText(label.slice(0, 8), pos.x - 48, pos.y + 6);
|
||||
});
|
||||
ctx.setFillStyle(theme.ink); ctx.setFontSize(24); ctx.fillText('冠军 ' + result.champion_name, 54, 850);
|
||||
}
|
||||
|
||||
@@ -1,2 +1,18 @@
|
||||
<view class="page" xhs:if="{{result}}"><view class="kicker">FINAL RICE</view><view class="headline">本命饭出炉!</view><view class="champion"><view class="crown">♛</view><view class="muted-light">48 种饭的最终冠军</view><view class="winner">{{result.champion_name}}</view><view class="runner" xhs:if="{{result.runner_up_name}}">决赛击败 {{result.runner_up_name}}</view></view><view class="poster-head"><text>分享卡片</text><text class="muted">3 款模板</text></view><canvas canvas-id="poster" class="poster"></canvas><view class="themes"><button class="{{template==='celebration'?'active':''}}" data-name="celebration" bindtap="pick">庆祝派对</button><button class="{{template==='editorial'?'active':''}}" data-name="editorial" bindtap="pick">杂志封面</button><button class="{{template==='sticker'?'active':''}}" data-name="sticker" bindtap="pick">贴纸手账</button></view><view class="sticker" bindtap="toggle"><text>手绘贴纸</text><text>{{sticker?'开启 ✓':'关闭'}}</text></view><button class="primary action" loading="{{saving}}" bindtap="save">保存分享图</button><button class="secondary action" bindtap="replay">再选一次</button></view>
|
||||
|
||||
<view class="page editor" xhs:if="{{result}}">
|
||||
<view class="editor-head"><view><text>分享图编辑器</text><view>{{result.test_title}} · {{result.champion_name}}</view></view><button class="export" loading="{{saving}}" bindtap="savePoster">导出</button></view>
|
||||
<canvas canvas-id="poster" class="poster"></canvas>
|
||||
<scroll-view scroll-x class="strip"><view class="strip-row">
|
||||
<button class="chip {{bracketLayout === 'symmetric' ? 'active' : ''}}" data-layout="symmetric" bindtap="pickBracketLayout">对称图</button>
|
||||
<button class="chip {{bracketLayout === 'single' ? 'active' : ''}}" data-layout="single" bindtap="pickBracketLayout">单边图</button>
|
||||
<button class="chip {{bracketView === 'overview' ? 'active' : ''}}" data-view="overview" bindtap="pickBracketView">总览</button>
|
||||
<button class="chip" data-view="left" bindtap="pickBracketView">左半区</button><button class="chip" data-view="right" bindtap="pickBracketView">右半区</button>
|
||||
<button class="chip" data-view="top16" bindtap="pickBracketView">16 强</button><button class="chip" data-view="top8" bindtap="pickBracketView">8 强</button><button class="chip" data-view="final" bindtap="pickBracketView">决赛</button>
|
||||
</view></scroll-view>
|
||||
<scroll-view scroll-x class="media-strip"><view class="media-row"><view class="media-thumb bracket-thumb">晋级图</view><button class="add-media" bindtap="chooseBackground">+<text>添加底图</text></button></view></scroll-view>
|
||||
<scroll-view scroll-x class="tool-strip"><view class="tool-row">
|
||||
<view xhs:for="{{tools}}" xhs:key="name" class="tool {{activeTool === item.name ? 'active' : ''}}" data-tool="{{item.name}}" bindtap="pickTool"><view class="tool-icon">{{index + 1}}</view><text>{{item.label}}</text></view>
|
||||
</view></scroll-view>
|
||||
<view class="tool-panel" xhs:if="{{activeTool === 'filter'}}"><button xhs:for="{{themeList}}" xhs:key="name" class="theme-chip {{template === item.name ? 'active' : ''}}" data-name="{{item.name}}" bindtap="pickTemplate">{{item.label}}</button></view>
|
||||
<view class="tool-panel" xhs:elif="{{activeTool === 'sticker'}}"><view class="setting-row" bindtap="toggleSticker"><text>装饰贴纸</text><text>{{sticker ? '已开启' : '关闭'}}</text></view></view>
|
||||
<view class="tool-panel hint" xhs:else>此工具将由 @entroia/poster-editor 的图层绑定驱动</view>
|
||||
</view>
|
||||
|
||||
+28
-2
@@ -1,2 +1,28 @@
|
||||
.top{display:flex;justify-content:space-between}.progress{height:10rpx;margin:20rpx 0 46rpx;overflow:hidden;border-radius:99rpx;background:#eadfd7}.progress view{height:100%;background:#ff6b35}.title{margin-bottom:34rpx;font-size:54rpx;font-weight:900}.pair{position:relative}.choice{position:relative;min-height:350rpx;margin-bottom:24rpx;padding:34rpx;box-sizing:border-box;border-radius:38rpx;color:#fff}.a{background:#ff6b35;transform:rotate(-1deg)}.b{background:#2766df;transform:rotate(1deg)}.letter{display:block;width:54rpx;height:54rpx;line-height:54rpx;text-align:center;border:2rpx solid rgba(255,255,255,.6);border-radius:50%;font-weight:900}.emoji{position:absolute;right:30rpx;top:20rpx;font-size:92rpx}.name{display:block;margin-top:65rpx;font-size:62rpx;font-weight:900}.hint{position:absolute;left:34rpx;bottom:30rpx;opacity:.7}.vs{position:absolute;z-index:2;left:50%;top:50%;width:82rpx;height:82rpx;line-height:82rpx;text-align:center;transform:translate(-50%,-50%) rotate(-8deg);border:5rpx solid #25211f;border-radius:50%;background:#fff;font-weight:900}
|
||||
|
||||
.arena { height: 100vh; overflow: hidden; padding: 24rpx 28rpx; background: #fff; }
|
||||
.topline { display: flex; align-items: center; justify-content: space-between; font-size: 23rpx; font-weight: 800; }
|
||||
.progress { margin-top: 14rpx; height: 7rpx; overflow: hidden; background: #e8e8e8; }
|
||||
.progress-in { height: 100%; background: #111; transition: width .25s; }
|
||||
.mode-tabs { display: flex; width: 430rpx; height: 62rpx; margin: 24rpx auto; padding: 5rpx; border-radius: 10rpx; background: #f1f1f1; }
|
||||
.mode { flex: 1; display: grid; place-items: center; border-radius: 7rpx; color: #777; font-size: 23rpx; }
|
||||
.mode.active { background: #111; color: #fff; font-weight: 800; }
|
||||
.single-stage { position: relative; display: grid; grid-template-columns: 1fr 1fr; gap: 16rpx; height: calc(100vh - 190rpx); max-height: 850rpx; }
|
||||
.choice { position: relative; display: flex; flex-direction: column; justify-content: center; min-width: 0; padding: 28rpx; color: #fff; transition: transform .2s cubic-bezier(.2,.9,.2,1), opacity .2s; }
|
||||
.choice:active { transform: scale(.94) rotate(-1deg); opacity: .8; }
|
||||
.choice-a { background: #f34b30; }
|
||||
.choice-b { background: #3564dd; }
|
||||
.choice-index { position: absolute; left: 24rpx; top: 24rpx; width: 48rpx; height: 48rpx; line-height: 48rpx; text-align: center; border: 2rpx solid rgba(255,255,255,.6); border-radius: 50%; font-size: 21rpx; }
|
||||
.choice-name { font-size: 45rpx; line-height: 1.1; font-weight: 950; word-break: break-all; }
|
||||
.tap-hint { position: absolute; left: 28rpx; bottom: 28rpx; font-size: 21rpx; opacity: .75; }
|
||||
.versus { position: absolute; z-index: 3; left: 50%; top: 50%; transform: translate(-50%, -50%); display: grid; place-items: center; width: 66rpx; height: 66rpx; border: 5rpx solid #111; border-radius: 50%; background: #fff; font-size: 21rpx; font-weight: 950; }
|
||||
.swipe-stage { perspective: 1000rpx; }
|
||||
.swipe-stage .choice-a { transform: rotate(-2deg); transform-origin: bottom right; }
|
||||
.swipe-stage .choice-b { transform: rotate(2deg); transform-origin: bottom left; }
|
||||
.multi-stage { display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; gap: 12rpx; height: calc(100vh - 190rpx); max-height: 850rpx; }
|
||||
.mini-match { position: relative; display: grid; grid-template-columns: 1fr 42rpx 1fr; align-items: stretch; min-height: 0; overflow: hidden; border: 2rpx solid #111; }
|
||||
.mini-choice { display: grid; place-items: center; padding: 16rpx; text-align: center; font-size: 27rpx; font-weight: 850; background: #fff; transition: .18s; }
|
||||
.mini-choice:active { color: #fff; background: #111; transform: scale(.95); }
|
||||
.mini-vs { display: grid; place-items: center; background: #111; color: #fff; font-size: 17rpx; font-weight: 900; }
|
||||
.mini-winner { grid-column: 1 / 4; display: flex; flex-direction: column; align-items: center; justify-content: center; animation: winner-in .35s cubic-bezier(.2,1.3,.4,1); background: #111; color: #fff; font-size: 34rpx; font-weight: 950; }
|
||||
.mini-winner text { margin-bottom: 10rpx; color: #d9ff54; font-size: 18rpx; letter-spacing: 4rpx; }
|
||||
.saving { position: fixed; z-index: 10; left: 50%; bottom: 50rpx; transform: translateX(-50%); padding: 20rpx 30rpx; border-radius: 99rpx; background: #111; color: #fff; font-size: 23rpx; }
|
||||
@keyframes winner-in { from { opacity: 0; transform: scale(.65) rotate(-4deg); } to { opacity: 1; transform: scale(1); } }
|
||||
|
||||
+113
-8
@@ -1,9 +1,114 @@
|
||||
var api=require('../../utils/api'),bracket=require('../../utils/bracket');
|
||||
Page({
|
||||
data:{loading:true,left:null,right:null,round:1,progress:0,completed:0,total:47},
|
||||
onLoad:function(){var self=this;api.request('/api/v1/tests/rice').then(function(test){self.test=test;self.state=bracket.create(test.candidates);self.refresh();self.setData({loading:false,total:test.candidates.length-1});}).catch(function(error){self.setData({loading:false,error:error.message});});},
|
||||
refresh:function(){var pair=bracket.current(this.state);this.setData({left:pair&&pair[0],right:pair&&pair[1],round:this.state.round,completed:this.state.decisions.length,progress:Math.round(this.state.decisions.length/this.data.total*100)});},
|
||||
choose:function(event){if(this.busy)return;this.busy=true;var winner=event.currentTarget.dataset.side==='left'?this.data.left:this.data.right,finished=bracket.choose(this.state,winner);if(finished)this.complete();else{this.refresh();this.busy=false;}},
|
||||
complete:function(){var self=this,champion=this.state.pool[0],final=this.state.decisions[this.state.decisions.length-1],runner=final.left_id===champion.id?final.right_id:final.left_id;api.ensureLogin().then(function(){return api.request('/api/v1/tests/rice/runs',{method:'POST',data:{app_key:'rice',champion_id:champion.id,runner_up_id:runner,ranking_ids:bracket.ranking(self.state),decisions:self.state.decisions}});}).then(function(result){xhs.setStorageSync('rice_last_result',result);xhs.redirectTo({url:'/pages/result/result?id='+result.id});}).catch(function(error){self.busy=false;xhs.showModal({title:'保存失败',content:error.message||'请重试',showCancel:false});});}
|
||||
});
|
||||
var api = require('../../utils/api');
|
||||
var bracket = require('../../utils/bracket');
|
||||
|
||||
Page({
|
||||
data: { loading: true, error: '', test: null, left: null, right: null, matches: [], mode: 'single', round: 1, progress: 0, completed: 0, totalDecisions: 0 },
|
||||
onLoad: function (options) {
|
||||
this.slug = options.slug || 'rice';
|
||||
this.load();
|
||||
},
|
||||
load: function () {
|
||||
var self = this;
|
||||
api.request('/api/v1/tests/' + this.slug).then(function (test) {
|
||||
self.test = test;
|
||||
self.state = bracket.create(test.candidates);
|
||||
self.totalDecisions = test.candidates.length - 1;
|
||||
self.refreshPair();
|
||||
self.setData({ loading: false, test: test, totalDecisions: self.totalDecisions });
|
||||
}).catch(function (error) { self.setData({ loading: false, error: error.message || '加载失败' }); });
|
||||
},
|
||||
refreshPair: function () {
|
||||
var pair = bracket.current(this.state);
|
||||
if (this.visibleRound !== this.state.round) {
|
||||
this.visibleRound = this.state.round;
|
||||
this.multiOffset = 0;
|
||||
}
|
||||
var offset = this.multiOffset || 0;
|
||||
var matches = this.state.matches.slice(offset, offset + 4).map(function (match, index) {
|
||||
return { index: offset + index, left: match.left, right: match.right, winner: match.winner };
|
||||
});
|
||||
this.setData({
|
||||
left: pair && pair[0],
|
||||
right: pair && pair[1],
|
||||
matches: matches,
|
||||
round: this.state.round,
|
||||
completed: this.state.decisions.length,
|
||||
progress: Math.round(this.state.decisions.length / this.totalDecisions * 100)
|
||||
});
|
||||
},
|
||||
setMode: function (event) {
|
||||
this.setData({ mode: event.currentTarget.dataset.mode });
|
||||
},
|
||||
choose: function (event) {
|
||||
if (this.choosing) return;
|
||||
this.choosing = true;
|
||||
var side = event.currentTarget.dataset.side;
|
||||
var winner = side === 'left' ? this.data.left : this.data.right;
|
||||
var finished = bracket.choose(this.state, winner);
|
||||
if (finished) this.complete();
|
||||
else {
|
||||
this.refreshPair();
|
||||
var self = this;
|
||||
setTimeout(function () { self.choosing = false; }, 220);
|
||||
}
|
||||
},
|
||||
chooseMatch: function (event) {
|
||||
if (this.choosing) return;
|
||||
var matchIndex = Number(event.currentTarget.dataset.match);
|
||||
var side = event.currentTarget.dataset.side;
|
||||
var match = this.state.matches[matchIndex];
|
||||
if (!match || match.winner || !match.right) return;
|
||||
this.choosing = true;
|
||||
var winner = side === 'left' ? match.left : match.right;
|
||||
var beforeRound = this.state.round;
|
||||
var finished = bracket.chooseAt(this.state, matchIndex, winner);
|
||||
if (finished) this.complete();
|
||||
else {
|
||||
this.refreshPair();
|
||||
var self = this;
|
||||
setTimeout(function () {
|
||||
if (beforeRound === self.state.round) {
|
||||
var offset = self.multiOffset || 0;
|
||||
var block = self.state.matches.slice(offset, offset + 4);
|
||||
if (block.length && block.every(function (item) { return item.winner; })) self.multiOffset = offset + 4;
|
||||
}
|
||||
self.refreshPair();
|
||||
self.choosing = false;
|
||||
}, 360);
|
||||
}
|
||||
},
|
||||
touchStart: function (event) { this.touchX = event.touches[0].clientX; },
|
||||
touchEnd: function (event) {
|
||||
if (this.data.mode !== 'swipe' || this.touchX == null) return;
|
||||
var delta = event.changedTouches[0].clientX - this.touchX;
|
||||
this.touchX = null;
|
||||
if (Math.abs(delta) < 55) return;
|
||||
this.choose({ currentTarget: { dataset: { side: delta < 0 ? 'left' : 'right' } } });
|
||||
},
|
||||
complete: function () {
|
||||
var self = this;
|
||||
var champion = this.state.pool[0];
|
||||
var finalDecision = this.state.decisions[this.state.decisions.length - 1];
|
||||
var runnerUpId = finalDecision.left_id === champion.id ? finalDecision.right_id : finalDecision.left_id;
|
||||
this.setData({ progress: 100, submitting: true });
|
||||
api.ensureLogin().then(function () {
|
||||
return api.request('/api/v1/tests/' + self.slug + '/runs', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
app_key: 'rice',
|
||||
champion_id: champion.id,
|
||||
runner_up_id: runnerUpId,
|
||||
ranking_ids: bracket.ranking(self.state),
|
||||
decisions: self.state.decisions
|
||||
}
|
||||
});
|
||||
}).then(function (result) {
|
||||
xhs.setStorageSync('rice_last_result', result);
|
||||
xhs.redirectTo({ url: '/pages/result/result?id=' + result.id });
|
||||
}).catch(function (error) {
|
||||
self.choosing = false;
|
||||
self.setData({ submitting: false });
|
||||
xhs.showModal({ title: '结果保存失败', content: error.message || '请检查网络后重试', showCancel: false });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
+29
-2
@@ -1,2 +1,29 @@
|
||||
<view class="page arena"><view xhs:if="{{loading}}" class="empty">正在抽签…</view><view xhs:elif="{{error}}" class="empty">{{error}}</view><block xhs:else><view class="top"><text>第 {{round}} 轮</text><text class="muted">{{completed}} / {{total}}</text></view><view class="progress"><view style="width:{{progress}}%"></view></view><view class="title">只能留下一碗饭。</view><view class="pair"><view class="choice a" data-side="left" bindtap="choose"><text class="letter">A</text><text class="emoji">🍚</text><text class="name">{{left.name}}</text><text class="hint">选它晋级 ↗</text></view><view class="vs">VS</view><view class="choice b" data-side="right" bindtap="choose"><text class="letter">B</text><text class="emoji">🍚</text><text class="name">{{right.name}}</text><text class="hint">选它晋级 ↗</text></view></view></block></view>
|
||||
|
||||
<view class="page arena">
|
||||
<view xhs:if="{{loading}}" class="empty">正在抽签…</view>
|
||||
<view xhs:elif="{{error}}" class="empty">{{error}}</view>
|
||||
<block xhs:else>
|
||||
<view class="topline"><text>第 {{round}} 轮</text><text class="muted">{{completed}} / {{totalDecisions}}</text></view>
|
||||
<view class="progress"><view class="progress-in" style="width: {{progress}}%"></view></view>
|
||||
<view class="mode-tabs">
|
||||
<view class="mode {{mode === 'single' ? 'active' : ''}}" data-mode="single" bindtap="setMode">一对</view>
|
||||
<view class="mode {{mode === 'multi' ? 'active' : ''}}" data-mode="multi" bindtap="setMode">多对</view>
|
||||
<view class="mode {{mode === 'swipe' ? 'active' : ''}}" data-mode="swipe" bindtap="setMode">滑动</view>
|
||||
</view>
|
||||
<view xhs:if="{{mode !== 'multi'}}" class="single-stage {{mode === 'swipe' ? 'swipe-stage' : ''}}" bindtouchstart="touchStart" bindtouchend="touchEnd">
|
||||
<view class="choice choice-a" data-side="left" bindtap="choose"><view class="choice-index">A</view><view class="choice-name">{{left.name}}</view><view class="tap-hint">{{mode === 'swipe' ? '向左滑选它' : '点按晋级'}}</view></view>
|
||||
<view class="versus">VS</view>
|
||||
<view class="choice choice-b" data-side="right" bindtap="choose"><view class="choice-index">B</view><view class="choice-name">{{right.name}}</view><view class="tap-hint">{{mode === 'swipe' ? '向右滑选它' : '点按晋级'}}</view></view>
|
||||
</view>
|
||||
<view xhs:else class="multi-stage">
|
||||
<view xhs:for="{{matches}}" xhs:key="index" class="mini-match {{item.winner ? 'resolved' : ''}}">
|
||||
<block xhs:if="{{item.winner}}"><view class="mini-winner"><text>晋级</text>{{item.winner.name}}</view></block>
|
||||
<block xhs:else>
|
||||
<view class="mini-choice" data-match="{{item.index}}" data-side="left" bindtap="chooseMatch">{{item.left.name}}</view>
|
||||
<view class="mini-vs">VS</view>
|
||||
<view class="mini-choice" data-match="{{item.index}}" data-side="right" bindtap="chooseMatch">{{item.right.name}}</view>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
<view xhs:if="{{submitting}}" class="saving">正在保存你的冠军…</view>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
+1
-2
@@ -1,2 +1 @@
|
||||
import{readFileSync,existsSync}from'node:fs';const required=['app.js','app.json','pages/index/index.xhsml','pages/test/test.js','pages/result/result.js','web/index.html','web/app.js'];for(const file of required)if(!existsSync(file))throw new Error(`Missing ${file}`);JSON.parse(readFileSync('app.json','utf8'));console.log(`Static checks passed (${required.length} required files).`);
|
||||
|
||||
import{readFileSync,existsSync}from'node:fs';const required=['app.js','app.json','pages/index/index.xhsml','pages/test/test.js','pages/result/result.js','assets/test-covers/rice.jpg','vendor/poster-editor.cjs','web/index.html','web/app.js'];for(const file of required)if(!existsSync(file))throw new Error(`Missing ${file}`);JSON.parse(readFileSync('app.json','utf8'));console.log(`Static checks passed (${required.length} required files).`);
|
||||
|
||||
@@ -7,6 +7,8 @@ required = [
|
||||
"pages/index/index.xhsml",
|
||||
"pages/test/test.js",
|
||||
"pages/result/result.js",
|
||||
"assets/test-covers/rice.jpg",
|
||||
"vendor/poster-editor.cjs",
|
||||
"web/index.html",
|
||||
"web/app.js",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const bracket = require('../utils/bracket');
|
||||
|
||||
function candidates(count) {
|
||||
return Array.from({ length: count }, (_, index) => ({ id: String(index), name: `C${index}` }));
|
||||
}
|
||||
|
||||
test('supports selecting several matches in the same round', () => {
|
||||
const state = bracket.create(candidates(4));
|
||||
assert.equal(bracket.chooseAt(state, 1, state.matches[1].right), false);
|
||||
assert.equal(state.round, 1);
|
||||
assert.equal(bracket.chooseAt(state, 0, state.matches[0].left), false);
|
||||
assert.equal(state.round, 2);
|
||||
assert.equal(bracket.choose(state, state.matches[0].left), true);
|
||||
assert.equal(state.pool.length, 1);
|
||||
assert.equal(state.decisions.length, 3);
|
||||
});
|
||||
|
||||
test('odd candidates receive a deterministic bye', () => {
|
||||
const state = bracket.create(candidates(3));
|
||||
bracket.chooseAt(state, 0, state.matches[0].left);
|
||||
assert.equal(state.round, 2);
|
||||
assert.equal(state.matches[0].right.id, '2');
|
||||
});
|
||||
+47
-5
@@ -1,6 +1,48 @@
|
||||
function create(candidates){return{round:1,pool:candidates.slice(),pairIndex:0,winners:[],eliminated:[],decisions:[]};}
|
||||
function current(state){if(state.pool.length===1)return null;return[state.pool[state.pairIndex],state.pool[state.pairIndex+1]||null];}
|
||||
function choose(state,winner){var pair=current(state);if(!pair||!pair[1])throw new Error('invalid bracket pair');var loser=pair[0].id===winner.id?pair[1]:pair[0];state.winners.push(winner);state.eliminated.unshift(loser);state.decisions.push({round:state.round,left_id:pair[0].id,right_id:pair[1].id,winner_id:winner.id});state.pairIndex+=2;if(state.pairIndex<state.pool.length){if(!state.pool[state.pairIndex+1]){state.winners.push(state.pool[state.pairIndex]);state.pairIndex+=1;}if(state.pairIndex<state.pool.length)return false;}state.pool=state.winners;state.winners=[];state.pairIndex=0;state.round+=1;return state.pool.length===1;}
|
||||
function ranking(state){return state.pool.concat(state.eliminated).map(function(item){return item.id;});}
|
||||
module.exports={create:create,current:current,choose:choose,ranking:ranking};
|
||||
function buildMatches(pool) {
|
||||
var matches = [];
|
||||
for (var index = 0; index < pool.length; index += 2) {
|
||||
matches.push({ left: pool[index], right: pool[index + 1] || null, winner: pool[index + 1] ? null : pool[index] });
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
function create(candidates) {
|
||||
var pool = candidates.slice();
|
||||
return { round: 1, pool: pool, matches: buildMatches(pool), eliminated: [], decisions: [] };
|
||||
}
|
||||
|
||||
function current(state) {
|
||||
var match = state.matches.filter(function (item) { return item.right && !item.winner; })[0];
|
||||
return match ? [match.left, match.right] : null;
|
||||
}
|
||||
|
||||
function advance(state) {
|
||||
if (state.matches.some(function (item) { return !item.winner; })) return false;
|
||||
state.pool = state.matches.map(function (item) { return item.winner; });
|
||||
if (state.pool.length === 1) return true;
|
||||
state.round += 1;
|
||||
state.matches = buildMatches(state.pool);
|
||||
return false;
|
||||
}
|
||||
|
||||
function chooseAt(state, matchIndex, winner) {
|
||||
var match = state.matches[matchIndex];
|
||||
if (!match || !match.right || match.winner) throw new Error('invalid bracket match');
|
||||
if (winner.id !== match.left.id && winner.id !== match.right.id) throw new Error('winner is not in match');
|
||||
var loser = match.left.id === winner.id ? match.right : match.left;
|
||||
match.winner = winner;
|
||||
state.eliminated.unshift(loser);
|
||||
state.decisions.push({ round: state.round, left_id: match.left.id, right_id: match.right.id, winner_id: winner.id });
|
||||
return advance(state);
|
||||
}
|
||||
|
||||
function choose(state, winner) {
|
||||
var index = state.matches.findIndex(function (item) { return item.right && !item.winner; });
|
||||
return chooseAt(state, index, winner);
|
||||
}
|
||||
|
||||
function ranking(state) {
|
||||
return state.pool.concat(state.eliminated).map(function (item) { return item.id; });
|
||||
}
|
||||
|
||||
module.exports = { create: create, current: current, choose: choose, chooseAt: chooseAt, ranking: ranking };
|
||||
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
'use strict';
|
||||
function hash(value){var h=2166136261,s=String(value);for(var i=0;i<s.length;i++){h^=s.charCodeAt(i);h=Math.imul(h,16777619);}return h>>>0;}
|
||||
function glyphAngle(seed,layer,index,max){return((hash(seed+':'+layer+':'+index)/4294967295)*2-1)*Math.abs(max||0);}
|
||||
function createDocument(options){options=options||{};return{schema:'entroia.poster-document',version:1,id:options.id||('doc-'+Date.now()),seed:options.seed||('seed-'+Date.now()),canvas:{width:options.width||1080,height:options.height||1440,background:options.background||'#fff'},layers:options.layers||[],bindings:options.bindings||{},meta:options.meta||{}};}
|
||||
module.exports={stableHash:hash,deterministicGlyphAngle:glyphAngle,createDocument:createDocument};
|
||||
Reference in New Issue
Block a user