diff --git a/.workbuddy/memory/2026-08-14.md b/.workbuddy/memory/2026-08-14.md index e8b4cf5..2b9a389 100644 --- a/.workbuddy/memory/2026-08-14.md +++ b/.workbuddy/memory/2026-08-14.md @@ -11,3 +11,14 @@ - **根因**:偶数 set 数时 `sets×hold/2` 正好落在某组练完→休息切换的那一 tick。`_circuitCue` 先播 halfway 并设 `_voiceBusyUntil=now+2600` 占麦;紧接着 `_advance()`→`_circuitPhase('resting')` 检查麦占用,`restStart` 被静音。原代码注释有意让"进度提示优先于阶段提示",但偶数组数下两者撞车。 - **修复**:`_circuitCue` 里先算 `atRestBoundary`(phaseRemaining<=0 && rest>0 && setIndex=10 && rest>=5);halfway/last30/last10 在"落在休息边界且 restStart 会播"时跳过自身,让休息语音优先。奇数组数一半点在中段不会撞车,不动;短组配置 rest 不播也保持原 halfway 行为,无回归。改 `pages/timer/timer.js` 的 `_circuitCue`。 - 顺带:last30/last10 在更长偶数组数(如 4 组×30s 的 set3→rest)也会撞同一问题,一并按同规则让位,行为一致。 + +## 新增:训练完成分享海报(canvas 绘制 + 小程序码) +- **现象**:训练完成后点分享,微信默认截取当前页面,因完成弹窗垂直居中,卡片只截到上半部分(绿色星星+"完成!"),大数字和按钮被切掉。 +- **方案**:用隐藏 canvas 2d 绘制一张 5:4(500×400 逻辑像素)的专用分享海报,在 `onShareAppMessage` 里作为 `imageUrl` 返回;提前在 `_saveAndShowCompletion` 弹窗数据就绪后生成,避免分享时现场绘制延迟。 +- **海报内容**:浅灰底 + 顶部品牌 + 白色圆角成绩卡 + 绿色渐变星星图标 + 状态文案 + 大数字时长 + 连续打卡(如有)+ 底部"扫码一起打卡"引导 + 右下角小程序二维码。 +- **文件改动**: + - `pages/timer/timer.wxml`:添加隐藏 `` + - `pages/timer/timer.wxss`:`.share-poster-canvas` 移出视口、不响应点击 + - `pages/timer/timer.js`:新增 `_drawSharePoster()`、`_formatShareDuration()`;`_saveAndShowCompletion()` 里 setData 后预生成;`onShareAppMessage()` 返回 `imageUrl: this._shareImageUrl`;`onTrainAgain()` 清空旧图防止复用 + - `static/share-qr.jpg`:加入用户提供的小程序二维码资源 +- **注意**:导出用 jpg quality 0.9、dest 500×400,控制文件在 128KB 以内;二维码路径 `/static/share-qr.jpg` 为相对小程序根目录;若二维码加载失败仍导出无二维码的海报,保证分享入口不灰。 diff --git a/pages/timer/timer.js b/pages/timer/timer.js index cb6ce31..871cbb2 100644 --- a/pages/timer/timer.js +++ b/pages/timer/timer.js @@ -846,6 +846,9 @@ Page({ completionTipRisk: scienceTip.risk }) + // 预生成分享海报,点"分享"时 onShareAppMessage 直接复用,避免现场绘制延迟 + this._drawSharePoster() + // Animate the big number from 0 to elapsed if (this._completionCountUp) this._completionCountUp.cancel() this._completionCountUp = countUp({ @@ -928,6 +931,8 @@ Page({ this._completionCountUp.cancel() this._completionCountUp = null } + // 清空上一轮分享图,避免"再来一次"后立即分享时复用旧图 + this._shareImageUrl = '' // Circuit mode: rebuild the FSM (start() only resets from idle, and a // finished/paused timer is no longer idle, so a fresh init is the clean // reset; _initCircuit also closes the completion modal). @@ -969,6 +974,201 @@ Page({ // taps on the completion modal). onNoop() { /* swallow */ }, + /** + * 绘制训练完成分享海报并导出为临时图片。 + * 在 _saveAndShowCompletion 里弹窗数据就绪后调用,提前生成, + * 用户点"分享"时 onShareAppMessage 直接取 _shareImageUrl。 + */ + _drawSharePoster() { + const query = wx.createSelectorQuery().in(this) + query.select('#sharePoster') + .fields({ node: true, size: true }) + .exec((res) => { + if (!res || !res[0] || !res[0].node) return + const canvas = res[0].node + const ctx = canvas.getContext('2d') + const dpr = wx.getSystemInfoSync().pixelRatio || 1 + // 5:4 比例,控制导出文件<128KB;画布逻辑像素 500x400,实际按 dpr 放大 + const W = 500 + const H = 400 + canvas.width = W * dpr + canvas.height = H * dpr + ctx.scale(dpr, dpr) + ctx.clearRect(0, 0, W, H) + + const theme = this.data.theme || themeMod.getCurrentTheme() + const primary = theme.primary || '#FF6B35' + + // 与完成弹窗视觉一致的成绩数据 + const elapsed = this.data.completionActualElapsed || this.data.completionElapsed || 0 + const streak = this.data.completionStreak || 0 + const message = this.data.completionMessage || '完成!' + + // helpers + const roundRect = (x, y, w, h, r) => { + const rr = Math.min(r, w / 2, h / 2) + ctx.beginPath() + ctx.moveTo(x + rr, y) + ctx.lineTo(x + w - rr, y) + ctx.quadraticCurveTo(x + w, y, x + w, y + rr) + ctx.lineTo(x + w, y + h - rr) + ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h) + ctx.lineTo(x + rr, y + h) + ctx.quadraticCurveTo(x, y + h, x, y + h - rr) + ctx.lineTo(x, y + rr) + ctx.quadraticCurveTo(x, y, x + rr, y) + ctx.closePath() + } + + const drawStar = (cx, cy, outerR, innerR, points) => { + ctx.beginPath() + for (let i = 0; i < points * 2; i++) { + const r = i % 2 === 0 ? outerR : innerR + const a = (Math.PI / points) * i - Math.PI / 2 + const x = cx + Math.cos(a) * r + const y = cy + Math.sin(a) * r + if (i === 0) ctx.moveTo(x, y) + else ctx.lineTo(x, y) + } + ctx.closePath() + } + + // --- background --- + ctx.fillStyle = '#F7F8FA' + ctx.fillRect(0, 0, W, H) + + // --- top brand --- + ctx.fillStyle = '#888888' + ctx.font = 'normal 400 18px sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'alphabetic' + ctx.fillText('平板的支撑', W / 2, 36) + + // --- main card shadow --- + ctx.save() + ctx.fillStyle = 'rgba(0,0,0,0.06)' + roundRect(38, 54, 424, 250, 22) + ctx.fill() + ctx.restore() + + // --- main card --- + ctx.save() + ctx.fillStyle = '#FFFFFF' + roundRect(35, 52, 430, 246, 20) + ctx.fill() + ctx.restore() + + // --- gradient circle icon (same feel as completion-emoji-wrap) --- + const circleX = W / 2 + const circleY = 108 + const circleR = 40 + const grad = ctx.createLinearGradient(circleX - circleR, circleY - circleR, circleX + circleR, circleY + circleR) + grad.addColorStop(0, '#4CAF50') + grad.addColorStop(1, '#81C784') + ctx.fillStyle = grad + ctx.beginPath() + ctx.arc(circleX, circleY, circleR, 0, Math.PI * 2) + ctx.fill() + + // --- white star --- + ctx.fillStyle = '#FFFFFF' + drawStar(circleX, circleY, 20, 8, 5) + ctx.fill() + + // --- status text --- + ctx.fillStyle = '#333333' + ctx.font = 'normal 600 22px sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'alphabetic' + ctx.fillText(message, W / 2, 172) + + // --- big number + unit --- + const fmt = this._formatShareDuration(elapsed) + const numLen = fmt.num.length + const numSize = numLen <= 2 ? 90 : numLen <= 3 ? 76 : numLen <= 4 ? 60 : 48 + ctx.font = `normal 800 ${numSize}px sans-serif` + ctx.textAlign = 'right' + ctx.textBaseline = 'alphabetic' + ctx.fillStyle = primary + const numX = W / 2 + 4 + const numY = 230 + ctx.fillText(fmt.num, numX, numY) + + ctx.fillStyle = '#666666' + ctx.font = 'normal 600 22px sans-serif' + ctx.textAlign = 'left' + ctx.fillText(fmt.unit, numX + 8, numY - 6) + + // --- streak --- + if (streak > 0) { + ctx.fillStyle = primary + ctx.font = 'normal 500 18px sans-serif' + ctx.textAlign = 'center' + ctx.fillText(`连续打卡 ${streak} 天`, W / 2, 272) + } + + // --- bottom left: CTA text --- + ctx.textAlign = 'left' + ctx.textBaseline = 'alphabetic' + ctx.fillStyle = '#333333' + ctx.font = 'normal 700 22px sans-serif' + ctx.fillText('扫码一起打卡', 48, 338) + + ctx.fillStyle = '#999999' + ctx.font = 'normal 400 15px sans-serif' + ctx.fillText('平板的支撑 · 每天进步一点点', 48, 366) + + // --- bottom right: QR code --- + const qrSize = 88 + const qrX = W - 48 - qrSize + const qrY = 308 + const doExport = () => { + wx.canvasToTempFilePath({ + canvas, + width: W, + height: H, + destWidth: W, + destHeight: H, + fileType: 'jpg', + quality: 0.9, + success: (r) => { + this._shareImageUrl = r.tempFilePath + }, + fail: (err) => { + console.warn('[sharePoster] export failed', err) + } + }) + } + + const img = canvas.createImage() + img.src = '/static/share-qr.jpg' + img.onload = () => { + ctx.save() + ctx.fillStyle = '#FFFFFF' + roundRect(qrX - 6, qrY - 6, qrSize + 12, qrSize + 12, 8) + ctx.fill() + ctx.drawImage(img, qrX, qrY, qrSize, qrSize) + ctx.restore() + doExport() + } + img.onerror = () => { + // 二维码加载失败时仍导出一张无二维码的海报,避免分享按钮失效 + doExport() + } + }) + }, + + /** + * 分享图专用时长格式化(与弹窗 WXS fmt 口径一致)。 + */ + _formatShareDuration(s) { + s = Math.floor(s || 0) + if (s < 60) return { num: String(s), unit: '秒' } + const m = Math.floor(s / 60) + const sec = s % 60 + return sec === 0 ? { num: String(m), unit: '分钟' } : { num: `${m}分${sec}`, unit: '秒' } + }, + /** * 分享给朋友。训练完成弹窗里的分享按钮会走到这里,标题里拼上用户 * 刚才撑了多久,转化率更高;右上角"···"菜单也共用同一份文案。 @@ -978,7 +1178,8 @@ Page({ const s = config.share.timer return { title: s.titleTemplate.replace('{elapsed}', elapsed), - path: s.path + path: s.path, + imageUrl: this._shareImageUrl || '' } }, diff --git a/pages/timer/timer.wxml b/pages/timer/timer.wxml index e357234..641288a 100644 --- a/pages/timer/timer.wxml +++ b/pages/timer/timer.wxml @@ -215,4 +215,7 @@ + + + diff --git a/pages/timer/timer.wxss b/pages/timer/timer.wxss index 394a07b..385eeb9 100644 --- a/pages/timer/timer.wxss +++ b/pages/timer/timer.wxss @@ -638,3 +638,14 @@ color: #FFFFFF; box-shadow: 0 6rpx 16rpx rgba(var(--primary-rgb), 0.3); } + +/* ---- hidden canvas for share poster (drawn on completion, consumed by onShareAppMessage) ---- */ +.share-poster-canvas { + position: fixed; + left: -9999px; + top: -9999px; + width: 500px; + height: 400px; + pointer-events: none; + opacity: 0; +} diff --git a/static/share-qr.jpg b/static/share-qr.jpg new file mode 100644 index 0000000..8bc4ceb Binary files /dev/null and b/static/share-qr.jpg differ