feat: add animated choice modes and bracket editor
Verify mini program and deploy preview / verify-deploy (push) Successful in 50s

This commit is contained in:
2026-09-19 04:11:21 +08:00
parent 90ac8b951f
commit 36aee09dfa
39 changed files with 1669 additions and 178 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
.DS_Store
node_modules/
dist/
project.private.config.json
+9 -1
View File
@@ -1,5 +1,13 @@
FROM node:22-alpine AS web-build
WORKDIR /app
COPY package.json package-lock.json vite.config.js ./
RUN npm ci
COPY web ./web
RUN npm run build:web
FROM nginx:1.27-alpine
COPY web /usr/share/nginx/html
COPY --from=web-build /app/dist /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
+1 -1
View File
@@ -1,6 +1,7 @@
{
"pages": [
"pages/index/index",
"pages/discover/discover",
"pages/test/test",
"pages/result/result",
"pages/me/me"
@@ -15,4 +16,3 @@
"request": 10000
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 631 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 KiB

+1199
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -2,6 +2,11 @@
"name": "pk-lab-xhs",
"version": "0.1.0",
"private": true,
"scripts": { "check": "node scripts/check.mjs" }
"scripts": {
"check": "node scripts/check.mjs",
"test": "node --test test/*.test.cjs",
"build:web": "vite build --config vite.config.js"
},
"dependencies": { "motion": "^12.23.24" },
"devDependencies": { "vite": "^7.1.7" }
}
+18
View File
@@ -0,0 +1,18 @@
.discover { padding: 28rpx 18rpx 180rpx; background: #fff; }
.heading { font-size: 46rpx; font-weight: 950; }
.subheading { margin: 10rpx 0 34rpx; color: #777; font-size: 24rpx; }
.result-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12rpx; }
.result-card { overflow: hidden; border: 1rpx solid #eee; background: #fff; }
.result-card image { display: block; width: 100%; height: 290rpx; }
.result-copy { padding: 16rpx; }
.result-copy text { color: #777; font-size: 21rpx; }
.result-copy view { margin-top: 6rpx; font-size: 31rpx; font-weight: 900; }
.bottom-nav { position: fixed; z-index: 20; left: 28rpx; right: 28rpx; bottom: calc(24rpx + env(safe-area-inset-bottom)); display: flex; height: 104rpx; padding: 0 18rpx; border-radius: 20rpx; background: #080808; box-shadow: 0 18rpx 50rpx rgba(0,0,0,.22); }
.nav-item { position: relative; flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #888; font-size: 18rpx; }
.nav-item.active { color: #fff; }
.nav-item.active:before { content: ''; position: absolute; top: -25rpx; width: 94rpx; height: 94rpx; border: 14rpx solid #fff; border-radius: 50%; background: #080808; }
.nav-icon { z-index: 1; width: 26rpx; height: 26rpx; margin-bottom: 7rpx; border: 5rpx solid currentColor; }
.home-icon { transform: rotate(45deg); border-radius: 5rpx; }
.discover-icon { border-radius: 50%; border-width: 6rpx 2rpx; }
.me-icon { border-radius: 50%; }
.nav-item text { z-index: 1; }
+16
View File
@@ -0,0 +1,16 @@
var api = require('../../utils/api');
Page({
data: { results: [], loading: true, error: '' },
onLoad: function () { this.load(); },
load: function () {
var self = this;
api.request('/api/v1/results/discover').then(function (results) {
self.setData({ results: results, loading: false, error: '' });
}).catch(function (error) {
self.setData({ loading: false, error: error.message || '加载失败' });
});
},
openHome: function () { xhs.redirectTo({ url: '/pages/index/index' }); },
openMe: function () { xhs.redirectTo({ url: '/pages/me/me' }); }
});
+1
View File
@@ -0,0 +1 @@
{ "navigationBarTitleText": "大家的选择" }
+18
View File
@@ -0,0 +1,18 @@
<view class="page discover">
<view class="heading">大家最近选了什么</view>
<view class="subheading">看看同频的人把谁送到了最后</view>
<view xhs:if="{{loading}}" class="empty">正在收集答案…</view>
<view xhs:elif="{{error}}" class="empty">{{error}}</view>
<view xhs:elif="{{!results.length}}" class="empty">还没有公开结果</view>
<view xhs:else class="result-grid">
<view xhs:for="{{results}}" xhs:key="id" class="result-card">
<image src="/assets/test-covers/{{item.test_slug}}.jpg" mode="aspectFill" />
<view class="result-copy"><text>{{item.test_title}}</text><view>{{item.champion_name}}</view></view>
</view>
</view>
<view class="bottom-nav">
<view class="nav-item" bindtap="openHome"><view class="nav-icon home-icon"></view><text>测试</text></view>
<view class="nav-item active"><view class="nav-icon discover-icon"></view><text>发现</text></view>
<view class="nav-item" bindtap="openMe"><view class="nav-icon me-icon"></view><text>我的</text></view>
</view>
</view>
+27 -22
View File
@@ -1,23 +1,28 @@
.home { padding-bottom: 70rpx; }
.hero { position: relative; overflow: hidden; background: #ff6b35; color: #fff; border-radius: 36rpx; padding: 40rpx 34rpx 34rpx; box-shadow: 0 18rpx 50rpx rgba(168, 65, 25, .18); }
.hero-row { display: flex; justify-content: space-between; align-items: flex-start; }
.eyebrow { font-size: 21rpx; letter-spacing: 4rpx; opacity: .8; }
.title { width: 480rpx; margin-top: 18rpx; font-size: 58rpx; line-height: 1.05; font-weight: 900; }
.hero-copy { margin-top: 42rpx; width: 480rpx; font-size: 27rpx; line-height: 1.6; opacity: .92; }
.me-button { margin: 0; padding: 0 24rpx; min-width: 0; height: 64rpx; line-height: 64rpx; font-size: 25rpx; border-radius: 999rpx; background: rgba(255,255,255,.2); color: #fff; }
.hero-stickers { margin-top: 30rpx; display: flex; justify-content: space-between; font-size: 30rpx; font-weight: 800; }
.section-head { display: flex; justify-content: space-between; margin: 48rpx 6rpx 24rpx; font-size: 30rpx; font-weight: 800; }
.section-head .muted { font-size: 24rpx; font-weight: 500; }
.waterfall { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-gap: 22rpx; align-items: start; }
.test-card { overflow: hidden; background: #fff; border-radius: 28rpx; box-shadow: 0 9rpx 30rpx rgba(62, 42, 30, .08); }
.test-card:active { transform: scale(.98); }
.card-art { position: relative; height: 250rpx; overflow: hidden; }
.card-1 .card-art { height: 310rpx; }
.card-number { position: absolute; left: 20rpx; top: 18rpx; color: rgba(255,255,255,.8); font-size: 22rpx; font-weight: 800; }
.card-emoji { position: absolute; left: 50%; top: 52%; transform: translate(-50%, -50%) rotate(-8deg); font-size: 112rpx; }
.card-body { padding: 24rpx; }
.card-title { font-size: 30rpx; font-weight: 900; }
.card-copy { margin-top: 12rpx; color: #746a63; font-size: 23rpx; line-height: 1.45; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; }
.card-meta { margin-top: 22rpx; display: flex; justify-content: space-between; color: #9a8e86; font-size: 20rpx; }
.home { padding: 22rpx 18rpx 180rpx; background: #fff; }
.mast { display: flex; align-items: center; gap: 16rpx; padding: 8rpx 4rpx 22rpx; }
.logo-mark { position: relative; display: grid; place-items: center; width: 72rpx; height: 58rpx; overflow: hidden; background: #111; color: #fff; font-size: 24rpx; font-weight: 950; letter-spacing: -2rpx; }
.logo-cut { position: absolute; left: 25rpx; top: -18rpx; width: 28rpx; height: 28rpx; border: 8rpx solid #fff; border-radius: 50%; }
.logo-word { font-size: 31rpx; font-weight: 950; letter-spacing: 2rpx; }
.search { display: flex; align-items: center; height: 72rpx; margin-bottom: 20rpx; padding: 0 24rpx; background: #f3f3f3; border-radius: 8rpx; }
.search-icon { margin-right: 14rpx; font-size: 38rpx; transform: rotate(-20deg); }
.search input { flex: 1; font-size: 27rpx; }
.waterfall { column-count: 2; column-gap: 10rpx; }
.test-card { display: inline-block; width: 100%; margin: 0 0 18rpx; overflow: hidden; break-inside: avoid; background: #fff; }
.test-card:active { opacity: .78; transform: scale(.985); }
.card-art { display: block; width: 100%; height: 360rpx; background: #eee; }
.card-1 .card-art { height: 430rpx; }
.card-2 .card-art { height: 390rpx; }
.card-body { padding: 16rpx 8rpx 10rpx; }
.card-title { font-size: 28rpx; font-weight: 850; }
.card-copy { margin-top: 8rpx; color: #555; font-size: 22rpx; line-height: 1.42; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
.card-meta { margin-top: 12rpx; display: flex; justify-content: space-between; color: #999; font-size: 19rpx; }
.retry { margin-top: 24rpx; width: 240rpx; font-size: 24rpx; }
.bottom-nav { position: fixed; z-index: 20; left: 28rpx; right: 28rpx; bottom: calc(24rpx + env(safe-area-inset-bottom)); display: flex; height: 104rpx; padding: 0 18rpx; border-radius: 20rpx; background: #080808; box-shadow: 0 18rpx 50rpx rgba(0,0,0,.22); }
.nav-item { position: relative; flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #888; font-size: 18rpx; }
.nav-item.active { color: #fff; }
.nav-item.active:before { content: ''; position: absolute; top: -25rpx; width: 94rpx; height: 94rpx; border: 14rpx solid #fff; border-radius: 50%; background: #080808; }
.nav-icon { z-index: 1; width: 26rpx; height: 26rpx; margin-bottom: 7rpx; border: 5rpx solid currentColor; }
.home-icon { transform: rotate(45deg); border-radius: 5rpx; }
.discover-icon { border-radius: 50%; border-width: 6rpx 2rpx; }
.me-icon { border-radius: 50%; }
.nav-item text { z-index: 1; }
+10 -2
View File
@@ -1,13 +1,13 @@
var api = require('../../utils/api');
Page({
data: { tests: [], loading: true, error: '' },
data: { tests: [], visibleTests: [], keyword: '', loading: true, error: '' },
onLoad: function () { this.loadTests(); },
onPullDownRefresh: function () { this.loadTests(true); },
loadTests: function (refreshing) {
var self = this;
api.request('/api/v1/tests').then(function (tests) {
self.setData({ tests: tests, loading: false, error: '' });
self.setData({ tests: tests, visibleTests: tests, loading: false, error: '' });
}).catch(function (error) {
self.setData({ loading: false, error: error.message || '加载失败' });
}).then(function () {
@@ -19,5 +19,13 @@ Page({
openTest: function (event) {
xhs.navigateTo({ url: '/pages/test/test?slug=' + event.currentTarget.dataset.slug });
},
search: function (event) {
var keyword = (event.detail.value || '').trim().toLowerCase();
var visible = this.data.tests.filter(function (item) {
return (item.title + ' ' + item.subtitle).toLowerCase().indexOf(keyword) >= 0;
});
this.setData({ keyword: keyword, visibleTests: visible });
},
openDiscover: function () { xhs.navigateTo({ url: '/pages/discover/discover' }); },
openMe: function () { xhs.navigateTo({ url: '/pages/me/me' }); }
});
+10 -18
View File
@@ -1,29 +1,21 @@
<view class="page home">
<view class="hero">
<view class="hero-row">
<view>
<view class="eyebrow">TASTE LAB · 01</view>
<view class="title">今天,认真选一个。</view>
</view>
<button class="me-button" bindtap="openMe">我的</button>
</view>
<view class="hero-copy">没有“都可以”。每轮只选一个,让你的偏爱一路赢到底。</view>
<view class="hero-stickers"><text>✦</text><text>二选一</text><text>↗</text></view>
</view>
<view class="section-head"><text>全部测试</text><text class="muted">{{tests.length}} 个题库</text></view>
<view class="mast"><view class="logo-mark"><view class="logo-cut"></view><text>PK</text></view><view class="logo-word">偏爱研究所</view></view>
<view class="search"><text class="search-icon">⌕</text><input placeholder="搜索测试" bindinput="search" value="{{keyword}}" /></view>
<view xhs:if="{{loading}}" class="empty">正在摆好擂台…</view>
<view xhs:elif="{{error}}" class="empty">{{error}}<button class="retry" bindtap="loadTests">再试一次</button></view>
<view xhs:else class="waterfall">
<view xhs:for="{{tests}}" xhs:key="slug" class="test-card card-{{index % 3}}" data-slug="{{item.slug}}" bindtap="openTest">
<view class="card-art" style="background: {{item.accent}}">
<text class="card-number">0{{index + 1}}</text><text class="card-emoji">{{item.emoji}}</text>
</view>
<view xhs:for="{{visibleTests}}" xhs:key="slug" class="test-card card-{{index % 3}}" data-slug="{{item.slug}}" bindtap="openTest">
<image class="card-art" src="/assets/test-covers/{{item.slug}}.jpg" mode="aspectFill" />
<view class="card-body">
<view class="card-title">{{item.title}}</view>
<view class="card-copy">{{item.subtitle}}</view>
<view class="card-meta"><text>{{item.candidate_count}} 强</text><text>{{item.run_count}} 人完成 →</text></view>
<view class="card-meta"><text>{{item.candidate_count}} 强</text><text>{{item.run_count}} 人完成</text></view>
</view>
</view>
</view>
<view class="bottom-nav">
<view class="nav-item active"><view class="nav-icon home-icon"></view><text>测试</text></view>
<view class="nav-item" bindtap="openDiscover"><view class="nav-icon discover-icon"></view><text>发现</text></view>
<view class="nav-item" bindtap="openMe"><view class="nav-icon me-icon"></view><text>我的</text></view>
</view>
</view>
+10 -1
View File
@@ -13,4 +13,13 @@
.result-test { color: #887c74; font-size: 22rpx; }
.result-champion { margin-top: 6rpx; font-size: 34rpx; font-weight: 900; }
.arrow { font-size: 34rpx; }
.profile { padding-bottom: 180rpx; }
.bottom-nav { position: fixed; z-index: 20; left: 28rpx; right: 28rpx; bottom: calc(24rpx + env(safe-area-inset-bottom)); display: flex; height: 104rpx; padding: 0 18rpx; border-radius: 20rpx; background: #080808; box-shadow: 0 18rpx 50rpx rgba(0,0,0,.22); }
.nav-item { position: relative; flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #888; font-size: 18rpx; }
.nav-item.active { color: #fff; }
.nav-item.active:before { content: ''; position: absolute; top: -25rpx; width: 94rpx; height: 94rpx; border: 14rpx solid #fff; border-radius: 50%; background: #080808; }
.nav-icon { z-index: 1; width: 26rpx; height: 26rpx; margin-bottom: 7rpx; border: 5rpx solid currentColor; }
.home-icon { transform: rotate(45deg); border-radius: 5rpx; }
.discover-icon { border-radius: 50%; border-width: 6rpx 2rpx; }
.me-icon { border-radius: 50%; }
.nav-item text { z-index: 1; }
+3 -2
View File
@@ -19,6 +19,7 @@ Page({
api.request('/api/v1/me/link-code', { method: 'POST' }).then(function (data) {
self.setData({ linkCode: data.code });
}).catch(function (error) { xhs.showToast({ title: error.message || '生成失败', icon: 'none' }); });
}
},
openHome: function () { xhs.redirectTo({ url: '/pages/index/index' }); },
openDiscover: function () { xhs.redirectTo({ url: '/pages/discover/discover' }); }
});
+5 -1
View File
@@ -15,5 +15,9 @@
<view class="medal">#1</view><view class="result-main"><view class="result-test">{{item.test_title}}</view><view class="result-champion">{{item.champion_name}}</view></view><view class="arrow">→</view>
</view>
</view>
<view class="bottom-nav">
<view class="nav-item" bindtap="openHome"><view class="nav-icon home-icon"></view><text>测试</text></view>
<view class="nav-item" bindtap="openDiscover"><view class="nav-icon discover-icon"></view><text>发现</text></view>
<view class="nav-item active"><view class="nav-icon me-icon"></view><text>我的</text></view>
</view>
</view>
+24 -16
View File
@@ -1,16 +1,24 @@
.result-kicker { text-align: center; color: #ff6b35; font-size: 22rpx; letter-spacing: 6rpx; font-weight: 800; }
.result-title { margin: 14rpx 0 28rpx; text-align: center; font-size: 46rpx; font-weight: 900; }
.champion { position: relative; overflow: hidden; padding: 54rpx 36rpx; text-align: center; border-radius: 36rpx; background: #25211f; color: #fff; }
.crown { position: absolute; right: 24rpx; top: 2rpx; color: #ffe568; font-size: 100rpx; transform: rotate(13deg); }
.test-name { opacity: .62; font-size: 24rpx; }
.champion-name { margin-top: 18rpx; font-size: 70rpx; line-height: 1.15; font-weight: 900; }
.runner { margin-top: 22rpx; color: #e5dcd6; font-size: 23rpx; }
.poster-head { display: flex; justify-content: space-between; margin: 46rpx 4rpx 20rpx; font-size: 30rpx; font-weight: 900; }
.poster-head .muted { font-size: 23rpx; font-weight: 500; }
.poster { width: 100%; height: 925rpx; border-radius: 30rpx; box-shadow: 0 16rpx 40rpx rgba(50,30,20,.12); }
.theme-scroll { margin-top: 26rpx; white-space: nowrap; }
.theme-row { display: flex; }
.theme-chip { flex: none; margin: 0 14rpx 0 0; padding: 0 28rpx; height: 68rpx; line-height: 68rpx; border-radius: 99rpx; background: #fff; color: #6d625b; font-size: 23rpx; }
.theme-chip.active { background: #25211f; color: #fff; }
.sticker-row { display: flex; justify-content: space-between; margin-top: 26rpx; padding: 26rpx; border-radius: 24rpx; background: #fff; font-size: 25rpx; }
.save, .again { margin-top: 22rpx; 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; }
+86 -14
View File
@@ -1,4 +1,5 @@
var api = require('../../utils/api');
var posterEditor = require('../../vendor/poster-editor.cjs');
var THEMES = {
celebration: { bg: '#ff6b35', ink: '#fffaf4', card: '#25211f', accent: '#ffe568', label: '庆祝派对' },
@@ -16,9 +17,20 @@ Page({
{ 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('pk_last_result');
this.runId = options.id;
if (cached && cached.id === options.id) this.setData({ result: cached });
@@ -36,35 +48,45 @@ Page({
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(46, 205, 658, 440);
ctx.setFillStyle(theme.card); ctx.fillRect(30, 190, 690, 610);
ctx.setFillStyle(isCelebration ? '#fffaf4' : theme.ink);
ctx.setFontSize(26); ctx.fillText(result.test_title, 84, 270);
ctx.setFontSize(result.champion_name.length > 7 ? 62 : 82);
ctx.fillText(result.champion_name, 84, 405);
ctx.setFillStyle(theme.accent); ctx.fillRect(84, 440, Math.min(500, result.champion_name.length * 80), 18);
ctx.setFillStyle(isCelebration ? '#fffaf4' : theme.ink);
ctx.setFontSize(24); ctx.fillText('一路二选一,它赢到了最后。', 84, 525);
var ranking = result.ranking_names || [];
ctx.setFillStyle(theme.ink); ctx.setFontSize(25); ctx.fillText('我的 TOP 4', 54, 720);
for (var i = 0; i < Math.min(4, ranking.length); i += 1) {
ctx.setFontSize(26); ctx.fillText((i + 1) + '. ' + ranking[i], 64, 774 + i * 48);
}
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(28); ctx.fillText('WIN!', 607, 134);
ctx.setFontSize(52); ctx.fillText('✦', 640, 700); ctx.fillText('♡', 550, 900);
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();
@@ -88,3 +110,53 @@ Page({
},
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);
}
+16 -13
View File
@@ -1,15 +1,18 @@
<view class="page result-page" xhs:if="{{result}}">
<view class="result-kicker">FINAL ANSWER</view>
<view class="result-title">你的冠军诞生了!</view>
<view class="champion"><view class="crown">♛</view><view class="test-name">{{result.test_title}}</view><view class="champion-name">{{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>
<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="theme-scroll">
<view class="theme-row">
<button xhs:for="{{themeList}}" xhs:key="name" class="theme-chip {{template === item.name ? 'active' : ''}}" data-name="{{item.name}}" bindtap="pickTemplate">{{item.label}}</button>
</view>
</scroll-view>
<view class="sticker-row" bindtap="toggleSticker"><text>添加手绘贴纸</text><text>{{sticker ? '已开启 ✓' : '关闭'}}</text></view>
<button class="primary save" loading="{{saving}}" bindtap="savePoster">保存分享图</button>
<button class="secondary again" bindtap="replay">再测一次</button>
<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 -19
View File
@@ -1,19 +1,28 @@
.arena { min-height: 100vh; background: #fff8f2; }
.topline { display: flex; align-items: center; justify-content: space-between; }
.progress { margin-top: 20rpx; height: 10rpx; overflow: hidden; border-radius: 99rpx; background: #eee4dd; }
.progress-in { height: 100%; border-radius: inherit; background: #ff6b35; transition: width .25s; }
.question { margin: 54rpx 8rpx 34rpx; font-size: 52rpx; line-height: 1.18; font-weight: 900; }
.pair { position: relative; }
.choice { position: relative; min-height: 360rpx; padding: 34rpx; box-sizing: border-box; border-radius: 38rpx; color: #fff; box-shadow: 0 20rpx 50rpx rgba(80, 35, 18, .14); }
.choice + .choice { margin-top: 24rpx; }
.choice-a { background: #ff6b35; transform: rotate(-1deg); }
.choice-b { background: #2766df; transform: rotate(1deg); }
.choice:active { transform: scale(.975); }
.choice-index { width: 54rpx; height: 54rpx; line-height: 54rpx; text-align: center; border: 2rpx solid rgba(255,255,255,.6); border-radius: 50%; font-size: 24rpx; font-weight: 800; }
.food-mark { position: absolute; right: 34rpx; top: 24rpx; font-size: 92rpx; opacity: .8; transform: rotate(12deg); }
.choice-name { margin-top: 72rpx; font-size: 60rpx; line-height: 1.12; font-weight: 900; }
.tap-hint { margin-top: 54rpx; font-size: 24rpx; opacity: .75; }
.versus { position: absolute; z-index: 3; left: 50%; top: 50%; transform: translate(-50%, -50%) rotate(-8deg); width: 84rpx; height: 84rpx; line-height: 84rpx; text-align: center; color: #201d1b; background: #fff; border: 5rpx solid #201d1b; border-radius: 50%; font-size: 26rpx; font-weight: 900; }
.round-note { margin: 38rpx 0; text-align: center; color: #8f827a; font-size: 24rpx; }
.saving { position: fixed; left: 50%; bottom: 70rpx; transform: translateX(-50%); padding: 22rpx 34rpx; border-radius: 99rpx; background: #201d1b; color: #fff; font-size: 25rpx; }
.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); } }
+48 -2
View File
@@ -2,7 +2,7 @@ var api = require('../../utils/api');
var bracket = require('../../utils/bracket');
Page({
data: { loading: true, error: '', test: null, left: null, right: null, round: 1, progress: 0, completed: 0, totalDecisions: 0 },
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;
this.load();
@@ -19,14 +19,26 @@ Page({
},
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;
@@ -36,9 +48,43 @@ Page({
if (finished) this.complete();
else {
this.refreshPair();
this.choosing = false;
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];
+18 -9
View File
@@ -2,19 +2,28 @@
<view xhs:if="{{loading}}" class="empty">正在抽签…</view>
<view xhs:elif="{{error}}" class="empty">{{error}}</view>
<block xhs:else>
<view class="topline"><text class="pill">第 {{round}} 轮</text><text class="muted">{{progress}}%</text></view>
<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="question"><view>此刻只能留一个,</view><view>你会选谁?</view></view>
<view class="pair">
<view class="choice choice-a" data-side="left" bindtap="choose">
<view class="choice-index">A</view><view class="food-mark">{{test.emoji}}</view><view class="choice-name">{{left.name}}</view><view class="tap-hint">点它晋级 ↗</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="food-mark">{{test.emoji}}</view><view class="choice-name">{{right.name}}</view><view class="tap-hint">点它晋级 ↗</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 class="round-note">已完成 {{completed}} / {{totalDecisions}} 次选择</view>
<view xhs:if="{{submitting}}" class="saving">正在保存你的冠军…</view>
</block>
</view>
+1 -2
View File
@@ -1,8 +1,7 @@
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'];
const required = ['app.js','app.json','pages/index/index.xhsml','pages/discover/discover.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'));
const web = readFileSync('web/index.html','utf8');
if (!web.includes('./app.js') || !web.includes('./style.css')) throw new Error('Preview assets are not linked');
console.log(`Static checks passed (${required.length} required files).`);
+3
View File
@@ -5,8 +5,11 @@ required = [
"app.js",
"app.json",
"pages/index/index.xhsml",
"pages/discover/discover.xhsml",
"pages/test/test.js",
"pages/result/result.js",
"assets/test-covers/rice.jpg",
"vendor/poster-editor.cjs",
"web/index.html",
"web/app.js",
]
+25
View File
@@ -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');
});
+35 -31
View File
@@ -1,44 +1,48 @@
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) {
return {
round: 1,
pool: candidates.slice(),
pairIndex: 0,
winners: [],
eliminated: [],
decisions: []
};
var pool = candidates.slice();
return { round: 1, pool: pool, matches: buildMatches(pool), eliminated: [], decisions: [] };
}
function current(state) {
if (state.pool.length === 1) return null;
return [state.pool[state.pairIndex], state.pool[state.pairIndex + 1] || null];
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 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;
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, ranking: ranking };
module.exports = { create: create, current: current, choose: choose, chooseAt: chooseAt, ranking: ranking };
+5
View File
@@ -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};
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite';
export default defineConfig({
root: 'web',
base: './',
build: { outDir: '../dist', emptyOutDir: true }
});
+29 -17
View File
@@ -1,8 +1,10 @@
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 };
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]; }); }
@@ -23,24 +25,31 @@
request('/api/v1/tests').then(function (tests) { state.tests = tests; renderHome(); }).catch(renderError);
}
function renderHome() {
root.innerHTML = '<div class="shell"><section class="hero"><div class="eyebrow">TASTE LAB · PUBLIC PREVIEW</div><h1>今天,认真选一个。</h1><p>没有“都可以”。每轮只选一个,让你的偏爱一路赢到底。</p></section><div class="section-head"><h2>全部测试</h2><span class="muted">' + state.tests.length + ' 个题库</span></div><section class="grid">' + state.tests.map(function (test, index) {
return '<button class="test-card" data-slug="' + test.slug + '"><div class="art" style="background:' + test.accent + '"><small>0' + (index + 1) + '</small><span class="emoji">' + test.emoji + '</span></div><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>';
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 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(),index:0,winners:[],eliminated:[],decisions:[]}; renderPair(); }).catch(renderError); }
function pair() { return [state.bracket.pool[state.bracket.index], state.bracket.pool[state.bracket.index + 1] || null]; }
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;
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><h1>此刻只能留一个,<br>你会选谁?</h1><div class="pair"><button class="choice" data-side="0"><span class="choice-letter">A</span><span class="choice-emoji">' + state.test.emoji + '</span><strong class="choice-name">' + escapeHtml(items[0].name) + '</strong><span class="choice-hint">点它晋级 ↗</span></button><span class="vs">VS</span><button class="choice" data-side="1"><span class="choice-letter">B</span><span class="choice-emoji">' + state.test.emoji + '</span><strong class="choice-name">' + escapeHtml(items[1].name) + '</strong><span class="choice-hint">点它晋级 ↗</span></button></div></div>';
root.querySelectorAll('.choice').forEach(function (el) { el.addEventListener('click', function () { choose(items[Number(el.dataset.side)]); }); });
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 choose(winner) {
var b = state.bracket, items = pair(), loser = items[0].id === winner.id ? items[1] : items[0];
b.winners.push(winner); b.eliminated.unshift(loser); b.decisions.push({round:b.round,left_id:items[0].id,right_id:items[1].id,winner_id:winner.id}); b.index += 2;
if (b.index < b.pool.length && !b.pool[b.index + 1]) { b.winners.push(b.pool[b.index]); b.index += 1; }
if (b.index >= b.pool.length) { b.pool = b.winners; b.winners = []; b.index = 0; b.round += 1; }
if (b.pool.length === 1) complete(); else renderPair();
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;
@@ -49,19 +58,22 @@
}
function renderResult() {
var r = state.result;
root.innerHTML = '<div class="shell result-shell"><section class="champion-panel"><small>FINAL ANSWER · ' + escapeHtml(r.test_title) + '</small><h1>' + escapeHtml(r.champion_name) + '</h1><p>' + (r.runner_up_name ? '决赛击败 ' + escapeHtml(r.runner_up_name) : '一路赢到最后') + '</p></section><div class="result-layout"><canvas id="poster" class="poster" width="750" height="1000"></canvas><aside class="control"><h2>分享卡片</h2><p class="muted">换模板、加贴纸,保存后直接发小红书。</p><button class="theme active" data-theme="celebration">庆祝派对</button><button class="theme" data-theme="editorial">杂志封面</button><button class="theme" data-theme="sticker">贴纸手账</button><button id="stickerToggle" class="theme">手绘贴纸:开启</button><button id="savePoster" class="button wide">保存 PNG</button><button id="replay" class="button outline wide">再测一次</button></aside></div></div>';
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 研究所 / MY PICK',54,72);ctx.font='900 58px system-ui';ctx.fillText('我的冠军是',54,156);ctx.fillStyle=t[2];ctx.fillRect(46,205,658,440);ctx.fillStyle=state.template==='celebration'?'#fffaf4':t[1];ctx.font='600 26px system-ui';ctx.fillText(r.test_title,84,270);ctx.font='900 '+(r.champion_name.length>7?62:82)+'px system-ui';ctx.fillText(r.champion_name,84,405);ctx.fillStyle=t[3];ctx.fillRect(84,440,Math.min(500,r.champion_name.length*80),18);ctx.fillStyle=state.template==='celebration'?'#fffaf4':t[1];ctx.font='24px system-ui';ctx.fillText('一路二选一,它赢到了最后。',84,525);ctx.fillStyle=t[1];ctx.font='700 25px system-ui';ctx.fillText('我的 TOP 4',54,720);(r.ranking_names||[]).slice(0,4).forEach(function(name,i){ctx.font='26px system-ui';ctx.fillText((i+1)+'. '+name,64,774+i*48);});if(state.sticker){ctx.fillStyle=t[3];ctx.beginPath();ctx.arc(645,125,58,0,Math.PI*2);ctx.fill();ctx.fillStyle=t[2];ctx.font='900 28px system-ui';ctx.fillText('WIN!',607,134);ctx.font='52px system-ui';ctx.fillText('✦',640,700);ctx.fillText('♡',550,900);}ctx.fillStyle=t[1];ctx.font='20px system-ui';ctx.fillText('保存这张图,把答案分享给同频的人',54,956);
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('meButton').addEventListener('click',loadHistory); loadHome();
document.getElementById('homeButton').addEventListener('click',loadHome); document.getElementById('discoverButton').addEventListener('click',loadDiscover); document.getElementById('meButton').addEventListener('click',loadHistory); loadHome();
}());
+1
View File
@@ -0,0 +1 @@
.mode-tabs{display:flex;width:320px;margin:18px auto;padding:5px;border-radius:8px;background:#eee}.mode-tabs button{flex:1;padding:10px;border-radius:6px;background:transparent;color:#777}.mode-tabs button.active{background:#111;color:#fff}.arena{height:calc(100vh - 110px);overflow:hidden;margin-top:24px}.arena .pair{height:calc(100% - 120px)}.arena .choice{border-radius:8px}.multi-pairs{display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;gap:10px;height:calc(100% - 110px)}.mini-pair{display:grid;grid-template-columns:1fr 40px 1fr;min-height:0;border:2px solid #111;background:#fff}.mini-pair button{padding:14px;background:#fff;font-weight:800}.mini-pair span{display:grid;place-items:center;background:#111;color:#fff;font-size:12px}.mini-pair.winner{display:grid;place-items:center;text-align:center;background:#111;color:#fff;animation:winner-in .32s cubic-bezier(.2,1.4,.4,1)}@keyframes winner-in{from{opacity:0;transform:scale(.7)}to{opacity:1;transform:scale(1)}}@media(max-width:700px){.arena{height:calc(100vh - 95px);width:calc(100% - 20px)}.arena .pair{grid-template-columns:1fr 1fr}.arena .choice{min-height:0}.multi-pairs{gap:6px}.mini-pair{grid-template-columns:1fr 28px 1fr}.mini-pair button{padding:8px;font-size:13px}}
+1
View File
@@ -0,0 +1 @@
.editor-shell{padding-bottom:100px}.editor-title{display:flex;align-items:center;justify-content:space-between;margin-bottom:18px}.editor-title h2{margin:5px 0 0}.editor-title small{color:#777}.control-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px}.control-grid .theme{margin:0}.editor-tools{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:18px}.editor-tools button{padding:12px 4px;border:1px solid #ddd;border-radius:8px;background:#fff;font-size:12px}.editor-shell .poster{border-radius:8px}.editor-shell .control{border-radius:8px}@media(max-width:800px){.editor-title{align-items:flex-end}.editor-title h2{font-size:17px}.editor-shell .result-layout{grid-template-columns:minmax(0,1fr)}.editor-shell .control{padding:18px}}
+1
View File
@@ -0,0 +1 @@
.home-shell{max-width:920px;margin-top:24px;padding-bottom:100px}.mast{display:flex;align-items:center;gap:14px;margin:0 0 20px;font-size:24px}.logo-mark{display:grid;width:54px;height:42px;place-items:center;background:#111;color:#fff;font-weight:950;letter-spacing:-2px}.search{display:flex;align-items:center;gap:10px;margin-bottom:18px;padding:13px 16px;background:#f2f2f2;border-radius:5px}.search input{width:100%;border:0;outline:0;background:transparent;font:inherit}.home-shell .grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;align-items:start}.home-shell .test-card{border-radius:0;box-shadow:none}.home-shell .test-card:nth-child(3n+2) .art{height:390px}.home-shell .art{display:block;width:100%;height:330px;object-fit:cover}.home-shell .card-body{padding:14px 4px 22px}.bottom-nav{position:fixed;z-index:40;left:50%;bottom:22px;display:flex;width:min(360px,calc(100% - 36px));height:72px;padding:0 12px;transform:translateX(-50%);border-radius:16px;background:#080808;box-shadow:0 16px 44px rgba(0,0,0,.28)}.nav-tab{position:relative;flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;background:none;color:#888;font-size:12px}.nav-tab i{width:18px;height:18px;margin-bottom:5px;border:3px solid currentColor;border-radius:50%}.nav-tab:first-child{color:#fff}.nav-tab:first-child:before{content:"";position:absolute;top:-17px;width:62px;height:62px;border:9px solid #fff;border-radius:50%;background:#080808}.nav-tab i,.nav-tab span{z-index:1}@media(max-width:520px){.home-shell{width:calc(100% - 16px)}.home-shell .grid{gap:8px}.home-shell .art{height:250px}.home-shell .test-card:nth-child(3n+2) .art{height:300px}.home-shell .card-body{padding:12px 3px 18px}.choice-emoji{display:none}}
+6 -4
View File
@@ -6,13 +6,15 @@
<meta name="theme-color" content="#fff8f2">
<title>PK 研究所 · 公网预览</title>
<link rel="stylesheet" href="./style.css">
<link rel="stylesheet" href="./home.css">
<link rel="stylesheet" href="./arena.css">
<link rel="stylesheet" href="./editor.css">
</head>
<body>
<header class="nav"><button id="homeButton" class="brand">PK LAB <i>✦</i></button><button id="meButton" class="nav-pill">我的档案</button></header>
<main id="app" aria-live="polite"></main>
<nav class="bottom-nav"><button id="homeButton" class="nav-tab active"><i></i><span>测试</span></button><button id="discoverButton" class="nav-tab"><i></i><span>发现</span></button><button id="meButton" class="nav-tab"><i></i><span>我的</span></button></nav>
<div id="toast" class="toast" role="status"></div>
<script src="./runtime-env.js"></script>
<script src="./app.js"></script>
<script type="module" src="./runtime-env.js"></script>
<script type="module" src="./app.js"></script>
</body>
</html>