From 6176f2c34120d72cabe2c24cb19827515440d7fa Mon Sep 17 00:00:00 2001 From: cnliucheng Date: Wed, 22 Jul 2026 09:36:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E8=AE=B0=E5=BD=95=E9=A1=B5?= =?UTF-8?q?=E6=9C=88=E4=BB=BD=E8=81=94=E5=8A=A8=E4=B8=8E=E5=A4=9A=E9=A1=B9?= =?UTF-8?q?=20UI=20=E7=BE=8E=E5=8C=96/=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - records: 底部列表与月份选择器联动,新增 [本月|最近] 分段控件;标题/空态随模式切换 - leaderboard: 领奖台布局(冠军居中+皇冠)、次数标注于底座且三档水平对齐;头像 cloud:// 批量预解析提速(云函数需重新部署) - index/timer: 主色底图标改白色变体修复隐形;目标数字按微信字体缩放补偿真机遮挡 - settings: 配色方案横滑渐变色卡;头像上传前压缩至 200px - theme: 新增 teal 主题;暗黑背景统一对齐 - 圆形头像双保险(image 自身 border-radius)修复月/年榜方图 注: cloudfunctions/leaderboard 改动须在开发者工具重新部署才生效。 --- app.js | 10 + app.wxss | 18 +- cloudfunctions/admin-dedupe/index.js | 13 + cloudfunctions/leaderboard/index.js | 48 +++- code-review-report.md | 131 ++++++++++ components/progress-ring/progress-ring.js | 67 +++++- components/progress-ring/progress-ring.wxss | 4 +- components/ui-btn/ui-btn.wxss | 4 +- components/ui-card/ui-card.wxss | 23 ++ components/ui-skeleton/ui-skeleton.wxss | 2 +- config.js | 6 +- custom-tab-bar/index.wxss | 2 +- pages/index/index.js | 57 ++++- pages/index/index.wxml | 10 +- pages/index/index.wxss | 36 ++- pages/leaderboard/leaderboard.wxml | 97 +++++--- pages/leaderboard/leaderboard.wxss | 249 +++++++++++++++++--- pages/records/records.js | 10 + pages/records/records.wxml | 16 +- pages/records/records.wxss | 28 ++- pages/settings/settings.js | 27 ++- pages/settings/settings.wxml | 13 +- pages/settings/settings.wxss | 95 ++++++-- pages/timer/timer.js | 11 +- pages/timer/timer.wxml | 24 +- pages/timer/timer.wxss | 31 +-- theme.json | 12 +- utils/darkMode.js | 3 +- utils/icons.js | 22 ++ utils/plan.js | 7 +- utils/theme.js | 19 +- utils/timer.js | 26 +- 32 files changed, 924 insertions(+), 197 deletions(-) create mode 100644 code-review-report.md diff --git a/app.js b/app.js index 7f28c48..e075723 100644 --- a/app.js +++ b/app.js @@ -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) {} }) }) diff --git a/app.wxss b/app.wxss index 9d8e653..d6e394f 100644 --- a/app.wxss +++ b/app.wxss @@ -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; diff --git a/cloudfunctions/admin-dedupe/index.js b/cloudfunctions/admin-dedupe/index.js index 5e34445..1f13ff8 100644 --- a/cloudfunctions/admin-dedupe/index.js +++ b/cloudfunctions/admin-dedupe/index.js @@ -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 diff --git a/cloudfunctions/leaderboard/index.js b/cloudfunctions/leaderboard/index.js index 240391b..de0d675 100644 --- a/cloudfunctions/leaderboard/index.js +++ b/cloudfunctions/leaderboard/index.js @@ -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 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 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 diff --git a/code-review-report.md b/code-review-report.md new file mode 100644 index 0000000..5b56672 --- /dev/null +++ b/code-review-report.md @@ -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` 重新累加出来的 + (第 102–132 行遍历 `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 + + ``` + 该属性确实绑定到了 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 经复核为误报,无需处理。 diff --git a/components/progress-ring/progress-ring.js b/components/progress-ring/progress-ring.js index b37c5db..396281f 100644 --- a/components/progress-ring/progress-ring.js +++ b/components/progress-ring/progress-ring.js @@ -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() } } } diff --git a/components/progress-ring/progress-ring.wxss b/components/progress-ring/progress-ring.wxss index cf761e5..ca66fba 100644 --- a/components/progress-ring/progress-ring.wxss +++ b/components/progress-ring/progress-ring.wxss @@ -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; diff --git a/components/ui-btn/ui-btn.wxss b/components/ui-btn/ui-btn.wxss index 78cbbf7..2408b8a 100644 --- a/components/ui-btn/ui-btn.wxss +++ b/components/ui-btn/ui-btn.wxss @@ -84,8 +84,8 @@ } .ui-btn__icon { - width: 1em; - height: 1em; + width: 1.3em; + height: 1.3em; flex-shrink: 0; } diff --git a/components/ui-card/ui-card.wxss b/components/ui-card/ui-card.wxss index f2abfbf..ff5d942 100644 --- a/components/ui-card/ui-card.wxss +++ b/components/ui-card/ui-card.wxss @@ -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; diff --git a/components/ui-skeleton/ui-skeleton.wxss b/components/ui-skeleton/ui-skeleton.wxss index f338605..e9dc937 100644 --- a/components/ui-skeleton/ui-skeleton.wxss +++ b/components/ui-skeleton/ui-skeleton.wxss @@ -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, diff --git a/config.js b/config.js index d6f460e..c7c3499 100644 --- a/config.js +++ b/config.js @@ -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)参数 - 直接改这里调整音色/音量/语速,无需改逻辑代码。 diff --git a/custom-tab-bar/index.wxss b/custom-tab-bar/index.wxss index 4af4b23..1149413 100644 --- a/custom-tab-bar/index.wxss +++ b/custom-tab-bar/index.wxss @@ -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); diff --git a/pages/index/index.js b/pages/index/index.js index 2a02ca9..521608c 100644 --- a/pages/index/index.js +++ b/pages/index/index.js @@ -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 }) }, diff --git a/pages/index/index.wxml b/pages/index/index.wxml index 71f3f51..67771a5 100644 --- a/pages/index/index.wxml +++ b/pages/index/index.wxml @@ -10,17 +10,17 @@ - + - + {{streakCount}} 连续打卡 - + {{planName}} @@ -44,7 +44,7 @@ - {{todayTargetText}} + {{todayTargetText}} 平板支撑 @@ -70,7 +70,7 @@ size="xl" block text="{{todayDone ? '再次训练' : '开始训练'}}" - icon-src="{{icons.playFill}}" + icon-src="{{icons.playWhite}}" bindtap="onStartTrain" > diff --git a/pages/index/index.wxss b/pages/index/index.wxss index 872eca0..6518051 100644 --- a/pages/index/index.wxss +++ b/pages/index/index.wxss @@ -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 { diff --git a/pages/leaderboard/leaderboard.wxml b/pages/leaderboard/leaderboard.wxml index 8079208..218b331 100644 --- a/pages/leaderboard/leaderboard.wxml +++ b/pages/leaderboard/leaderboard.wxml @@ -1,13 +1,15 @@ - - - {{item.label}} + + + + {{item.label}} + @@ -27,33 +29,68 @@ 快去训练吧,争当第一名! - - - - - 🥇 - 🥈 - 🥉 - {{item.rank}} + + + + + + + + 2 + + {{rankedList[1].name}} + {{rankedList[1].durationText}} + {{rankedList[1].sessions}}次 - - - {{item.name}} - {{item.sessions}}次 + + + + + + 1 + + {{rankedList[0].name}} + {{rankedList[0].durationText}} + {{rankedList[0].sessions}}次 + + + + + + 3 + + {{rankedList[2].name}} + {{rankedList[2].durationText}} + {{rankedList[2].sessions}}次 - {{item.durationText}} - + + + + + + {{item.rank}} + + + + {{item.name}} + {{item.sessions}}次 + + {{item.durationText}} + + + - {{myEntry.rank}} - + {{myEntry.rank}} + 我 ({{myEntry.name}}) {{myEntry.sessions}}次 diff --git a/pages/leaderboard/leaderboard.wxss b/pages/leaderboard/leaderboard.wxss index 40a3d76..3f5689a 100644 --- a/pages/leaderboard/leaderboard.wxss +++ b/pages/leaderboard/leaderboard.wxss @@ -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 clips the inside it. + border-radius on the 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 '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 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 { diff --git a/pages/records/records.js b/pages/records/records.js index 3661111..8064ca3 100644 --- a/pages/records/records.js +++ b/pages/records/records.js @@ -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')}` diff --git a/pages/records/records.wxml b/pages/records/records.wxml index cd0d61e..0d2e0e7 100644 --- a/pages/records/records.wxml +++ b/pages/records/records.wxml @@ -75,22 +75,26 @@ - 最近记录 + {{listMode === 'month' ? currentYear + '年' + currentMonth + '月记录' : '最近记录'}} + + + 本月 + 最近 - 长按可删除 - + 长按可删除 + 开始 - 还没有训练记录 - 完成第一次训练,开启你的坚持! + {{listMode === 'month' ? '本月还没有训练记录' : '还没有训练记录'}} + {{listMode === 'month' ? '换个有训练的月份看看吧' : '完成第一次训练,开启你的坚持!'}} - + { + 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 diff --git a/pages/settings/settings.wxml b/pages/settings/settings.wxml index 97ecbf0..472121c 100644 --- a/pages/settings/settings.wxml +++ b/pages/settings/settings.wxml @@ -7,7 +7,7 @@