feat(ui): 记录页月份联动与多项 UI 美化/修复
- records: 底部列表与月份选择器联动,新增 [本月|最近] 分段控件;标题/空态随模式切换 - leaderboard: 领奖台布局(冠军居中+皇冠)、次数标注于底座且三档水平对齐;头像 cloud:// 批量预解析提速(云函数需重新部署) - index/timer: 主色底图标改白色变体修复隐形;目标数字按微信字体缩放补偿真机遮挡 - settings: 配色方案横滑渐变色卡;头像上传前压缩至 200px - theme: 新增 teal 主题;暗黑背景统一对齐 - 圆形头像双保险(image 自身 border-radius)修复月/年榜方图 注: cloudfunctions/leaderboard 改动须在开发者工具重新部署才生效。
This commit is contained in:
@@ -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) {}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
<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 经复核为误报,无需处理。
|
||||
@@ -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,14 +91,53 @@ 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, startAngle, endAngle)
|
||||
ctx.strokeStyle = primaryColor
|
||||
ctx.arc(cx, cy, radius, a0, a1)
|
||||
ctx.strokeStyle = `rgb(${r},${g},${b})`
|
||||
ctx.lineWidth = lw
|
||||
ctx.lineCap = 'round'
|
||||
ctx.lineCap = 'butt'
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// round cap at the tail (12 o'clock side)
|
||||
ctx.beginPath()
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -84,8 +84,8 @@
|
||||
}
|
||||
|
||||
.ui-btn__icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
width: 1.3em;
|
||||
height: 1.3em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)参数 - 直接改这里调整音色/音量/语速,无需改逻辑代码。
|
||||
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<view class="container page-enter" style="{{themeStyle}}">
|
||||
<!-- Period tabs -->
|
||||
<view class="period-tabs">
|
||||
<!-- 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-tab {{activePeriod === item.key ? 'active' : ''}}"
|
||||
class="period-seg-item {{activePeriod === item.key ? 'active' : ''}}"
|
||||
data-period="{{item.key}}"
|
||||
bindtap="switchPeriod"
|
||||
>{{item.label}}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Loading skeleton -->
|
||||
<view class="leaderboard-skeleton" wx:if="{{loading}}">
|
||||
@@ -27,21 +29,55 @@
|
||||
<text class="empty-sub">快去训练吧,争当第一名!</text>
|
||||
</view>
|
||||
|
||||
<!-- Rank list -->
|
||||
<view class="rank-list" wx:else>
|
||||
<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>
|
||||
<!-- 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>
|
||||
</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"
|
||||
class="rank-item {{item.openid === myEntry.openid ? 'is-me' : ''}} rank-item--enter"
|
||||
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 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>
|
||||
<text>{{item.rank}}</text>
|
||||
</view>
|
||||
<image class="rank-avatar" src="{{icons.peopleFill}}" mode="aspectFit"></image>
|
||||
<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>
|
||||
@@ -49,11 +85,12 @@
|
||||
<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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')}`
|
||||
|
||||
@@ -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>
|
||||
<text class="history-hint" wx:if="{{historyList.length > 0}}">长按可删除</text>
|
||||
<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>
|
||||
<view wx:if="{{historyList.length === 0}}" class="empty-state">
|
||||
</view>
|
||||
<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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {}
|
||||
}
|
||||
|
||||
+14
-2
@@ -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') {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user