diff --git a/Dockerfile b/Dockerfile
index 5572499..e901ef3 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
diff --git a/assets/test-covers/rice.jpg b/assets/test-covers/rice.jpg
new file mode 100644
index 0000000..d6d2db3
Binary files /dev/null and b/assets/test-covers/rice.jpg differ
diff --git a/package.json b/package.json
index 4b303b5..862c296 100644
--- a/package.json
+++ b/package.json
@@ -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"}}
diff --git a/pages/index/index.xhsml b/pages/index/index.xhsml
index 425eb88..3e44157 100644
--- a/pages/index/index.xhsml
+++ b/pages/index/index.xhsml
@@ -3,7 +3,7 @@
{{error}}
- ONLY RICE · 48 强🍚
+ ONLY RICE · 48 强
本命饭终极 PK蛋炒饭、煲仔饭、咖喱饭……每轮二选一,看看谁能成为你唯一的那碗饭。
diff --git a/pages/result/result.css b/pages/result/result.css
index 4c7c1c2..bb44c96 100644
--- a/pages/result/result.css
+++ b/pages/result/result.css
@@ -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; }
diff --git a/pages/result/result.js b/pages/result/result.js
index ae04067..a7c96fb 100644
--- a/pages/result/result.js
+++ b/pages/result/result.js
@@ -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);
+}
diff --git a/pages/result/result.xhsml b/pages/result/result.xhsml
index 8a88d86..a20523b 100644
--- a/pages/result/result.xhsml
+++ b/pages/result/result.xhsml
@@ -1,2 +1,18 @@
-FINAL RICE本命饭出炉!♛48 种饭的最终冠军{{result.champion_name}}决赛击败 {{result.runner_up_name}}分享卡片3 款模板手绘贴纸{{sticker?'开启 ✓':'关闭'}}
-
+
+ 分享图编辑器{{result.test_title}} · {{result.champion_name}}
+
+
+
+
+
+
+
+
+ 晋级图
+
+ {{index + 1}}{{item.label}}
+
+
+ 装饰贴纸{{sticker ? '已开启' : '关闭'}}
+ 此工具将由 @entroia/poster-editor 的图层绑定驱动
+
diff --git a/pages/test/test.css b/pages/test/test.css
index 0d84b4e..b053fcf 100644
--- a/pages/test/test.css
+++ b/pages/test/test.css
@@ -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); } }
diff --git a/pages/test/test.js b/pages/test/test.js
index f4471b2..ccd9386 100644
--- a/pages/test/test.js
+++ b/pages/test/test.js
@@ -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 });
+ });
+ }
+});
diff --git a/pages/test/test.xhsml b/pages/test/test.xhsml
index c9d562e..9c19a9a 100644
--- a/pages/test/test.xhsml
+++ b/pages/test/test.xhsml
@@ -1,2 +1,29 @@
-正在抽签…{{error}}第 {{round}} 轮{{completed}} / {{total}}只能留下一碗饭。A🍚{{left.name}}选它晋级 ↗VSB🍚{{right.name}}选它晋级 ↗
-
+
+ 正在抽签…
+ {{error}}
+
+ 第 {{round}} 轮{{completed}} / {{totalDecisions}}
+
+
+ 一对
+ 多对
+ 滑动
+
+
+ A{{left.name}}{{mode === 'swipe' ? '向左滑选它' : '点按晋级'}}
+ VS
+ B{{right.name}}{{mode === 'swipe' ? '向右滑选它' : '点按晋级'}}
+
+
+
+ 晋级{{item.winner.name}}
+
+ {{item.left.name}}
+ VS
+ {{item.right.name}}
+
+
+
+ 正在保存你的冠军…
+
+
diff --git a/scripts/check.mjs b/scripts/check.mjs
index 6dd6aa1..0e4fc79 100644
--- a/scripts/check.mjs
+++ b/scripts/check.mjs
@@ -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).`);
diff --git a/scripts/check.py b/scripts/check.py
index dedf7e1..7c9dc95 100644
--- a/scripts/check.py
+++ b/scripts/check.py
@@ -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",
]
diff --git a/test/bracket.test.cjs b/test/bracket.test.cjs
new file mode 100644
index 0000000..ea65209
--- /dev/null
+++ b/test/bracket.test.cjs
@@ -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');
+});
diff --git a/utils/bracket.js b/utils/bracket.js
index dcbc566..2993228 100644
--- a/utils/bracket.js
+++ b/utils/bracket.js
@@ -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>>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};