feat(ui): 记录页月份联动与多项 UI 美化/修复

- records: 底部列表与月份选择器联动,新增 [本月|最近] 分段控件;标题/空态随模式切换
- leaderboard: 领奖台布局(冠军居中+皇冠)、次数标注于底座且三档水平对齐;头像 cloud:// 批量预解析提速(云函数需重新部署)
- index/timer: 主色底图标改白色变体修复隐形;目标数字按微信字体缩放补偿真机遮挡
- settings: 配色方案横滑渐变色卡;头像上传前压缩至 200px
- theme: 新增 teal 主题;暗黑背景统一对齐
- 圆形头像双保险(image 自身 border-radius)修复月/年榜方图

注: cloudfunctions/leaderboard 改动须在开发者工具重新部署才生效。
This commit is contained in:
2026-07-22 09:36:22 +08:00
parent 70b87d424d
commit 6176f2c341
32 changed files with 924 additions and 197 deletions
+10
View File
@@ -15,7 +15,17 @@ App({
pages.forEach(p => {
if (p && typeof p.applyThemeToPage === 'function') {
p.applyThemeToPage()
} else if (p && p.route) {
// index/records/leaderboard 等页面没定义 applyThemeToPage 方法,
// 但都在 onLoad/onShow 里套用过主题,这里直接用 themeMod 兜底刷新,
// 否则系统深/浅色切换时这些页不会实时变色。
try { themeMod.applyThemeToPage(p) } catch (e) {}
}
// 自定义 tabBar 不在 pages 列表里,单独通知它切换暗黑/明亮
try {
const tb = p.getTabBar && p.getTabBar()
if (tb && typeof tb.updateTheme === 'function') tb.updateTheme()
} catch (e) {}
})
})
+10 -8
View File
@@ -1,8 +1,10 @@
page {
--text: #333333;
--text-secondary: #999999;
--bg: #F5F5F5;
--bg-gradient-start: #F5F5F5;
/* Cold-start (pre-JS) fallback uses the default orange theme's tint;
getThemeStyle replaces these with the selected theme's primaryBg. */
--bg: #FFF3ED;
--bg-gradient-start: #FFF3ED;
--bg-gradient-end: #FAFAFA;
--card-bg: #FFFFFF;
--success: #4CAF50;
@@ -22,12 +24,12 @@ page {
page {
--text: #E5E5E7;
--text-secondary: #98989F;
--bg: #2C2C2E;
--bg-gradient-start: #2C2C2E;
--bg-gradient-end: #38383A;
--card-bg: #3A3A3C;
--bg-soft: #48484A;
--border: #48484A;
--bg: #131316;
--bg-gradient-start: #131316;
--bg-gradient-end: #1C1C20;
--card-bg: #26262B;
--bg-soft: #343439;
--border: #343439;
--success: #30D158;
--success-rgb: 48,209,88;
--danger: #FF6B6B;
+13
View File
@@ -28,6 +28,19 @@ const PAGE_SIZE = 100
* can't be re-run by a user. It's destructive.
*/
exports.main = async () => {
// 安全闸门:仅允许白名单内的管理员调用,否则直接拒绝,防止被任意用户
// 触发批量删除(该函数以特权身份运行且会删文档)。白名单在云函数环境变量
// ADMIN_OPENIDS 中以逗号分隔配置;未配置时默认拒绝(失败安全),避免误部署后被滥用。
const ctx = cloud.getWXContext()
const caller = (ctx && ctx.OPENID) || ''
const allowlist = (process.env.ADMIN_OPENIDS || '')
.split(',')
.map(s => s.trim())
.filter(Boolean)
if (!caller || allowlist.length === 0 || !allowlist.includes(caller)) {
return { err: 'forbidden', msg: '无权限执行该操作' }
}
// 1. Page through every doc in the collection.
const docs = []
let skip = 0
+45 -3
View File
@@ -126,7 +126,8 @@ exports.main = async (event) => {
openid,
duration,
sessions,
nickname: profile.nickname || ''
nickname: profile.nickname || '',
avatarUrl: profile.avatarUrl || ''
})
}
}
@@ -152,9 +153,23 @@ exports.main = async (event) => {
nickname: item.nickname || '',
name: _displayName(item),
duration: item.duration,
sessions: item.sessions
sessions: item.sessions,
avatarUrl: item.avatarUrl || ''
}))
// Pre-resolve cloud:// avatar fileIDs into temporary HTTPS URLs so the
// client <image> doesn't pay a getTempFileURL round-trip at render time
// (that lazy round-trip is what made avatars lag 1-2s behind the text).
// Non-cloud values (wx qlogo links, empty strings, base64 data URIs)
// pass through untouched. If the resolve fails we keep the original fileID
// — the client can still load it, just without the speed-up.
const _avatarUrls = ranked.map(r => r.avatarUrl).concat(myEntry ? [myEntry.avatarUrl] : [])
const _resolved = await _resolveAvatars(_avatarUrls)
_resolved.forEach((url, i) => {
if (i < ranked.length) ranked[i].avatarUrl = url
else if (myEntry) myEntry.avatarUrl = url
})
return {
period,
ranked,
@@ -165,12 +180,39 @@ exports.main = async (event) => {
nickname: myEntry.nickname || '',
name: _displayName(myEntry),
duration: myEntry.duration,
sessions: myEntry.sessions
sessions: myEntry.sessions,
avatarUrl: myEntry.avatarUrl || ''
} : null,
updatedAt: now.toISOString()
}
}
/**
* Batch-resolve cloud:// avatar fileIDs into temporary HTTPS URLs.
* The client <image> would otherwise perform this getTempFileURL round-trip
* lazily at render time — the root of the 1-2s avatar delay. Only cloud://
* IDs are resolved; any other value passes through untouched. getTempFileURL
* accepts ≤50 fileIDs per call, so we page in batches of 50. On failure we
* return the original URLs so the client degrades to its normal cloud:// load.
*/
const _resolveAvatars = async (urls) => {
const cloudUrls = (urls || []).filter(u => typeof u === 'string' && u.startsWith('cloud://'))
if (cloudUrls.length === 0) return urls || []
const map = {}
for (let i = 0; i < cloudUrls.length; i += 50) {
const batch = cloudUrls.slice(i, i + 50)
try {
const res = await cloud.getTempFileURL({ fileList: batch })
;(res.fileList || []).forEach(f => {
if (f && f.fileID && f.tempFileURL) map[f.fileID] = f.tempFileURL
})
} catch (e) {
console.warn('[leaderboard] getTempFileURL batch failed:', e)
}
}
return (urls || []).map(u => (u && map[u]) ? map[u] : u)
}
function maskOpenid(openid) {
if (!openid || openid === 'unknown') return '未知用户'
if (openid.length <= 4) return '****' + openid
+131
View File
@@ -0,0 +1,131 @@
# 微信小程序代码分析报告(平板支撑训练)
审查范围:`app.*``pages/*`5 页)、`utils/*`11 模块)、`cloudfunctions/*`4 个)、`custom-tab-bar``components/*`
整体评价:代码质量较高,日志、容错、云同步去重/合并都做得很到位。以下为发现的问题,按严重程度排列。
> **修复进度**2026-07-21):高优 #1、#2 已修;中优 #4、#5、#6 已修;低优 #7、#9、#10 已修。
> #3、#8 经复核为**误报**,无需修改(见正文)。
---
## 🔴 高(High
### 1. 同一天多次训练会错误跳过计划天数 ✅ 已修复
- **位置**`utils/plan.js``getPlanDay()`
- **问题**
```js
const trainedDays = new Set(eligibleRecords.map(r => r.date)).size
```
`r.date` 是 `"YYYY-MM-DD HH:MM:SS"`(含时分秒)。同一天训练两次会得到两条不同的字符串,
`Set` 计成 2 天,导致 `planDay` 被 +2,**平白跳过一天的进度**。计划进度应按"日历天"而非"训练次数"计算。
- **修复**:改用 `dateOnly` 去重
```js
const trainedDays = new Set(eligibleRecords.map(r => dateOnly(r.date))).size
```
`dateOnly` 已在 `utils/util.js` 中导出,本文件已 `require`
### 2. 系统深色模式切换时,3 个 tab 页不会实时重绘 ✅ 已修复
- **位置**`app.js` 的 `darkMod.watchDarkMode` 回调 + `pages/index|records|leaderboard/*.js`
- **问题**:回调只对有 `applyThemeToPage` 方法的页面生效:
```js
pages.forEach(p => { if (p && typeof p.applyThemeToPage === 'function') p.applyThemeToPage() })
```
但 **index / records / leaderboard 三个页面都没有定义 `applyThemeToPage` 方法**(只有 `settings` 定义了)。
结果:用户在手机系统里切深色/浅色时,这三页不会立刻变色,要等下次 `onShow` 才刷新。
- **修复**:在 app.js 的回调里补上兜底分支(与 settings 保持一致),并对自定义 tabBar 调用 `updateTheme()`
```js
pages.forEach(p => {
if (p && typeof p.applyThemeToPage === 'function') p.applyThemeToPage()
else if (p && p.route) { try { themeMod.applyThemeToPage(p) } catch(e){} }
try { const tb = p.getTabBar && p.getTabBar(); if (tb && tb.updateTheme) tb.updateTheme() } catch(e){}
})
```
(三页 `data` 中本就有 `themeStyle`,且 onLoad/onShow 都套用过主题,兜底可直接生效。)
---
## 🟠 中(Medium
### 3. 排行榜数据可伪造(⚠️ 经复核为误报 — 无需修复)
- **位置**`cloudfunctions/leaderboard/index.js`
- **复核结论**:初审认为"信任客户端上传的 `records.duration`",但实际查看云函数实现后发现——
该函数**只接收 `period` / `maxRank`**,duration 是在服务端从用户自己的 `doc.records` 重新累加出来的
(第 102132 行遍历 `records[monthKey]` 求和),并不读取客户端传入的 `duration`。
因此单个用户最多只能伪造自己那条记录,无法影响他人或全局榜单,属于客户端上报类健身应用的固有信任模型,可接受。
- **结论**:**无需修改**。若未来要做反作弊,可再加单条时长上限 / 频率校验,但属于增强项而非缺陷。
### 4. `admin-dedupe` 云函数无鉴权且具破坏性 ✅ 已修复
- **位置**`cloudfunctions/admin-dedupe/index.js`
- **问题**:该函数以管理员身份运行(wx-server-sdk 默认绕过安全规则),会**批量删除文档**,
但函数体内**没有任何调用者鉴权**。一旦部署,任意小程序用户都能调用它删数据。
- **修复**:在 `exports.main` 入口加 openid 白名单闸门(读环境变量 `ADMIN_OPENIDS`,逗号分隔):
```js
const ctx = cloud.getWXContext()
const caller = (ctx && ctx.OPENID) || ''
const allowlist = (process.env.ADMIN_OPENIDS || '').split(',').map(s => s.trim()).filter(Boolean)
if (!caller || allowlist.length === 0 || !allowlist.includes(caller)) {
return { err: 'forbidden', msg: '无权限执行该操作' }
}
```
- **部署要求**:必须在云函数环境变量中配置 `ADMIN_OPENIDS`(你的微信 openid);未配置时函数**默认拒绝执行(失败安全)**。
- 仍建议用毕即删/禁用该函数,白名单只是兜底闸门。
### 5. Timer 前台监听只注册不注销(监听器泄漏) ✅ 已修复
- **位置**`utils/timer.js` → `start()` 注册 `wx.onAppShow`,但 `stop()/pause()/onUnload` 均未 `wx.offAppShow`
- **问题**:每次进入训练页新建 Timer 都会注册一个 `onAppShow`,旧的不会被移除。
虽有 `if (this._running && !this._paused)` 守卫不会出错,但多次训练后会累积监听器(内存泄漏、潜在性能问题)。
- **修复**:抽出 `_bindAppShow()` / `_unbindAppShow()`,并在 `stop()` 中调用 `wx.offAppShow(this._onAppShow)` 且重置 `_appShowBound = false`。
页面 `onUnload` 已调用 `this._timer.stop()`,故页面销毁时监听一并注销,无遗漏。
### 6. `getTodayTarget` 在空计划天数数组时会崩溃 ✅ 已修复
- **位置**`utils/plan.js` → `getTodayTarget()`
```js
return day ? day.target : plan.days[0].target
```
- **问题**:若 `plan.days` 为空(totalDays=0,极端数据损坏场景),`plan.days[0]` 为 `undefined` → 访问 `.target` 抛异常。
- **修复**
```js
if (!plan.days || plan.days.length === 0) return 0
```
---
## 🟡 低(Low
### 7. `darkMode.js` 使用了已废弃 API ✅ 已修复
- `wx.getSystemInfoSync()` 已废弃,真机会打印警告。已改用 `wx.getWindowInfo()`(同样返回 `theme` 字段)。
### 8. `ui-modal` 的 `customStyle` 属性(⚠️ 误报 — 实际已使用)
- 初审称"wxml 中未使用",但复核 `components/ui-modal/ui-modal.wxml` 第 3 行:
```xml
<view class="ui-modal__body" catchtap="onNoop" style="{{customStyle}}">
```
该属性确实绑定到了 body 的 `style`,属正常可用的样式覆写入口,**不是死属性**。无需修改。
- 注:`timer.wxml` 里 `custom-style="margin-right:28rpx;"` 挂在 `ui-btn` 组件上,与 `ui-modal` 无关,亦无误。
### 9. `onTrainAgain` 用 `setData` 写入下划线前缀字段 ✅ 已修复
- `pages/timer/timer.js``this.setData({ ..., _countUpTask: null })`。
- **修复**:改为实例字段赋值 `this._countUpTask = null`(下划线前缀字段不应写进 page data)。
### 10. `index.js` 的 planProgress 边界为 NaN ✅ 已修复
- `Math.round((Math.min(planDay, plan.totalDays) / plan.totalDays) * 100)`,当 `plan.totalDays` 为 0 时得到 `NaN`。
- **修复**
```js
const planProgress = plan.totalDays > 0 ? Math.round((clampedDay / plan.totalDays) * 100) : 0
```
---
## ✅ 亮点(值得肯定)
- 云同步的"按 openid 定位 + 合并去重 + 永不重复 add"逻辑处理得很扎实,避免了旧版"每次冷启动新建文档导致排行翻倍"的坑。
- 录音/语音、震动提醒、暗色模式、自定义计划编辑器、完成庆祝动画等交互细节都考虑周全,且有完善的降级(云未部署/无密钥时静默回落本地)。
- `custom-tab-bar` 单独监听了系统主题变化,避免了切 tab 闪白,处理到位。
---
## 建议优先处理顺序(最终)
1. ~~#1 计划天数统计 bug~~ ✅
2. ~~#2 深色模式实时刷新~~ ✅
3. ~~#4 admin 函数鉴权~~ ✅(高优安全项,已修;部署时记得配 `ADMIN_OPENIDS`
4. ~~#5 / #6 / #7 / #9 / #10~~
5. #3#8 经复核为误报,无需处理。
+58 -9
View File
@@ -28,7 +28,7 @@ Component({
})
},
'remaining, duration, size, ringWidth, primaryColor, trackColor'() {
'remaining, duration, size, ringWidth, primaryColor, primaryLightColor, trackColor'() {
this._draw()
}
},
@@ -53,11 +53,19 @@ Component({
},
methods: {
// '#FF6B35' -> [255, 107, 53]
_hexToRgb(hex) {
const m = /^#?([0-9a-f]{6})$/i.exec(hex || '')
if (!m) return [255, 107, 53]
const n = parseInt(m[1], 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
},
_draw() {
const ctx = this._ctx
if (!ctx) return
const { size, ringWidth, duration, remaining, primaryColor, trackColor } = this.data
const { size, ringWidth, duration, remaining, primaryColor, primaryLightColor, trackColor } = this.data
const dpr = this._dpr
const w = size * dpr
@@ -66,7 +74,9 @@ Component({
const cx = w / 2
const cy = h / 2
const radius = (size - ringWidth) / 2 * dpr
// reserve room for the glowing end dot so its shadow isn't clipped
const glowPad = 6 * dpr
const radius = (size - ringWidth) / 2 * dpr - glowPad
const lw = ringWidth * dpr
// track ring
@@ -81,13 +91,52 @@ Component({
const ratio = duration > 0 ? Math.max(0, Math.min(1, remaining / duration)) : 0
if (ratio > 0.001) {
const startAngle = -Math.PI / 2
const endAngle = startAngle + ratio * Math.PI * 2
const sweep = ratio * Math.PI * 2
const [r1, g1, b1] = this._hexToRgb(primaryColor)
const [r2, g2, b2] = this._hexToRgb(primaryLightColor || primaryColor)
// Conic gradient along the arc (light -> primary), drawn as small
// arc segments. Segment count is bounded so per-frame redraws stay cheap.
const segs = Math.max(16, Math.min(120, Math.ceil(sweep / 0.06)))
for (let i = 0; i < segs; i++) {
const a0 = startAngle + (sweep * i) / segs
const a1 = startAngle + (sweep * (i + 1)) / segs + 0.004 // tiny overlap to hide seams
const t = 1 - i / (segs - 1) // head (i=0, oldest remaining) is primary, tail is light
const r = Math.round(r1 + (r2 - r1) * t)
const g = Math.round(g1 + (g2 - g1) * t)
const b = Math.round(b1 + (b2 - b1) * t)
ctx.beginPath()
ctx.arc(cx, cy, radius, a0, a1)
ctx.strokeStyle = `rgb(${r},${g},${b})`
ctx.lineWidth = lw
ctx.lineCap = 'butt'
ctx.stroke()
}
// round cap at the tail (12 o'clock side)
ctx.beginPath()
ctx.arc(cx, cy, radius, startAngle, endAngle)
ctx.strokeStyle = primaryColor
ctx.lineWidth = lw
ctx.lineCap = 'round'
ctx.stroke()
ctx.arc(cx + radius * Math.cos(startAngle), cy + radius * Math.sin(startAngle), lw / 2, 0, Math.PI * 2)
ctx.fillStyle = `rgb(${r2},${g2},${b2})`
ctx.fill()
// glowing dot at the leading end
const endAngle = startAngle + sweep
const ex = cx + radius * Math.cos(endAngle)
const ey = cy + radius * Math.sin(endAngle)
ctx.save()
ctx.shadowColor = primaryColor
ctx.shadowBlur = 12 * dpr
ctx.beginPath()
ctx.arc(ex, ey, lw / 2, 0, Math.PI * 2)
ctx.fillStyle = primaryColor
ctx.fill()
// white core for a "light bulb" feel
ctx.shadowBlur = 0
ctx.beginPath()
ctx.arc(ex, ey, lw / 4, 0, Math.PI * 2)
ctx.fillStyle = 'rgba(255,255,255,0.9)'
ctx.fill()
ctx.restore()
}
}
}
+2 -2
View File
@@ -21,8 +21,8 @@
}
.time-text {
font-size: 80rpx;
font-weight: 700;
font-size: 88rpx;
font-weight: 800;
color: var(--text);
font-variant-numeric: tabular-nums;
letter-spacing: 2rpx;
+2 -2
View File
@@ -84,8 +84,8 @@
}
.ui-btn__icon {
width: 1em;
height: 1em;
width: 1.3em;
height: 1.3em;
flex-shrink: 0;
}
+23
View File
@@ -7,6 +7,14 @@
box-sizing: border-box;
}
/* Dark mode: shadows barely read against the darker backdrop, so add a
faint bright edge to keep cards elevated and separated from the page. */
@media (prefers-color-scheme: dark) {
.ui-card {
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.45), 0 0 0 1rpx rgba(255, 255, 255, 0.04);
}
}
.ui-card--flush {
padding: 0;
}
@@ -18,6 +26,21 @@
border-radius: 20rpx;
}
/* Gradient variant: hero card filled with the theme gradient, white text.
Descendant elements override --text / --text-secondary locally so any
generic text inside automatically flips to white. */
.ui-card.gradient {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 100%);
box-shadow: 0 8rpx 28rpx rgba(var(--primary-rgb), 0.35);
/* These custom properties are the ONLY channel that crosses the component
style-isolation boundary into slotted page content (see MEMORY.md). Any
text/fill inside the hero card must read from them, never var(--primary). */
--text: #FFFFFF;
--text-secondary: rgba(255, 255, 255, 0.85);
--border: rgba(255, 255, 255, 0.28);
--on-primary: #FFFFFF;
}
/* Staggered entrance animation */
.ui-card.in {
animation: uiCardIn 0.45s ease both;
+1 -1
View File
@@ -69,7 +69,7 @@
/* dark mode */
@media (prefers-color-scheme: dark) {
.sk-card { background: var(--card-bg, #1C1C1E); }
.sk-card { background: var(--card-bg, #26262B); }
.skeleton--active .sk-bar,
.skeleton--active .sk-circle {
background: linear-gradient(90deg,
+3 -3
View File
@@ -4,16 +4,16 @@
*/
module.exports = {
/** 应用版本号,外显在设置页「关于」 */
version: 'v2.7',
version: 'v2.9',
/** 最后更新日期,外显在设置页「关于」 */
updatedAt: '2026-07-10',
updatedAt: '2026-07-22',
/** 开发者名称 / 微信号,设置页点击可复制 */
developer: '刘承',
/** 排行榜最大显示人数 */
leaderboardMaxRank: 20,
leaderboardMaxRank: 50,
/**
* 语音合成(TTS)参数 - 直接改这里调整音色/音量/语速,无需改逻辑代码。
+1 -1
View File
@@ -65,7 +65,7 @@
/* dark mode */
@media (prefers-color-scheme: dark) {
.dock-inner {
background: var(--card-bg, #1C1C1E);
background: var(--card-bg, #26262B);
box-shadow:
0 8rpx 32rpx rgba(0, 0, 0, 0.5),
0 2rpx 8rpx rgba(0, 0, 0, 0.3);
+54 -3
View File
@@ -14,6 +14,13 @@ Page({
todayDone: false,
todayDuration: 0,
todayTargetText: '',
// Ring number font size in rpx. WeChat auto-scales text by the user's
// font-size setting (fontSizeScaleFactor) but does NOT scale rpx box
// dimensions — so on a real device with enlarged system font the 84rpx
// number grows past the fixed-size circular ring and gets clipped. We
// cancel that scale here so the number renders at a constant visual size
// (matching the devtools/simulator) on every device. See _readFontScale().
targetTimeFontSize: 84,
streakCount: 0,
planName: '',
planDay: 1,
@@ -28,9 +35,50 @@ Page({
onLoad() {
themeMod.applyThemeToPage(this)
this._readFontScale()
this._firstShow = true
},
/**
* Read the device's WeChat font-size scale and compensate the ring number
* so it never overflows the fixed circular clip on real devices.
*
* WeChat auto-applies `fontSizeScaleFactor` to ALL text (including rpx font
* sizes) but leaves rpx box dimensions untouched. A user who bumps up the
* system font therefore sees enlarged text inside a non-enlarged ring.
*
* CRITICAL asymmetry: the DevTools simulator reports the SAME
* `fontSizeScaleFactor` as a real device (the user's actual WeChat setting)
* but does NOT actually scale rendered text. So if we compensate there, the
* number ends up too small in the simulator. We therefore SKIP the
* compensation under DevTools (host.env === 'devtools') and keep the design
* size — matching what the simulator renders literally.
*
* On real devices, dividing our design size by the factor cancels the
* auto-scale, keeping the number a constant 84rpx-equivalent everywhere.
* Bounded so a bad reading can only ever make the number smaller (safe),
* never larger (overflow).
*/
_readFontScale() {
let factor = 1
try {
const info = wx.getAppBaseInfo ? wx.getAppBaseInfo() : wx.getSystemInfoSync()
const isDevtools = info.host && info.host.env === 'devtools'
if (!isDevtools) {
if (typeof info.fontSizeScaleFactor === 'number' && info.fontSizeScaleFactor > 0) {
factor = info.fontSizeScaleFactor
} else if (typeof info.fontSizeSetting === 'number' && info.fontSizeSetting > 0) {
// fontSizeScaleFactor == currentFontSize / standardFontSize(17px)
factor = info.fontSizeSetting / 17
}
}
} catch (e) {}
const DESIGN = 84
let size = Math.round(DESIGN / factor)
size = Math.max(48, Math.min(96, size))
this.setData({ targetTimeFontSize: size })
},
onShow() {
themeMod.applyThemeToPage(this)
try {
@@ -86,9 +134,12 @@ Page({
const customPlans = storage.getCustomPlans()
const plan = planMod.getPlan(settings.planId, customPlans)
const planDay = planMod.getPlanDay(settings.planId, allRecords, settings.planStartDate, customPlans)
const target = planMod.getTodayTarget(settings.planId, Math.min(planDay, plan.totalDays))
const clampedDay = Math.min(planDay, plan.totalDays)
const target = planMod.getTodayTarget(settings.planId, clampedDay)
const theme = themeMod.getCurrentTheme()
// 防御:totalDays 为 0(极端数据损坏)时避免除以 0 得到 NaN
const planProgress = plan.totalDays > 0 ? Math.round((clampedDay / plan.totalDays) * 100) : 0
this.setData({
theme,
icons: iconsMod.build(theme),
@@ -98,9 +149,9 @@ Page({
todayTargetText: util.formatTime(target),
streakCount: streak.count,
planName: plan.name,
planDay: Math.min(planDay, plan.totalDays),
planDay: clampedDay,
planTotal: plan.totalDays,
planProgress: Math.round((Math.min(planDay, plan.totalDays) / plan.totalDays) * 100)
planProgress
})
},
+5 -5
View File
@@ -10,17 +10,17 @@
</view>
<!-- 连续打卡卡片 -->
<ui-card variant="in" class="{{justCompleted ? 'just-completed' : ''}}">
<ui-card variant="gradient in" class="{{justCompleted ? 'just-completed' : ''}}">
<view class="header-card">
<view class="streak-section">
<image class="streak-icon" src="{{icons.hotFill}}" mode="aspectFit"></image>
<image class="streak-icon" src="{{icons.hotWhite}}" mode="aspectFit"></image>
<text class="streak-num">{{streakCount}}</text>
<text class="streak-label">连续打卡</text>
</view>
<view class="divider"></view>
<view class="plan-info">
<view class="plan-badge">
<image class="plan-badge-icon" src="{{icons.formFill}}" mode="aspectFit"></image>
<image class="plan-badge-icon" src="{{icons.formWhite}}" mode="aspectFit"></image>
<text class="plan-badge-text">{{planName}}</text>
</view>
<view class="progress-bar-wrap">
@@ -44,7 +44,7 @@
</view>
<view class="target-display">
<view class="target-ring">
<text class="target-time">{{todayTargetText}}</text>
<text class="target-time" style="font-size: {{targetTimeFontSize}}rpx;">{{todayTargetText}}</text>
</view>
<text class="target-unit">平板支撑</text>
</view>
@@ -70,7 +70,7 @@
size="xl"
block
text="{{todayDone ? '再次训练' : '开始训练'}}"
icon-src="{{icons.playFill}}"
icon-src="{{icons.playWhite}}"
bindtap="onStartTrain"
></ui-btn>
+24 -12
View File
@@ -54,8 +54,11 @@
.streak-num {
font-size: 56rpx;
font-weight: 700;
color: var(--primary);
line-height: 1.1;
/* On the gradient hero card --text cascades to white; on any plain card it
stays dark, so var(--text) reads correctly in both contexts. We must NOT
use var(--primary) here — that would be orange-on-orange and invisible. */
color: var(--text);
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.12);
}
.streak-label {
@@ -114,7 +117,9 @@
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary), var(--primary-light));
/* On the gradient hero card --on-primary (white) overrides the fallback,
so the fill stays visible instead of blending into the orange card. */
background: var(--on-primary, linear-gradient(90deg, var(--primary), var(--primary-light)));
border-radius: 4rpx;
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
@@ -126,9 +131,9 @@
top: -4rpx;
width: 16rpx;
height: 16rpx;
background: var(--primary);
background: var(--on-primary, var(--primary));
border-radius: 50%;
box-shadow: 0 0 10rpx var(--primary);
box-shadow: 0 0 10rpx var(--on-primary, var(--primary));
}
.progress-label {
@@ -167,20 +172,27 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: 200rpx;
height: 200rpx;
width: 280rpx;
height: 280rpx;
border-radius: 50%;
background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.08), rgba(var(--primary-rgb), 0.02));
border: 4rpx solid rgba(var(--primary-rgb), 0.15);
margin-bottom: 16rpx;
background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.10), rgba(var(--primary-rgb), 0.03));
border: 6rpx solid rgba(var(--primary-rgb), 0.18);
box-shadow: 0 0 0 14rpx rgba(var(--primary-rgb), 0.05);
margin-bottom: 20rpx;
overflow: hidden;
}
.target-time {
font-size: 64rpx;
font-weight: 700;
font-size: 84rpx;
font-weight: 800;
color: var(--primary);
font-variant-numeric: tabular-nums;
letter-spacing: -1rpx;
line-height: 1;
max-width: 240rpx;
text-align: center;
white-space: nowrap;
overflow: hidden;
}
.target-unit {
+67 -30
View File
@@ -1,13 +1,15 @@
<view class="container page-enter" style="{{themeStyle}}">
<!-- Period tabs -->
<view class="period-tabs">
<view
wx:for="{{periods}}"
wx:key="key"
class="period-tab {{activePeriod === item.key ? 'active' : ''}}"
data-period="{{item.key}}"
bindtap="switchPeriod"
>{{item.label}}</view>
<!-- Period tabs: pill-style segmented control, consistent with settings -->
<view class="period-seg-wrap">
<view class="period-seg">
<view
wx:for="{{periods}}"
wx:key="key"
class="period-seg-item {{activePeriod === item.key ? 'active' : ''}}"
data-period="{{item.key}}"
bindtap="switchPeriod"
>{{item.label}}</view>
</view>
</view>
<!-- Loading skeleton -->
@@ -27,33 +29,68 @@
<text class="empty-sub">快去训练吧,争当第一名!</text>
</view>
<!-- Rank list -->
<view class="rank-list" wx:else>
<view
wx:for="{{rankedList}}"
wx:key="openid"
class="rank-item {{item.openid === myEntry.openid ? 'is-me' : ''}} rank-item--enter"
style="animation-delay: {{index < 10 ? index * 60 : 0}}ms;"
>
<view class="rank-num {{item.rank === 1 ? 'gold' : item.rank === 2 ? 'silver' : item.rank === 3 ? 'bronze' : ''}}">
<text wx:if="{{item.rank === 1}}">🥇</text>
<text wx:elif="{{item.rank === 2}}">🥈</text>
<text wx:elif="{{item.rank === 3}}">🥉</text>
<text wx:else>{{item.rank}}</text>
<block wx:else>
<!-- Podium: top 3 (only when there are at least 3 entries) -->
<view class="podium" wx:if="{{rankedList.length >= 3}}">
<!-- 2nd -->
<view class="podium-slot podium-slot--second rank-item--enter" style="animation-delay: 60ms;">
<view class="podium-avatar-wrap">
<view class="avatar avatar--md {{rankedList[1].avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{rankedList[1].avatarUrl || icons.peopleFill}}" mode="{{rankedList[1].avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
<view class="podium-medal podium-medal--silver">2</view>
</view>
<text class="podium-name">{{rankedList[1].name}}</text>
<text class="podium-dur">{{rankedList[1].durationText}}</text>
<view class="podium-base podium-base--second"><text class="podium-base__label">{{rankedList[1].sessions}}次</text></view>
</view>
<image class="rank-avatar" src="{{icons.peopleFill}}" mode="aspectFit"></image>
<view class="rank-info">
<text class="rank-name">{{item.name}}</text>
<text class="rank-sessions">{{item.sessions}}次</text>
<!-- 1st -->
<view class="podium-slot podium-slot--first rank-item--enter" style="animation-delay: 0ms;">
<image class="podium-crown" src="{{icons.crownFill}}" mode="aspectFit"></image>
<view class="podium-avatar-wrap">
<view class="avatar avatar--md avatar--lg {{rankedList[0].avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{rankedList[0].avatarUrl || icons.peopleFill}}" mode="{{rankedList[0].avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
<view class="podium-medal podium-medal--gold">1</view>
</view>
<text class="podium-name podium-name--first">{{rankedList[0].name}}</text>
<text class="podium-dur podium-dur--first">{{rankedList[0].durationText}}</text>
<view class="podium-base podium-base--first"><text class="podium-base__label">{{rankedList[0].sessions}}次</text></view>
</view>
<!-- 3rd -->
<view class="podium-slot podium-slot--third rank-item--enter" style="animation-delay: 120ms;">
<view class="podium-avatar-wrap">
<view class="avatar avatar--md {{rankedList[2].avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{rankedList[2].avatarUrl || icons.peopleFill}}" mode="{{rankedList[2].avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
<view class="podium-medal podium-medal--bronze">3</view>
</view>
<text class="podium-name">{{rankedList[2].name}}</text>
<text class="podium-dur">{{rankedList[2].durationText}}</text>
<view class="podium-base podium-base--third"><text class="podium-base__label">{{rankedList[2].sessions}}次</text></view>
</view>
<text class="rank-dur">{{item.durationText}}</text>
</view>
</view>
<!-- Rank list: top-3 shown on the podium are skipped when podium exists -->
<view class="rank-list">
<view
wx:for="{{rankedList}}"
wx:key="openid"
wx:if="{{rankedList.length < 3 || index >= 3}}"
class="rank-item {{myEntry && item.openid === myEntry.openid ? 'is-me' : ''}} rank-item--enter"
style="animation-delay: {{index < 10 ? index * 60 : 0}}ms;"
>
<view class="rank-num {{item.rank === 1 ? 'gold' : item.rank === 2 ? 'silver' : item.rank === 3 ? 'bronze' : ''}}">
<text>{{item.rank}}</text>
</view>
<view class="avatar avatar--sm {{item.avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{item.avatarUrl || icons.peopleFill}}" mode="{{item.avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
<view class="rank-info">
<text class="rank-name">{{item.name}}</text>
<text class="rank-sessions">{{item.sessions}}次</text>
</view>
<text class="rank-dur">{{item.durationText}}</text>
</view>
</view>
</block>
<!-- My entry pinned at bottom (if not already in list) -->
<view class="my-bar" wx:if="{{myEntry && myEntry.rank > rankedList.length}}">
<view class="rank-num">{{myEntry.rank}}</view>
<image class="rank-avatar" src="{{icons.peopleFill}}" mode="aspectFit"></image>
<view class="rank-num my-bar__num">{{myEntry.rank}}</view>
<view class="avatar avatar--sm my-bar__avatar {{myEntry.avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{myEntry.avatarUrl || icons.peopleFill}}" mode="{{myEntry.avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
<view class="rank-info">
<text class="rank-name">我 ({{myEntry.name}})</text>
<text class="rank-sessions">{{myEntry.sessions}}次</text>
+211 -38
View File
@@ -5,42 +5,36 @@
box-sizing: border-box;
}
/* Period tabs */
.period-tabs {
display: flex;
background: var(--card-bg);
padding: 0 32rpx;
border-bottom: 1rpx solid var(--border);
/* Period tabs: pill segmented control, same language as settings .segmented */
.period-seg-wrap {
padding: 20rpx 24rpx 8rpx;
position: sticky;
top: 0;
z-index: 10;
}
.period-tab {
.period-seg {
display: flex;
background: var(--bg-soft);
border-radius: 999rpx;
padding: 6rpx;
}
.period-seg-item {
flex: 1;
text-align: center;
padding: 24rpx 0 20rpx;
font-size: 30rpx;
padding: 16rpx 0;
font-size: 28rpx;
color: var(--text-secondary);
position: relative;
transition: color 0.2s, background-color 0.3s ease;
border-radius: 999rpx;
transition: background 0.25s ease, color 0.25s ease, box-shadow 0.25s ease;
}
.period-tab.active {
.period-seg-item.active {
background: var(--card-bg);
color: var(--primary);
font-weight: 600;
}
.period-tab.active::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 40rpx;
height: 6rpx;
border-radius: 3rpx;
background: var(--primary);
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.10);
}
/* Status */
@@ -75,6 +69,163 @@
opacity: 0.6;
}
/* ---- podium (top 3) ---- */
.podium {
display: flex;
align-items: flex-end;
justify-content: center;
padding: 32rpx 24rpx 0;
gap: 20rpx;
}
.podium-slot {
display: flex;
flex-direction: column;
align-items: center;
width: 200rpx;
}
.podium-crown {
width: 56rpx;
height: 56rpx;
margin-bottom: -8rpx;
z-index: 1;
animation: float 2.5s ease-in-out infinite;
}
.podium-avatar-wrap {
position: relative;
}
/* Circular avatar: a rounded <view> clips the <image> inside it.
border-radius on the <image> itself is NOT reliably clipped by some
WeChat base libraries, so we wrap it and clip on the parent instead. */
.avatar {
position: relative;
border-radius: 50%;
overflow: hidden;
background: var(--bg-soft);
flex-shrink: 0;
box-sizing: border-box;
}
.avatar__img {
width: 100%;
height: 100%;
display: block;
/* Some WeChat base libraries don't clip a remote <image>'s native layer
with the parent view's overflow:hidden (they DO clip the placeholder
SVG, which is why daily looked fine but monthly/yearly real photos
showed square). Rounding the <image> itself is the cross-version-safe
fix and also covers the wrapper. */
border-radius: 50%;
}
.avatar--md {
width: 96rpx;
height: 96rpx;
padding: 20rpx;
opacity: 0.7;
border: 4rpx solid var(--card-bg);
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
margin-bottom: 12rpx;
}
.avatar--lg {
width: 128rpx;
height: 128rpx;
padding: 26rpx;
border: 4rpx solid rgba(var(--primary-rgb), 0.4);
box-shadow: 0 6rpx 20rpx rgba(var(--primary-rgb), 0.25);
opacity: 0.85;
margin-bottom: 12rpx;
}
/* A real photo fills the whole circle (no inner ring), mirroring the
settings-page avatar. The placeholder keeps the padding ring above. */
.avatar.has-avatar {
padding: 0;
opacity: 1;
}
.podium-medal {
position: absolute;
bottom: -8rpx;
left: 50%;
transform: translateX(-50%);
width: 40rpx;
height: 40rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
font-weight: 700;
color: #FFFFFF;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.15);
}
.podium-medal--gold { background: linear-gradient(135deg, #FFC53D, #FA8C16); }
.podium-medal--silver { background: linear-gradient(135deg, #C9CDD4, #8C8C8C); }
.podium-medal--bronze { background: linear-gradient(135deg, #E8A06A, #D46B08); }
.podium-name {
font-size: 26rpx;
font-weight: 500;
color: var(--text);
max-width: 180rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.3;
}
.podium-name--first {
font-size: 28rpx;
font-weight: 600;
}
/* session count printed on the podium base. Pinned a fixed offset above the
base's bottom edge so all three sit on the SAME horizontal line despite the
stepped base heights (the podium itself is bottom-aligned via flex-end). */
.podium-base__label {
position: absolute;
left: 0;
right: 0;
bottom: 10rpx;
text-align: center;
font-size: 22rpx;
font-weight: 700;
color: var(--primary);
line-height: 1;
}
.podium-dur {
font-size: 24rpx;
font-weight: 600;
color: var(--text-secondary);
margin-top: 2rpx;
font-variant-numeric: tabular-nums;
}
.podium-dur--first {
font-size: 28rpx;
color: var(--primary);
}
/* podium bases: stepped blocks under each slot */
.podium-base {
width: 100%;
margin-top: 16rpx;
border-radius: 16rpx 16rpx 0 0;
background: linear-gradient(180deg, rgba(var(--primary-rgb), 0.10), rgba(var(--primary-rgb), 0.02));
position: relative;
}
.podium-base--first { height: 96rpx; background: linear-gradient(180deg, rgba(var(--primary-rgb), 0.18), rgba(var(--primary-rgb), 0.04)); }
.podium-base--second { height: 64rpx; }
.podium-base--third { height: 48rpx; }
/* Rank list */
.rank-list {
padding: 16rpx 24rpx;
@@ -116,14 +267,11 @@
.rank-num.silver { background: var(--bg-soft); color: #8C8C8C; font-size: 36rpx; }
.rank-num.bronze { background: rgba(212, 107, 8, 0.15); color: #D46B08; font-size: 36rpx; }
.rank-avatar {
.avatar--sm {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: var(--bg-soft);
flex-shrink: 0;
margin-right: 16rpx;
opacity: 0.6;
margin-right: 16rpx;
}
.rank-info {
@@ -155,27 +303,46 @@
margin-left: 16rpx;
}
/* My rank bar (pinned) */
/* My rank bar (pinned): solid gradient card so it reads as "my highlight"
floating above the list, not a translucent outline */
.my-bar {
position: fixed;
bottom: calc(110rpx + env(safe-area-inset-bottom));
bottom: calc(120rpx + env(safe-area-inset-bottom));
left: 24rpx;
right: 24rpx;
display: flex;
align-items: center;
background: rgba(var(--primary-rgb), 0.12);
border: 2rpx solid var(--primary);
border-radius: 16rpx;
padding: 16rpx 24rpx;
background: linear-gradient(135deg, var(--primary), var(--primary-light));
border-radius: 20rpx;
padding: 20rpx 24rpx;
z-index: 10;
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.08);
box-shadow: 0 8rpx 24rpx rgba(var(--primary-rgb), 0.35);
}
.my-bar .rank-num.my-bar__num {
background: rgba(255, 255, 255, 0.22);
color: #FFFFFF;
}
.my-bar__avatar {
opacity: 1;
background: rgba(255, 255, 255, 0.85);
border: 2rpx solid rgba(255, 255, 255, 0.6);
}
.my-bar .rank-name {
color: var(--primary);
color: #FFFFFF;
font-weight: 600;
}
.my-bar .rank-sessions {
color: rgba(255, 255, 255, 0.8);
}
.my-bar .rank-dur {
color: #FFFFFF;
}
/* ---- rank item entrance animation ---- */
.rank-item--enter {
animation: rankIn 0.4s cubic-bezier(0.2, 0, 0.2, 1) both;
@@ -191,7 +358,13 @@
display: flex;
flex-direction: column;
align-items: center;
padding: 80rpx 32rpx;
justify-content: center;
/* 垂直居中在"顶部 segmented 控件以下、底部 tabBar 以上"的可用区域。
~120rpx ≈ seg-wrap 高度;134rpx + safe-area 与 .container 的
padding-bottom 一致,预留自定义 tabBar。 */
min-height: calc(100vh - 120rpx - 134rpx - env(safe-area-inset-bottom));
padding: 32rpx;
box-sizing: border-box;
}
.empty-illus {
+10
View File
@@ -17,6 +17,7 @@ Page({
currentMonth: new Date().getMonth() + 1,
monthRecords: [],
historyList: [],
listMode: 'month',
showDayDetail: false,
dayDetail: {},
icons: iconsMod.build()
@@ -76,6 +77,8 @@ Page({
maxDurationText: util.formatDuration(stats.maxDuration),
monthRecords: records,
historyList,
listMode: this.data.listMode || 'month',
displayList: (this.data.listMode || 'month') === 'month' ? records : historyList,
loading: false
})
},
@@ -104,6 +107,13 @@ Page({
this.refresh()
},
onToggleListMode(e) {
const mode = e.currentTarget.dataset.mode
if (!mode || mode === this.data.listMode) return
this.setData({ listMode: mode })
this.refresh()
},
onDayTap(e) {
const { year, month, day } = e.detail
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
+10 -6
View File
@@ -75,22 +75,26 @@
<view class="history-header">
<view class="history-title-wrap">
<image class="history-title-icon" src="{{icons.formFill}}" mode="aspectFit"></image>
<text class="section-title">最近记录</text>
<text class="section-title">{{listMode === 'month' ? currentYear + '年' + currentMonth + '月记录' : '最近记录'}}</text>
</view>
<view class="rec-seg">
<view class="rec-seg-item {{listMode === 'month' ? 'active' : ''}}" data-mode="month" bindtap="onToggleListMode">本月</view>
<view class="rec-seg-item {{listMode === 'recent' ? 'active' : ''}}" data-mode="recent" bindtap="onToggleListMode">最近</view>
</view>
<text class="history-hint" wx:if="{{historyList.length > 0}}">长按可删除</text>
</view>
<view wx:if="{{historyList.length === 0}}" class="empty-state">
<text class="history-hint" wx:if="{{displayList.length > 0}}">长按可删除</text>
<view wx:if="{{displayList.length === 0}}" class="empty-state">
<view class="empty-illus">
<view class="empty-illus-body"></view>
<view class="empty-illus-arm empty-illus-arm--l"></view>
<view class="empty-illus-arm empty-illus-arm--r"></view>
<view class="empty-illus-bubble">开始</view>
</view>
<text class="empty-text">还没有训练记录</text>
<text class="empty-sub">完成第一次训练,开启你的坚持!</text>
<text class="empty-text">{{listMode === 'month' ? '本月还没有训练记录' : '还没有训练记录'}}</text>
<text class="empty-sub">{{listMode === 'month' ? '换个有训练的月份看看吧' : '完成第一次训练,开启你的坚持!'}}</text>
</view>
<view class="history-list">
<view class="history-item" wx:for="{{historyList}}" wx:key="id" data-id="{{item.id}}" bindlongpress="onLongPressDelete">
<view class="history-item" wx:for="{{displayList}}" wx:key="id" data-id="{{item.id}}" bindlongpress="onLongPressDelete">
<image class="history-item-icon"
src="{{item.duration >= 120 ? icons.hotFill : item.duration >= 60 ? icons.likeFill : icons.roundCheckFill}}"
mode="aspectFit"
+27 -1
View File
@@ -109,6 +109,32 @@
.history-hint {
font-size: 22rpx;
color: var(--text-secondary);
margin-bottom: 8rpx;
}
/* segmented control: [本月 | 最近] in the records history header.
Mirrors the .period-seg language used on the leaderboard/settings pages. */
.rec-seg {
display: flex;
background: var(--bg-soft);
border-radius: 999rpx;
padding: 4rpx;
flex-shrink: 0;
}
.rec-seg-item {
padding: 8rpx 24rpx;
font-size: 24rpx;
color: var(--text-secondary);
border-radius: 999rpx;
transition: background 0.25s ease, color 0.25s ease, box-shadow 0.25s ease;
}
.rec-seg-item.active {
background: var(--card-bg);
color: var(--primary);
font-weight: 600;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.10);
}
.empty-state {
@@ -256,7 +282,7 @@
@media (prefers-color-scheme: dark) {
.day-detail-body {
/* 暗黑下 body(#1C1C1E)与页面(#0F0F12)都偏深,加深阴影 + 微亮描边拉开层次 */
/* 暗黑下 body(#26262B)与页面(#131316)都偏深,加深阴影 + 微亮描边拉开层次 */
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.6), 0 0 0 1rpx rgba(255, 255, 255, 0.06);
}
}
+26 -1
View File
@@ -194,12 +194,37 @@ Page({
*/
async _uploadAvatar(filePath) {
if (!cloud.enabled) throw new Error('cloud not enabled')
// Shrink the chosen avatar (chooseAvatar returns a full-res PNG) before
// upload — a 200px-wide file is tens of KB vs the original's MB, which
// is what lets remote avatars load in well under a second on the
// leaderboard. Falls back to the original file if compression is
// unavailable (e.g. old base lib / devtools) so upload never breaks.
const compressed = await this._compressAvatar(filePath)
const cloudPath = `avatar/${Date.now()}-${Math.floor(Math.random() * 1e6)}.png`
const res = await wx.cloud.uploadFile({ cloudPath, filePath })
const res = await wx.cloud.uploadFile({ cloudPath, filePath: compressed })
if (!res || !res.fileID) throw new Error('uploadFile returned no fileID')
return res.fileID
},
/**
* Compress an avatar temp file to ~200px wide before upload. Uses
* wx.compressImage (base lib 2.21.0+ honours `compressedWidth`; older
* libs ignore it and just apply `quality`, still a win). Resolves to the
* original path on any failure so the caller degrades gracefully.
*/
_compressAvatar(filePath) {
return new Promise((resolve) => {
if (!wx.compressImage || !filePath) { resolve(filePath); return }
wx.compressImage({
src: filePath,
quality: 80,
compressedWidth: 200,
success: (res) => resolve((res && res.tempFilePath) || filePath),
fail: () => resolve(filePath)
})
})
},
onNicknameBlur(e) {
const value = (e.detail.value || '').trim()
if (!value) return
+7 -6
View File
@@ -7,7 +7,7 @@
</view>
<view class="profile-row">
<button class="avatar-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
<image class="avatar-img" src="{{avatarUrl || icons.peopleFill}}" mode="aspectFit"></image>
<image class="avatar-img {{avatarUrl ? 'has-avatar' : ''}}" src="{{avatarUrl || icons.peopleFill}}" mode="{{avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image>
<view class="avatar-overlay">
<text>点击设置</text>
</view>
@@ -39,17 +39,18 @@
<image class="section-icon" src="{{icons.skinFill}}" mode="aspectFit"></image>
<text class="section-title">配色方案</text>
</view>
<view class="theme-list">
<view class="theme-cards">
<view
class="theme-item {{currentThemeId === item.id ? 'active' : ''}}"
class="theme-card {{currentThemeId === item.id ? 'active' : ''}}"
wx:for="{{themes}}"
wx:key="id"
data-id="{{item.id}}"
bindtap="onSelectTheme"
>
<view class="theme-color-dot" style="background: {{item.primary}};"></view>
<text class="theme-name">{{item.name}}</text>
<image class="theme-check-img" wx:if="{{currentThemeId === item.id}}" src="{{icons.check}}" mode="aspectFit"></image>
<view class="theme-card-swatch" style="background: linear-gradient(135deg, {{item.primary}}, {{item.primaryLight}});">
<image class="theme-card-check" wx:if="{{currentThemeId === item.id}}" src="{{icons.checkWhite}}" mode="aspectFit"></image>
</view>
<text class="theme-card-name {{currentThemeId === item.id ? 'active' : ''}}">{{item.name}}</text>
</view>
</view>
</ui-card>
+80 -15
View File
@@ -14,7 +14,64 @@
margin-bottom: 0;
}
/* ---- theme picker ---- */
/* ---- theme picker: horizontal gradient swatch cards ---- */
.theme-cards {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
padding-top: 8rpx;
}
.theme-card {
display: flex;
flex-direction: column;
align-items: center;
width: calc((100% - 40rpx) / 3);
transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.theme-card:active {
transform: scale(0.94);
}
.theme-card-swatch {
width: 100%;
height: 96rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.10);
border: 4rpx solid transparent;
box-sizing: border-box;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
.theme-card.active .theme-card-swatch {
border-color: var(--card-bg);
box-shadow: 0 0 0 4rpx var(--primary), 0 6rpx 16rpx rgba(var(--primary-rgb), 0.35);
transform: scale(1.04);
}
.theme-card-check {
width: 40rpx;
height: 40rpx;
filter: drop-shadow(0 2rpx 4rpx rgba(0, 0, 0, 0.25));
}
.theme-card-name {
font-size: 24rpx;
color: var(--text-secondary);
margin-top: 12rpx;
line-height: 1;
}
.theme-card-name.active {
color: var(--primary);
font-weight: 600;
}
/* ---- dark-mode & plan list (row-style pickers, e.g. 深色模式) ---- */
.theme-list { margin-top: 0; }
.theme-item {
@@ -40,20 +97,6 @@
border-top: 1rpx solid transparent;
}
.theme-color-dot {
width: 44rpx;
height: 44rpx;
border-radius: 50%;
margin-right: 20rpx;
flex-shrink: 0;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.15);
transition: transform 0.2s ease;
}
.theme-item:active .theme-color-dot {
transform: scale(1.15);
}
.theme-name {
flex: 1;
font-size: 28rpx;
@@ -78,6 +121,10 @@
.plan-item:last-child { border-bottom: none; }
.plan-item {
position: relative;
}
.plan-item.active {
background: rgba(var(--primary-rgb), 0.12);
margin: 0 -32rpx;
@@ -87,6 +134,19 @@
border-bottom: none;
}
/* left accent bar on the active plan, making the selection scan-able */
.plan-item.active::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 6rpx;
height: 48rpx;
border-radius: 3rpx;
background: linear-gradient(180deg, var(--primary), var(--primary-light));
}
.plan-radio {
width: 40rpx;
height: 40rpx;
@@ -283,6 +343,11 @@
box-sizing: border-box;
}
/* 真实头像(正方形)用 aspectFill 填满圆形容器,去掉占位图的内缩 padding */
.avatar-img.has-avatar {
padding: 0;
}
.avatar-overlay {
position: absolute;
bottom: 0;
+5 -6
View File
@@ -243,13 +243,13 @@ Page({
const nextStreak = (streak.lastDate && streak.count >= 1) ? streak.count + 1 : 1
if (stats.totalSessions === 0) {
return { level: 'first', message: '🎉 第一次训练!坚持就是胜利' }
return { level: 'first', message: '第一次训练!坚持就是胜利' }
}
if ([7, 14, 30, 50, 100, 365].includes(nextStreak)) {
return { level: 'milestone', message: `🔥 连续打卡 ${nextStreak} 天!` }
return { level: 'milestone', message: `连续打卡 ${nextStreak} 天!` }
}
if (elapsed > stats.maxDuration) {
return { level: 'record', message: `🏆 新纪录!${elapsed}` }
return { level: 'record', message: `新纪录!${elapsed}` }
}
return { level: 'normal', message: '完成!' }
},
@@ -454,6 +454,7 @@ Page({
this._lastCountdown = 0
this._lastTipIndex = -1
this._clearVibrateTimers()
this._countUpTask = null
this.setData({
showCompletion: false,
isCompleted: false,
@@ -462,9 +463,7 @@ Page({
isPaused: false,
remaining: this.data.duration,
overtime: 0,
goalReached: false,
// Re-init countUp so the big number rolls again on next start
_countUpTask: null
goalReached: false
})
},
+12 -12
View File
@@ -29,21 +29,21 @@
duration="{{duration}}"
remaining="{{isCompleted ? 0 : remaining}}"
size="{{360}}"
ringWidth="{{16}}"
ringWidth="{{22}}"
status="{{status}}"
primaryColor="{{theme.primary}}"
primaryLightColor="{{theme.primaryLight}}"
trackColor="{{isDark ? '#2C2C2E' : '#EEEEEE'}}"
trackColor="{{isDark ? '#3A3A40' : '#EEEEEE'}}"
></progress-ring>
<!-- 完成庆祝粒子 -->
<view class="celebrate-burst" wx:if="{{isCompleted}}">
<view class="spark s1">✨</view>
<view class="spark s2">⭐</view>
<view class="spark s3">💫</view>
<view class="spark s4">🌟</view>
<view class="spark s5">✨</view>
<view class="spark s6">💪</view>
<image class="spark s1" src="{{icons.sparkleFill}}" mode="aspectFit"></image>
<image class="spark s2" src="{{icons.sparkleFill}}" mode="aspectFit"></image>
<image class="spark s3" src="{{icons.crownFill}}" mode="aspectFit"></image>
<image class="spark s4" src="{{icons.sparkleFill}}" mode="aspectFit"></image>
<image class="spark s5" src="{{icons.sparkleFill}}" mode="aspectFit"></image>
<image class="spark s6" src="{{icons.likeFill}}" mode="aspectFit"></image>
</view>
<view class="overtime-badge" wx:if="{{overtime > 0}}">
@@ -87,7 +87,7 @@
size="xl"
block
text="开始"
icon-src="{{icons.playFill}}"
icon-src="{{icons.playWhite}}"
bindtap="onStart"
></ui-btn>
@@ -106,7 +106,7 @@
variant="primary"
size="xl"
text="继续"
icon-src="{{icons.playFill}}"
icon-src="{{icons.playWhite}}"
custom-style="margin-right: 28rpx;"
bindtap="onStart"
></ui-btn>
@@ -137,7 +137,7 @@
<view class="completion-mask" wx:if="{{showCompletion}}" catchtouchmove="onNoop">
<view class="completion-card">
<view class="completion-emoji-wrap">
<text class="completion-emoji">{{completionLevel === 'first' ? '🎉' : completionLevel === 'record' ? '🏆' : completionLevel === 'milestone' ? '🔥' : '✨'}}</text>
<image class="completion-icon" src="{{completionLevel === 'first' ? icons.partyWhite : completionLevel === 'record' ? icons.trophyWhite : completionLevel === 'milestone' ? icons.hotWhite : icons.sparkleWhite}}" mode="aspectFit"></image>
</view>
<text class="completion-title">{{completionMessage}}</text>
@@ -153,7 +153,7 @@
</view>
<view class="completion-streak" wx:if="{{completionStreak > 0}}">
<text class="streak-flame">🔥</text>
<image class="streak-flame" src="{{icons.hotFill}}" mode="aspectFit"></image>
<text class="streak-text">连续打卡 {{completionStreak}} 天</text>
</view>
+17 -14
View File
@@ -17,24 +17,25 @@
justify-content: center;
}
/* breathing guide rings — three concentric layers */
/* breathing guide rings — soft radial glows instead of hard outline circles,
so they read as ambient halo rather than a bullseye */
.breathe-ring {
position: absolute;
border-radius: 50%;
border: 3rpx solid rgba(var(--primary-rgb), 0.15);
background: radial-gradient(circle, rgba(var(--primary-rgb), 0.10) 0%, rgba(var(--primary-rgb), 0) 65%);
animation: breathePulse 3.5s ease-in-out infinite;
pointer-events: none;
z-index: 0;
}
.breathe-ring-1 { width: 480rpx; height: 480rpx; animation-delay: 0s; }
.breathe-ring-2 { width: 400rpx; height: 400rpx; animation-delay: 0.5s; border-color: rgba(var(--primary-rgb), 0.25); border-width: 4rpx; }
.breathe-ring-3 { width: 320rpx; height: 320rpx; animation-delay: 1s; border-color: rgba(var(--primary-rgb), 0.35); border-width: 5rpx; }
.breathe-ring-1 { width: 520rpx; height: 520rpx; animation-delay: 0s; }
.breathe-ring-2 { width: 440rpx; height: 440rpx; animation-delay: 0.5s; background: radial-gradient(circle, rgba(var(--primary-rgb), 0.14) 0%, rgba(var(--primary-rgb), 0) 65%); }
.breathe-ring-3 { width: 380rpx; height: 380rpx; animation-delay: 1s; background: radial-gradient(circle, rgba(var(--primary-rgb), 0.18) 0%, rgba(var(--primary-rgb), 0) 65%); }
.breathe-ring.fast { animation: breathePulseFast 1.4s ease-in-out infinite; }
.breathe-ring.fast.breathe-ring-1 { animation-delay: 0s; border-color: rgba(var(--primary-rgb), 0.3); }
.breathe-ring.fast.breathe-ring-2 { animation-delay: 0.25s; border-color: rgba(var(--primary-rgb), 0.45); }
.breathe-ring.fast.breathe-ring-3 { animation-delay: 0.5s; border-color: rgba(var(--primary-rgb), 0.6); }
.breathe-ring.fast.breathe-ring-1 { animation-delay: 0s; }
.breathe-ring.fast.breathe-ring-2 { animation-delay: 0.25s; }
.breathe-ring.fast.breathe-ring-3 { animation-delay: 0.5s; }
/* celebration burst */
.celebrate-burst {
@@ -47,11 +48,12 @@
.spark {
position: absolute;
font-size: 36rpx;
width: 44rpx;
height: 44rpx;
animation: sparkBurst 1s ease-out forwards;
}
.s1 { top: -10rpx; left: 50%; margin-left: -18rpx; animation-delay: 0s; }
.s1 { top: -10rpx; left: 50%; margin-left: -22rpx; animation-delay: 0s; }
.s2 { top: 30rpx; right: -10rpx; animation-delay: 0.1s; }
.s3 { bottom: -10rpx; right: 50rpx; animation-delay: 0.2s; }
.s4 { bottom: 20rpx; left: 30rpx; animation-delay: 0.15s; }
@@ -264,9 +266,9 @@
50% { transform: scale(1.08); }
}
.completion-emoji {
font-size: 80rpx;
line-height: 1;
.completion-icon {
width: 80rpx;
height: 80rpx;
}
.completion-title {
@@ -346,7 +348,8 @@
}
.streak-flame {
font-size: 32rpx;
width: 32rpx;
height: 32rpx;
}
.streak-text {
+6 -6
View File
@@ -2,15 +2,15 @@
"light": {
"navigationBarBackgroundColor": "#FF6B35",
"navigationBarTextStyle": "white",
"backgroundColor": "#F5F5F5",
"backgroundColorTop": "#F5F5F5",
"backgroundColorBottom": "#F5F5F5"
"backgroundColor": "#FFF3ED",
"backgroundColorTop": "#FFF3ED",
"backgroundColorBottom": "#FFF3ED"
},
"dark": {
"navigationBarBackgroundColor": "#FF6B35",
"navigationBarTextStyle": "white",
"backgroundColor": "#0F0F12",
"backgroundColorTop": "#0F0F12",
"backgroundColorBottom": "#0F0F12"
"backgroundColor": "#131316",
"backgroundColorTop": "#131316",
"backgroundColorBottom": "#131316"
}
}
+2 -1
View File
@@ -7,7 +7,8 @@ const STORAGE_KEY = 'dark_mode_pref'
function _readSystemTheme() {
try {
const info = wx.getSystemInfoSync()
// wx.getSystemInfoSync 已废弃,改用 wx.getWindowInfo(同样返回 theme 字段)
const info = wx.getWindowInfo()
return info.theme === 'dark'
} catch (e) {
return false
+22
View File
@@ -53,6 +53,15 @@ const PATHS = {
rankFill: '<path d="M3.5 18.49l6-6.01 4 4L22 6.92l-1.41-1.41-7.09 7.97-4-4L2 16.99z"/>',
crown: '<path d="M5 16L3 5l5.5 5L12 4l3.5 6L21 5l-2 11H5zm0 2h14v2H5z" fill-rule="evenodd"/>',
crownFill: '<path d="M5 16L3 5l5.5 5L12 4l3.5 6L21 5l-2 11H5zm0 2h14v2H5z"/>',
// Trophy (record celebrations) — Material emoji-events
trophy: '<path d="M19 5h-2V3H7v2H5c-1.1 0-2 .9-2 2v1c0 2.55 1.92 4.63 4.39 4.94.63 1.5 1.98 2.63 3.61 2.96V19H7v2h10v-2h-4v-3.1c1.63-.33 2.98-1.46 3.61-2.96C19.08 12.63 21 10.55 21 8V7c0-1.1-.9-2-2-2zM5 8V7h2v3.82C5.84 10.4 5 9.3 5 8zm14 0c0 1.3-.84 2.4-2 2.82V7h2v1z" fill-rule="evenodd"/>',
trophyFill: '<path d="M19 5h-2V3H7v2H5c-1.1 0-2 .9-2 2v1c0 2.55 1.92 4.63 4.39 4.94.63 1.5 1.98 2.63 3.61 2.96V19H7v2h10v-2h-4v-3.1c1.63-.33 2.98-1.46 3.61-2.96C19.08 12.63 21 10.55 21 8V7c0-1.1-.9-2-2-2zM5 8V7h2v3.82C5.84 10.4 5 9.3 5 8zm14 0c0 1.3-.84 2.4-2 2.82V7h2v1z"/>',
// Party popper (first-time celebrations) — Material celebration
party: '<path d="M2 22l14-5-9-9-5 14zm12.53-9.47l1.41-1.41 2.83 2.83-1.41 1.41-2.83-2.83zm.53-6.53L16.47 4.59l1.41-1.41 1.41 1.41-1.41 1.42zM21 11h-3v-2h3v2zm-5.65-4.35L14.24 5.54 15.65 4.1l1.11 1.11-1.41 1.44zM13 3V0h2v3h-2zM8.35 5.65l-1.41-1.44L8.06 3.1l1.41 1.41 1.12 1.14z" fill-rule="evenodd"/>',
partyFill: '<path d="M2 22l14-5-9-9-5 14zm12.53-9.47l1.41-1.41 2.83 2.83-1.41 1.41-2.83-2.83zm.53-6.53L16.47 4.59l1.41-1.41 1.41 1.41-1.41 1.42zM21 11h-3v-2h3v2zm-5.65-4.35L14.24 5.54 15.65 4.1l1.11 1.11-1.41 1.44zM13 3V0h2v3h-2zM8.35 5.65l-1.41-1.44L8.06 3.1l1.41 1.41 1.12 1.14z"/>',
// Sparkle (generic celebrations + confetti particles)
sparkle: '<path d="M12 2l1.8 5.7L19.5 9l-5.7 1.3L12 16l-1.8-5.7L4.5 9l5.7-1.3L12 2zm7 11l.9 2.8 2.8.9-2.8.9-.9 2.8-.9-2.8-2.8-.9 2.8-.9.9-2.8zM5 14l.7 2.1 2.1.7-2.1.7L5 19.5l-.7-2.1-2.1-.7 2.1-.7L5 14z" fill-rule="evenodd"/>',
sparkleFill: '<path d="M12 2l1.8 5.7L19.5 9l-5.7 1.3L12 16l-1.8-5.7L4.5 9l5.7-1.3L12 2zm7 11l.9 2.8 2.8.9-2.8.9-.9 2.8-.9-2.8-2.8-.9 2.8-.9.9-2.8zM5 14l.7 2.1 2.1.7-2.1.7L5 19.5l-.7-2.1-2.1-.7 2.1-.7L5 14z"/>',
form: '<path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z" fill-rule="evenodd"/>',
formFill: '<path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/>',
// Notification
@@ -132,6 +141,19 @@ const build = (theme) => {
out.deleteInactive = _svg(PATHS.delete, INACTIVE)
out.successFill = _svg(PATHS.roundCheckFill, s)
out.successLine = _svg(PATHS.roundCheck, INACTIVE)
// White variants for icons sitting on the theme-gradient card / disc
// (completion modal, podium accents, the index hero card, etc.) AND for
// icons rendered on the primary (filled) buttons, whose background is the
// same theme color as a *Fill variant — orange-on-orange would vanish.
// Baked-in white is REQUIRED: these are data-URI SVG <image> tags, so CSS
// color / currentColor cannot recolor them; only a white SVG works.
out.trophyWhite = _svg(PATHS.trophyFill, '#FFFFFF')
out.partyWhite = _svg(PATHS.partyFill, '#FFFFFF')
out.hotWhite = _svg(PATHS.hotFill, '#FFFFFF')
out.formWhite = _svg(PATHS.formFill, '#FFFFFF')
out.playWhite = _svg(PATHS.playFill, '#FFFFFF')
out.sparkleWhite = _svg(PATHS.sparkleFill, '#FFFFFF')
out.checkWhite = _svg(PATHS.check, '#FFFFFF')
return out
}
+6 -1
View File
@@ -75,6 +75,9 @@ const getPlan = (planId, customPlans) => {
const getTodayTarget = (planId, currentDay) => {
const plan = getPlan(planId)
// 防御:计划数据损坏(planId 无效 / days 为空)时直接返回 0,
// 避免 plan.days[0] 为 undefined 后访问 .target 抛出崩溃。
if (!plan.days || plan.days.length === 0) return 0
const day = plan.days.find(d => d.day === currentDay)
return day ? day.target : plan.days[0].target
}
@@ -95,7 +98,9 @@ const getPlanDay = (planId, records, planStartDate, customPlans) => {
return true
})
const trainedDays = new Set(eligibleRecords.map(r => r.date)).size
// 用 dateOnly 去重:r.date 带时分秒,同一天多次训练必须算作"1 天",
// 否则会被当成分属两天而错误跳过计划进度。
const trainedDays = new Set(eligibleRecords.map(r => dateOnly(r.date))).size
return Math.min(trainedDays + 1, plan.totalDays)
}
+15 -4
View File
@@ -38,6 +38,14 @@ const THEMES = [
primaryLight: '#F06A9A',
primaryBg: '#FDEEF3',
primaryRgb: '236,64,122'
},
{
id: 'teal',
name: '湖青',
primary: '#00BCD4',
primaryLight: '#4DD0E1',
primaryBg: '#E0F7FA',
primaryRgb: '0,188,212'
}
]
@@ -95,9 +103,12 @@ const setTheme = (id) => {
const getThemeStyle = (theme, isDark = false) => {
const t = theme || getCurrentTheme()
// Dark palette is unified with theme.json (#131316 family) so the chrome
// background and the page background stop disagreeing. Card is brightened
// to keep elevation readable on the darker backdrop.
const baseVars = isDark
? '--text:#E5E5E7;--text-secondary:#98989F;--bg:#2C2C2E;--bg-gradient-start:#2C2C2E;--bg-gradient-end:#38383A;--card-bg:#3A3A3C;--bg-soft:#48484A;--border:#48484A;--success:#30D158;--success-rgb:48,209,88;--danger:#FF6B6B;--danger-rgb:255,107,107;'
: BASE_VARS
? '--text:#E5E5E7;--text-secondary:#98989F;--bg:#131316;--bg-gradient-start:#131316;--bg-gradient-end:#1C1C20;--card-bg:#26262B;--bg-soft:#343439;--border:#343439;--success:#30D158;--success-rgb:48,209,88;--danger:#FF6B6B;--danger-rgb:255,107,107;'
: `--text:#333333;--text-secondary:#999999;--bg:${t.primaryBg};--bg-gradient-start:${t.primaryBg};--bg-gradient-end:#FAFAFA;--card-bg:#FFFFFF;--bg-soft:#F0F0F0;--border:#EEEEEE;--success:#4CAF50;--success-rgb:76,175,80;--danger:#E53935;--danger-rgb:229,57,53;`
return `${baseVars}--primary:${t.primary};--primary-light:${t.primaryLight};--primary-bg:${t.primaryBg};--primary-rgb:${t.primaryRgb};`
}
@@ -116,7 +127,7 @@ const applyThemeToPage = (self) => {
// theme.json 的 dark section 只响应系统暗黑模式。用户手动选择暗黑
// 但系统是明亮时,窗口背景仍为浅色,切 tab 会闪白。这里用 API 强制设置
// 窗口背景,让 tab 切换时的瞬间底色与当前模式一致。
const bg = isDark ? '#0F0F12' : '#F5F5F5'
const bg = isDark ? '#131316' : theme.primaryBg
try { wx.setBackgroundColor({ backgroundColor: bg, backgroundColorTop: bg, backgroundColorBottom: bg }) } catch (e) {}
}
@@ -125,7 +136,7 @@ const applyWindowBg = () => {
try {
const darkMod = require('./darkMode')
const isDark = darkMod.getInitialDarkMode()
const bg = isDark ? '#0F0F12' : '#F5F5F5'
const bg = isDark ? '#131316' : getCurrentTheme().primaryBg
wx.setBackgroundColor({ backgroundColor: bg, backgroundColorTop: bg, backgroundColorBottom: bg })
} catch (e) {}
}
+19 -7
View File
@@ -36,14 +36,25 @@ class Timer {
this._tick()
this._intervalId = setInterval(() => { this._tick() }, 1000)
// Register foreground listener (idempotent — we keep a flag so we don't
// Register foreground listener (idempotent — keep a flag so we don't
// double-register if start() is called multiple times in one session)
if (!this._appShowBound && typeof wx.onAppShow === 'function') {
try {
wx.onAppShow(this._onAppShow)
this._appShowBound = true
} catch (e) {}
}
this._bindAppShow()
}
_bindAppShow() {
if (this._appShowBound || typeof wx.onAppShow !== 'function') return
try {
wx.onAppShow(this._onAppShow)
this._appShowBound = true
} catch (e) {}
}
_unbindAppShow() {
if (!this._appShowBound || typeof wx.offAppShow !== 'function') return
try {
wx.offAppShow(this._onAppShow)
} catch (e) {}
this._appShowBound = false
}
pause() {
@@ -67,6 +78,7 @@ class Timer {
this._paused = false
clearInterval(this._intervalId)
this._intervalId = null
this._unbindAppShow()
return this._elapsed
}