feat(timer): 训练完成分享海报(canvas+小程序码),解决截图被截断
根因:onShareAppMessage 未返回 imageUrl,微信默认截页面顶部, 完成弹窗垂直居中导致分享卡片只截到上半部分。 实现: - 隐藏 canvas 2d 绘制 5:4(500x400)成绩海报,含绿色星星图标、状态文案、 大数字时长、连续打卡、底部引导与右下角小程序码 - _saveAndShowCompletion 弹窗数据就绪后预生成,onShareAppMessage 直接复用 - onTrainAgain 清空 _shareImageUrl 防旧图复用 - 新增 static/share-qr.jpg(用户提供的小程序码)
This commit is contained in:
@@ -11,3 +11,14 @@
|
|||||||
- **根因**:偶数 set 数时 `sets×hold/2` 正好落在某组练完→休息切换的那一 tick。`_circuitCue` 先播 halfway 并设 `_voiceBusyUntil=now+2600` 占麦;紧接着 `_advance()`→`_circuitPhase('resting')` 检查麦占用,`restStart` 被静音。原代码注释有意让"进度提示优先于阶段提示",但偶数组数下两者撞车。
|
- **根因**:偶数 set 数时 `sets×hold/2` 正好落在某组练完→休息切换的那一 tick。`_circuitCue` 先播 halfway 并设 `_voiceBusyUntil=now+2600` 占麦;紧接着 `_advance()`→`_circuitPhase('resting')` 检查麦占用,`restStart` 被静音。原代码注释有意让"进度提示优先于阶段提示",但偶数组数下两者撞车。
|
||||||
- **修复**:`_circuitCue` 里先算 `atRestBoundary`(phaseRemaining<=0 && rest>0 && setIndex<sets) 与 `restWillSpeak`(hold>=10 && rest>=5);halfway/last30/last10 在"落在休息边界且 restStart 会播"时跳过自身,让休息语音优先。奇数组数一半点在中段不会撞车,不动;短组配置 rest 不播也保持原 halfway 行为,无回归。改 `pages/timer/timer.js` 的 `_circuitCue`。
|
- **修复**:`_circuitCue` 里先算 `atRestBoundary`(phaseRemaining<=0 && rest>0 && setIndex<sets) 与 `restWillSpeak`(hold>=10 && rest>=5);halfway/last30/last10 在"落在休息边界且 restStart 会播"时跳过自身,让休息语音优先。奇数组数一半点在中段不会撞车,不动;短组配置 rest 不播也保持原 halfway 行为,无回归。改 `pages/timer/timer.js` 的 `_circuitCue`。
|
||||||
- 顺带:last30/last10 在更长偶数组数(如 4 组×30s 的 set3→rest)也会撞同一问题,一并按同规则让位,行为一致。
|
- 顺带:last30/last10 在更长偶数组数(如 4 组×30s 的 set3→rest)也会撞同一问题,一并按同规则让位,行为一致。
|
||||||
|
|
||||||
|
## 新增:训练完成分享海报(canvas 绘制 + 小程序码)
|
||||||
|
- **现象**:训练完成后点分享,微信默认截取当前页面,因完成弹窗垂直居中,卡片只截到上半部分(绿色星星+"完成!"),大数字和按钮被切掉。
|
||||||
|
- **方案**:用隐藏 canvas 2d 绘制一张 5:4(500×400 逻辑像素)的专用分享海报,在 `onShareAppMessage` 里作为 `imageUrl` 返回;提前在 `_saveAndShowCompletion` 弹窗数据就绪后生成,避免分享时现场绘制延迟。
|
||||||
|
- **海报内容**:浅灰底 + 顶部品牌 + 白色圆角成绩卡 + 绿色渐变星星图标 + 状态文案 + 大数字时长 + 连续打卡(如有)+ 底部"扫码一起打卡"引导 + 右下角小程序二维码。
|
||||||
|
- **文件改动**:
|
||||||
|
- `pages/timer/timer.wxml`:添加隐藏 `<canvas type="2d" id="sharePoster">`
|
||||||
|
- `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` 为相对小程序根目录;若二维码加载失败仍导出无二维码的海报,保证分享入口不灰。
|
||||||
|
|||||||
+202
-1
@@ -846,6 +846,9 @@ Page({
|
|||||||
completionTipRisk: scienceTip.risk
|
completionTipRisk: scienceTip.risk
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 预生成分享海报,点"分享"时 onShareAppMessage 直接复用,避免现场绘制延迟
|
||||||
|
this._drawSharePoster()
|
||||||
|
|
||||||
// Animate the big number from 0 to elapsed
|
// Animate the big number from 0 to elapsed
|
||||||
if (this._completionCountUp) this._completionCountUp.cancel()
|
if (this._completionCountUp) this._completionCountUp.cancel()
|
||||||
this._completionCountUp = countUp({
|
this._completionCountUp = countUp({
|
||||||
@@ -928,6 +931,8 @@ Page({
|
|||||||
this._completionCountUp.cancel()
|
this._completionCountUp.cancel()
|
||||||
this._completionCountUp = null
|
this._completionCountUp = null
|
||||||
}
|
}
|
||||||
|
// 清空上一轮分享图,避免"再来一次"后立即分享时复用旧图
|
||||||
|
this._shareImageUrl = ''
|
||||||
// Circuit mode: rebuild the FSM (start() only resets from idle, and a
|
// 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
|
// finished/paused timer is no longer idle, so a fresh init is the clean
|
||||||
// reset; _initCircuit also closes the completion modal).
|
// reset; _initCircuit also closes the completion modal).
|
||||||
@@ -969,6 +974,201 @@ Page({
|
|||||||
// taps on the completion modal).
|
// taps on the completion modal).
|
||||||
onNoop() { /* swallow */ },
|
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
|
const s = config.share.timer
|
||||||
return {
|
return {
|
||||||
title: s.titleTemplate.replace('{elapsed}', elapsed),
|
title: s.titleTemplate.replace('{elapsed}', elapsed),
|
||||||
path: s.path
|
path: s.path,
|
||||||
|
imageUrl: this._shareImageUrl || ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -215,4 +215,7 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 隐藏 canvas:用于绘制训练完成分享海报,onShareAppMessage 取它的导出图 -->
|
||||||
|
<canvas type="2d" id="sharePoster" class="share-poster-canvas"></canvas>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -638,3 +638,14 @@
|
|||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
box-shadow: 0 6rpx 16rpx rgba(var(--primary-rgb), 0.3);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
Reference in New Issue
Block a user