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 => {
|
pages.forEach(p => {
|
||||||
if (p && typeof p.applyThemeToPage === 'function') {
|
if (p && typeof p.applyThemeToPage === 'function') {
|
||||||
p.applyThemeToPage()
|
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 {
|
page {
|
||||||
--text: #333333;
|
--text: #333333;
|
||||||
--text-secondary: #999999;
|
--text-secondary: #999999;
|
||||||
--bg: #F5F5F5;
|
/* Cold-start (pre-JS) fallback uses the default orange theme's tint;
|
||||||
--bg-gradient-start: #F5F5F5;
|
getThemeStyle replaces these with the selected theme's primaryBg. */
|
||||||
|
--bg: #FFF3ED;
|
||||||
|
--bg-gradient-start: #FFF3ED;
|
||||||
--bg-gradient-end: #FAFAFA;
|
--bg-gradient-end: #FAFAFA;
|
||||||
--card-bg: #FFFFFF;
|
--card-bg: #FFFFFF;
|
||||||
--success: #4CAF50;
|
--success: #4CAF50;
|
||||||
@@ -22,12 +24,12 @@ page {
|
|||||||
page {
|
page {
|
||||||
--text: #E5E5E7;
|
--text: #E5E5E7;
|
||||||
--text-secondary: #98989F;
|
--text-secondary: #98989F;
|
||||||
--bg: #2C2C2E;
|
--bg: #131316;
|
||||||
--bg-gradient-start: #2C2C2E;
|
--bg-gradient-start: #131316;
|
||||||
--bg-gradient-end: #38383A;
|
--bg-gradient-end: #1C1C20;
|
||||||
--card-bg: #3A3A3C;
|
--card-bg: #26262B;
|
||||||
--bg-soft: #48484A;
|
--bg-soft: #343439;
|
||||||
--border: #48484A;
|
--border: #343439;
|
||||||
--success: #30D158;
|
--success: #30D158;
|
||||||
--success-rgb: 48,209,88;
|
--success-rgb: 48,209,88;
|
||||||
--danger: #FF6B6B;
|
--danger: #FF6B6B;
|
||||||
|
|||||||
@@ -28,6 +28,19 @@ const PAGE_SIZE = 100
|
|||||||
* can't be re-run by a user. It's destructive.
|
* can't be re-run by a user. It's destructive.
|
||||||
*/
|
*/
|
||||||
exports.main = async () => {
|
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.
|
// 1. Page through every doc in the collection.
|
||||||
const docs = []
|
const docs = []
|
||||||
let skip = 0
|
let skip = 0
|
||||||
|
|||||||
@@ -126,7 +126,8 @@ exports.main = async (event) => {
|
|||||||
openid,
|
openid,
|
||||||
duration,
|
duration,
|
||||||
sessions,
|
sessions,
|
||||||
nickname: profile.nickname || ''
|
nickname: profile.nickname || '',
|
||||||
|
avatarUrl: profile.avatarUrl || ''
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,9 +153,23 @@ exports.main = async (event) => {
|
|||||||
nickname: item.nickname || '',
|
nickname: item.nickname || '',
|
||||||
name: _displayName(item),
|
name: _displayName(item),
|
||||||
duration: item.duration,
|
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 {
|
return {
|
||||||
period,
|
period,
|
||||||
ranked,
|
ranked,
|
||||||
@@ -165,12 +180,39 @@ exports.main = async (event) => {
|
|||||||
nickname: myEntry.nickname || '',
|
nickname: myEntry.nickname || '',
|
||||||
name: _displayName(myEntry),
|
name: _displayName(myEntry),
|
||||||
duration: myEntry.duration,
|
duration: myEntry.duration,
|
||||||
sessions: myEntry.sessions
|
sessions: myEntry.sessions,
|
||||||
|
avatarUrl: myEntry.avatarUrl || ''
|
||||||
} : null,
|
} : null,
|
||||||
updatedAt: now.toISOString()
|
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) {
|
function maskOpenid(openid) {
|
||||||
if (!openid || openid === 'unknown') return '未知用户'
|
if (!openid || openid === 'unknown') return '未知用户'
|
||||||
if (openid.length <= 4) return '****' + openid
|
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()
|
this._draw()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -53,11 +53,19 @@ Component({
|
|||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
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() {
|
_draw() {
|
||||||
const ctx = this._ctx
|
const ctx = this._ctx
|
||||||
if (!ctx) return
|
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 dpr = this._dpr
|
||||||
|
|
||||||
const w = size * dpr
|
const w = size * dpr
|
||||||
@@ -66,7 +74,9 @@ Component({
|
|||||||
|
|
||||||
const cx = w / 2
|
const cx = w / 2
|
||||||
const cy = h / 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
|
const lw = ringWidth * dpr
|
||||||
|
|
||||||
// track ring
|
// track ring
|
||||||
@@ -81,14 +91,53 @@ Component({
|
|||||||
const ratio = duration > 0 ? Math.max(0, Math.min(1, remaining / duration)) : 0
|
const ratio = duration > 0 ? Math.max(0, Math.min(1, remaining / duration)) : 0
|
||||||
if (ratio > 0.001) {
|
if (ratio > 0.001) {
|
||||||
const startAngle = -Math.PI / 2
|
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.beginPath()
|
||||||
ctx.arc(cx, cy, radius, startAngle, endAngle)
|
ctx.arc(cx, cy, radius, a0, a1)
|
||||||
ctx.strokeStyle = primaryColor
|
ctx.strokeStyle = `rgb(${r},${g},${b})`
|
||||||
ctx.lineWidth = lw
|
ctx.lineWidth = lw
|
||||||
ctx.lineCap = 'round'
|
ctx.lineCap = 'butt'
|
||||||
ctx.stroke()
|
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 {
|
.time-text {
|
||||||
font-size: 80rpx;
|
font-size: 88rpx;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
letter-spacing: 2rpx;
|
letter-spacing: 2rpx;
|
||||||
|
|||||||
@@ -84,8 +84,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ui-btn__icon {
|
.ui-btn__icon {
|
||||||
width: 1em;
|
width: 1.3em;
|
||||||
height: 1em;
|
height: 1.3em;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,14 @@
|
|||||||
box-sizing: border-box;
|
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 {
|
.ui-card--flush {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
@@ -18,6 +26,21 @@
|
|||||||
border-radius: 20rpx;
|
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 */
|
/* Staggered entrance animation */
|
||||||
.ui-card.in {
|
.ui-card.in {
|
||||||
animation: uiCardIn 0.45s ease both;
|
animation: uiCardIn 0.45s ease both;
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
|
|
||||||
/* dark mode */
|
/* dark mode */
|
||||||
@media (prefers-color-scheme: dark) {
|
@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-bar,
|
||||||
.skeleton--active .sk-circle {
|
.skeleton--active .sk-circle {
|
||||||
background: linear-gradient(90deg,
|
background: linear-gradient(90deg,
|
||||||
|
|||||||
@@ -4,16 +4,16 @@
|
|||||||
*/
|
*/
|
||||||
module.exports = {
|
module.exports = {
|
||||||
/** 应用版本号,外显在设置页「关于」 */
|
/** 应用版本号,外显在设置页「关于」 */
|
||||||
version: 'v2.7',
|
version: 'v2.9',
|
||||||
|
|
||||||
/** 最后更新日期,外显在设置页「关于」 */
|
/** 最后更新日期,外显在设置页「关于」 */
|
||||||
updatedAt: '2026-07-10',
|
updatedAt: '2026-07-22',
|
||||||
|
|
||||||
/** 开发者名称 / 微信号,设置页点击可复制 */
|
/** 开发者名称 / 微信号,设置页点击可复制 */
|
||||||
developer: '刘承',
|
developer: '刘承',
|
||||||
|
|
||||||
/** 排行榜最大显示人数 */
|
/** 排行榜最大显示人数 */
|
||||||
leaderboardMaxRank: 20,
|
leaderboardMaxRank: 50,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 语音合成(TTS)参数 - 直接改这里调整音色/音量/语速,无需改逻辑代码。
|
* 语音合成(TTS)参数 - 直接改这里调整音色/音量/语速,无需改逻辑代码。
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
/* dark mode */
|
/* dark mode */
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
.dock-inner {
|
.dock-inner {
|
||||||
background: var(--card-bg, #1C1C1E);
|
background: var(--card-bg, #26262B);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 8rpx 32rpx rgba(0, 0, 0, 0.5),
|
0 8rpx 32rpx rgba(0, 0, 0, 0.5),
|
||||||
0 2rpx 8rpx rgba(0, 0, 0, 0.3);
|
0 2rpx 8rpx rgba(0, 0, 0, 0.3);
|
||||||
|
|||||||
+54
-3
@@ -14,6 +14,13 @@ Page({
|
|||||||
todayDone: false,
|
todayDone: false,
|
||||||
todayDuration: 0,
|
todayDuration: 0,
|
||||||
todayTargetText: '',
|
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,
|
streakCount: 0,
|
||||||
planName: '',
|
planName: '',
|
||||||
planDay: 1,
|
planDay: 1,
|
||||||
@@ -28,9 +35,50 @@ Page({
|
|||||||
|
|
||||||
onLoad() {
|
onLoad() {
|
||||||
themeMod.applyThemeToPage(this)
|
themeMod.applyThemeToPage(this)
|
||||||
|
this._readFontScale()
|
||||||
this._firstShow = true
|
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() {
|
onShow() {
|
||||||
themeMod.applyThemeToPage(this)
|
themeMod.applyThemeToPage(this)
|
||||||
try {
|
try {
|
||||||
@@ -86,9 +134,12 @@ Page({
|
|||||||
const customPlans = storage.getCustomPlans()
|
const customPlans = storage.getCustomPlans()
|
||||||
const plan = planMod.getPlan(settings.planId, customPlans)
|
const plan = planMod.getPlan(settings.planId, customPlans)
|
||||||
const planDay = planMod.getPlanDay(settings.planId, allRecords, settings.planStartDate, 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()
|
const theme = themeMod.getCurrentTheme()
|
||||||
|
// 防御:totalDays 为 0(极端数据损坏)时避免除以 0 得到 NaN
|
||||||
|
const planProgress = plan.totalDays > 0 ? Math.round((clampedDay / plan.totalDays) * 100) : 0
|
||||||
this.setData({
|
this.setData({
|
||||||
theme,
|
theme,
|
||||||
icons: iconsMod.build(theme),
|
icons: iconsMod.build(theme),
|
||||||
@@ -98,9 +149,9 @@ Page({
|
|||||||
todayTargetText: util.formatTime(target),
|
todayTargetText: util.formatTime(target),
|
||||||
streakCount: streak.count,
|
streakCount: streak.count,
|
||||||
planName: plan.name,
|
planName: plan.name,
|
||||||
planDay: Math.min(planDay, plan.totalDays),
|
planDay: clampedDay,
|
||||||
planTotal: plan.totalDays,
|
planTotal: plan.totalDays,
|
||||||
planProgress: Math.round((Math.min(planDay, plan.totalDays) / plan.totalDays) * 100)
|
planProgress
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -10,17 +10,17 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 连续打卡卡片 -->
|
<!-- 连续打卡卡片 -->
|
||||||
<ui-card variant="in" class="{{justCompleted ? 'just-completed' : ''}}">
|
<ui-card variant="gradient in" class="{{justCompleted ? 'just-completed' : ''}}">
|
||||||
<view class="header-card">
|
<view class="header-card">
|
||||||
<view class="streak-section">
|
<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-num">{{streakCount}}</text>
|
||||||
<text class="streak-label">连续打卡</text>
|
<text class="streak-label">连续打卡</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="divider"></view>
|
<view class="divider"></view>
|
||||||
<view class="plan-info">
|
<view class="plan-info">
|
||||||
<view class="plan-badge">
|
<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>
|
<text class="plan-badge-text">{{planName}}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="progress-bar-wrap">
|
<view class="progress-bar-wrap">
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="target-display">
|
<view class="target-display">
|
||||||
<view class="target-ring">
|
<view class="target-ring">
|
||||||
<text class="target-time">{{todayTargetText}}</text>
|
<text class="target-time" style="font-size: {{targetTimeFontSize}}rpx;">{{todayTargetText}}</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="target-unit">平板支撑</text>
|
<text class="target-unit">平板支撑</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
size="xl"
|
size="xl"
|
||||||
block
|
block
|
||||||
text="{{todayDone ? '再次训练' : '开始训练'}}"
|
text="{{todayDone ? '再次训练' : '开始训练'}}"
|
||||||
icon-src="{{icons.playFill}}"
|
icon-src="{{icons.playWhite}}"
|
||||||
bindtap="onStartTrain"
|
bindtap="onStartTrain"
|
||||||
></ui-btn>
|
></ui-btn>
|
||||||
|
|
||||||
|
|||||||
+24
-12
@@ -54,8 +54,11 @@
|
|||||||
.streak-num {
|
.streak-num {
|
||||||
font-size: 56rpx;
|
font-size: 56rpx;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--primary);
|
/* On the gradient hero card --text cascades to white; on any plain card it
|
||||||
line-height: 1.1;
|
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 {
|
.streak-label {
|
||||||
@@ -114,7 +117,9 @@
|
|||||||
|
|
||||||
.progress-fill {
|
.progress-fill {
|
||||||
height: 100%;
|
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;
|
border-radius: 4rpx;
|
||||||
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -126,9 +131,9 @@
|
|||||||
top: -4rpx;
|
top: -4rpx;
|
||||||
width: 16rpx;
|
width: 16rpx;
|
||||||
height: 16rpx;
|
height: 16rpx;
|
||||||
background: var(--primary);
|
background: var(--on-primary, var(--primary));
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-shadow: 0 0 10rpx var(--primary);
|
box-shadow: 0 0 10rpx var(--on-primary, var(--primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-label {
|
.progress-label {
|
||||||
@@ -167,20 +172,27 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 200rpx;
|
width: 280rpx;
|
||||||
height: 200rpx;
|
height: 280rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.08), rgba(var(--primary-rgb), 0.02));
|
background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.10), rgba(var(--primary-rgb), 0.03));
|
||||||
border: 4rpx solid rgba(var(--primary-rgb), 0.15);
|
border: 6rpx solid rgba(var(--primary-rgb), 0.18);
|
||||||
margin-bottom: 16rpx;
|
box-shadow: 0 0 0 14rpx rgba(var(--primary-rgb), 0.05);
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.target-time {
|
.target-time {
|
||||||
font-size: 64rpx;
|
font-size: 84rpx;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: -1rpx;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
max-width: 240rpx;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.target-unit {
|
.target-unit {
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
<view class="container page-enter" style="{{themeStyle}}">
|
<view class="container page-enter" style="{{themeStyle}}">
|
||||||
<!-- Period tabs -->
|
<!-- Period tabs: pill-style segmented control, consistent with settings -->
|
||||||
<view class="period-tabs">
|
<view class="period-seg-wrap">
|
||||||
|
<view class="period-seg">
|
||||||
<view
|
<view
|
||||||
wx:for="{{periods}}"
|
wx:for="{{periods}}"
|
||||||
wx:key="key"
|
wx:key="key"
|
||||||
class="period-tab {{activePeriod === item.key ? 'active' : ''}}"
|
class="period-seg-item {{activePeriod === item.key ? 'active' : ''}}"
|
||||||
data-period="{{item.key}}"
|
data-period="{{item.key}}"
|
||||||
bindtap="switchPeriod"
|
bindtap="switchPeriod"
|
||||||
>{{item.label}}</view>
|
>{{item.label}}</view>
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- Loading skeleton -->
|
<!-- Loading skeleton -->
|
||||||
<view class="leaderboard-skeleton" wx:if="{{loading}}">
|
<view class="leaderboard-skeleton" wx:if="{{loading}}">
|
||||||
@@ -27,21 +29,55 @@
|
|||||||
<text class="empty-sub">快去训练吧,争当第一名!</text>
|
<text class="empty-sub">快去训练吧,争当第一名!</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Rank list -->
|
<block wx:else>
|
||||||
<view class="rank-list" 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
|
<view
|
||||||
wx:for="{{rankedList}}"
|
wx:for="{{rankedList}}"
|
||||||
wx:key="openid"
|
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;"
|
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' : ''}}">
|
<view class="rank-num {{item.rank === 1 ? 'gold' : item.rank === 2 ? 'silver' : item.rank === 3 ? 'bronze' : ''}}">
|
||||||
<text wx:if="{{item.rank === 1}}">🥇</text>
|
<text>{{item.rank}}</text>
|
||||||
<text wx:elif="{{item.rank === 2}}">🥈</text>
|
|
||||||
<text wx:elif="{{item.rank === 3}}">🥉</text>
|
|
||||||
<text wx:else>{{item.rank}}</text>
|
|
||||||
</view>
|
</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">
|
<view class="rank-info">
|
||||||
<text class="rank-name">{{item.name}}</text>
|
<text class="rank-name">{{item.name}}</text>
|
||||||
<text class="rank-sessions">{{item.sessions}}次</text>
|
<text class="rank-sessions">{{item.sessions}}次</text>
|
||||||
@@ -49,11 +85,12 @@
|
|||||||
<text class="rank-dur">{{item.durationText}}</text>
|
<text class="rank-dur">{{item.durationText}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
</block>
|
||||||
|
|
||||||
<!-- My entry pinned at bottom (if not already in list) -->
|
<!-- My entry pinned at bottom (if not already in list) -->
|
||||||
<view class="my-bar" wx:if="{{myEntry && myEntry.rank > rankedList.length}}">
|
<view class="my-bar" wx:if="{{myEntry && myEntry.rank > rankedList.length}}">
|
||||||
<view class="rank-num">{{myEntry.rank}}</view>
|
<view class="rank-num my-bar__num">{{myEntry.rank}}</view>
|
||||||
<image class="rank-avatar" src="{{icons.peopleFill}}" mode="aspectFit"></image>
|
<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">
|
<view class="rank-info">
|
||||||
<text class="rank-name">我 ({{myEntry.name}})</text>
|
<text class="rank-name">我 ({{myEntry.name}})</text>
|
||||||
<text class="rank-sessions">{{myEntry.sessions}}次</text>
|
<text class="rank-sessions">{{myEntry.sessions}}次</text>
|
||||||
|
|||||||
@@ -5,42 +5,36 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Period tabs */
|
/* Period tabs: pill segmented control, same language as settings .segmented */
|
||||||
.period-tabs {
|
.period-seg-wrap {
|
||||||
display: flex;
|
padding: 20rpx 24rpx 8rpx;
|
||||||
background: var(--card-bg);
|
|
||||||
padding: 0 32rpx;
|
|
||||||
border-bottom: 1rpx solid var(--border);
|
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.period-tab {
|
.period-seg {
|
||||||
|
display: flex;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border-radius: 999rpx;
|
||||||
|
padding: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-seg-item {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 24rpx 0 20rpx;
|
padding: 16rpx 0;
|
||||||
font-size: 30rpx;
|
font-size: 28rpx;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
position: relative;
|
border-radius: 999rpx;
|
||||||
transition: color 0.2s, background-color 0.3s ease;
|
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);
|
color: var(--primary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.10);
|
||||||
|
|
||||||
.period-tab.active::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
width: 40rpx;
|
|
||||||
height: 6rpx;
|
|
||||||
border-radius: 3rpx;
|
|
||||||
background: var(--primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status */
|
/* Status */
|
||||||
@@ -75,6 +69,163 @@
|
|||||||
opacity: 0.6;
|
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 */
|
||||||
.rank-list {
|
.rank-list {
|
||||||
padding: 16rpx 24rpx;
|
padding: 16rpx 24rpx;
|
||||||
@@ -116,14 +267,11 @@
|
|||||||
.rank-num.silver { background: var(--bg-soft); color: #8C8C8C; font-size: 36rpx; }
|
.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-num.bronze { background: rgba(212, 107, 8, 0.15); color: #D46B08; font-size: 36rpx; }
|
||||||
|
|
||||||
.rank-avatar {
|
.avatar--sm {
|
||||||
width: 64rpx;
|
width: 64rpx;
|
||||||
height: 64rpx;
|
height: 64rpx;
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--bg-soft);
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-right: 16rpx;
|
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
|
margin-right: 16rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rank-info {
|
.rank-info {
|
||||||
@@ -155,27 +303,46 @@
|
|||||||
margin-left: 16rpx;
|
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 {
|
.my-bar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: calc(110rpx + env(safe-area-inset-bottom));
|
bottom: calc(120rpx + env(safe-area-inset-bottom));
|
||||||
left: 24rpx;
|
left: 24rpx;
|
||||||
right: 24rpx;
|
right: 24rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: rgba(var(--primary-rgb), 0.12);
|
background: linear-gradient(135deg, var(--primary), var(--primary-light));
|
||||||
border: 2rpx solid var(--primary);
|
border-radius: 20rpx;
|
||||||
border-radius: 16rpx;
|
padding: 20rpx 24rpx;
|
||||||
padding: 16rpx 24rpx;
|
|
||||||
z-index: 10;
|
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 {
|
.my-bar .rank-name {
|
||||||
color: var(--primary);
|
color: #FFFFFF;
|
||||||
font-weight: 600;
|
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 entrance animation ---- */
|
||||||
.rank-item--enter {
|
.rank-item--enter {
|
||||||
animation: rankIn 0.4s cubic-bezier(0.2, 0, 0.2, 1) both;
|
animation: rankIn 0.4s cubic-bezier(0.2, 0, 0.2, 1) both;
|
||||||
@@ -191,7 +358,13 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
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 {
|
.empty-illus {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ Page({
|
|||||||
currentMonth: new Date().getMonth() + 1,
|
currentMonth: new Date().getMonth() + 1,
|
||||||
monthRecords: [],
|
monthRecords: [],
|
||||||
historyList: [],
|
historyList: [],
|
||||||
|
listMode: 'month',
|
||||||
showDayDetail: false,
|
showDayDetail: false,
|
||||||
dayDetail: {},
|
dayDetail: {},
|
||||||
icons: iconsMod.build()
|
icons: iconsMod.build()
|
||||||
@@ -76,6 +77,8 @@ Page({
|
|||||||
maxDurationText: util.formatDuration(stats.maxDuration),
|
maxDurationText: util.formatDuration(stats.maxDuration),
|
||||||
monthRecords: records,
|
monthRecords: records,
|
||||||
historyList,
|
historyList,
|
||||||
|
listMode: this.data.listMode || 'month',
|
||||||
|
displayList: (this.data.listMode || 'month') === 'month' ? records : historyList,
|
||||||
loading: false
|
loading: false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -104,6 +107,13 @@ Page({
|
|||||||
this.refresh()
|
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) {
|
onDayTap(e) {
|
||||||
const { year, month, day } = e.detail
|
const { year, month, day } = e.detail
|
||||||
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||||
|
|||||||
@@ -75,22 +75,26 @@
|
|||||||
<view class="history-header">
|
<view class="history-header">
|
||||||
<view class="history-title-wrap">
|
<view class="history-title-wrap">
|
||||||
<image class="history-title-icon" src="{{icons.formFill}}" mode="aspectFit"></image>
|
<image class="history-title-icon" src="{{icons.formFill}}" mode="aspectFit"></image>
|
||||||
<text class="section-title">最近记录</text>
|
<text class="section-title">{{listMode === 'month' ? currentYear + '年' + currentMonth + '月记录' : '最近记录'}}</text>
|
||||||
</view>
|
</view>
|
||||||
<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>
|
||||||
<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">
|
||||||
<view class="empty-illus-body"></view>
|
<view class="empty-illus-body"></view>
|
||||||
<view class="empty-illus-arm empty-illus-arm--l"></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-arm empty-illus-arm--r"></view>
|
||||||
<view class="empty-illus-bubble">开始</view>
|
<view class="empty-illus-bubble">开始</view>
|
||||||
</view>
|
</view>
|
||||||
<text class="empty-text">还没有训练记录</text>
|
<text class="empty-text">{{listMode === 'month' ? '本月还没有训练记录' : '还没有训练记录'}}</text>
|
||||||
<text class="empty-sub">完成第一次训练,开启你的坚持!</text>
|
<text class="empty-sub">{{listMode === 'month' ? '换个有训练的月份看看吧' : '完成第一次训练,开启你的坚持!'}}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="history-list">
|
<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"
|
<image class="history-item-icon"
|
||||||
src="{{item.duration >= 120 ? icons.hotFill : item.duration >= 60 ? icons.likeFill : icons.roundCheckFill}}"
|
src="{{item.duration >= 120 ? icons.hotFill : item.duration >= 60 ? icons.likeFill : icons.roundCheckFill}}"
|
||||||
mode="aspectFit"
|
mode="aspectFit"
|
||||||
|
|||||||
@@ -109,6 +109,32 @@
|
|||||||
.history-hint {
|
.history-hint {
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
color: var(--text-secondary);
|
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 {
|
.empty-state {
|
||||||
@@ -256,7 +282,7 @@
|
|||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
.day-detail-body {
|
.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);
|
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) {
|
async _uploadAvatar(filePath) {
|
||||||
if (!cloud.enabled) throw new Error('cloud not enabled')
|
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 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')
|
if (!res || !res.fileID) throw new Error('uploadFile returned no fileID')
|
||||||
return res.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) {
|
onNicknameBlur(e) {
|
||||||
const value = (e.detail.value || '').trim()
|
const value = (e.detail.value || '').trim()
|
||||||
if (!value) return
|
if (!value) return
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="profile-row">
|
<view class="profile-row">
|
||||||
<button class="avatar-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
|
<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">
|
<view class="avatar-overlay">
|
||||||
<text>点击设置</text>
|
<text>点击设置</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -39,17 +39,18 @@
|
|||||||
<image class="section-icon" src="{{icons.skinFill}}" mode="aspectFit"></image>
|
<image class="section-icon" src="{{icons.skinFill}}" mode="aspectFit"></image>
|
||||||
<text class="section-title">配色方案</text>
|
<text class="section-title">配色方案</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="theme-list">
|
<view class="theme-cards">
|
||||||
<view
|
<view
|
||||||
class="theme-item {{currentThemeId === item.id ? 'active' : ''}}"
|
class="theme-card {{currentThemeId === item.id ? 'active' : ''}}"
|
||||||
wx:for="{{themes}}"
|
wx:for="{{themes}}"
|
||||||
wx:key="id"
|
wx:key="id"
|
||||||
data-id="{{item.id}}"
|
data-id="{{item.id}}"
|
||||||
bindtap="onSelectTheme"
|
bindtap="onSelectTheme"
|
||||||
>
|
>
|
||||||
<view class="theme-color-dot" style="background: {{item.primary}};"></view>
|
<view class="theme-card-swatch" style="background: linear-gradient(135deg, {{item.primary}}, {{item.primaryLight}});">
|
||||||
<text class="theme-name">{{item.name}}</text>
|
<image class="theme-card-check" wx:if="{{currentThemeId === item.id}}" src="{{icons.checkWhite}}" mode="aspectFit"></image>
|
||||||
<image class="theme-check-img" wx:if="{{currentThemeId === item.id}}" src="{{icons.check}}" mode="aspectFit"></image>
|
</view>
|
||||||
|
<text class="theme-card-name {{currentThemeId === item.id ? 'active' : ''}}">{{item.name}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</ui-card>
|
</ui-card>
|
||||||
|
|||||||
@@ -14,7 +14,64 @@
|
|||||||
margin-bottom: 0;
|
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-list { margin-top: 0; }
|
||||||
|
|
||||||
.theme-item {
|
.theme-item {
|
||||||
@@ -40,20 +97,6 @@
|
|||||||
border-top: 1rpx solid transparent;
|
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 {
|
.theme-name {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
@@ -78,6 +121,10 @@
|
|||||||
|
|
||||||
.plan-item:last-child { border-bottom: none; }
|
.plan-item:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.plan-item {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.plan-item.active {
|
.plan-item.active {
|
||||||
background: rgba(var(--primary-rgb), 0.12);
|
background: rgba(var(--primary-rgb), 0.12);
|
||||||
margin: 0 -32rpx;
|
margin: 0 -32rpx;
|
||||||
@@ -87,6 +134,19 @@
|
|||||||
border-bottom: none;
|
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 {
|
.plan-radio {
|
||||||
width: 40rpx;
|
width: 40rpx;
|
||||||
height: 40rpx;
|
height: 40rpx;
|
||||||
@@ -283,6 +343,11 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 真实头像(正方形)用 aspectFill 填满圆形容器,去掉占位图的内缩 padding */
|
||||||
|
.avatar-img.has-avatar {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.avatar-overlay {
|
.avatar-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
|||||||
@@ -243,13 +243,13 @@ Page({
|
|||||||
const nextStreak = (streak.lastDate && streak.count >= 1) ? streak.count + 1 : 1
|
const nextStreak = (streak.lastDate && streak.count >= 1) ? streak.count + 1 : 1
|
||||||
|
|
||||||
if (stats.totalSessions === 0) {
|
if (stats.totalSessions === 0) {
|
||||||
return { level: 'first', message: '🎉 第一次训练!坚持就是胜利' }
|
return { level: 'first', message: '第一次训练!坚持就是胜利' }
|
||||||
}
|
}
|
||||||
if ([7, 14, 30, 50, 100, 365].includes(nextStreak)) {
|
if ([7, 14, 30, 50, 100, 365].includes(nextStreak)) {
|
||||||
return { level: 'milestone', message: `🔥 连续打卡 ${nextStreak} 天!` }
|
return { level: 'milestone', message: `连续打卡 ${nextStreak} 天!` }
|
||||||
}
|
}
|
||||||
if (elapsed > stats.maxDuration) {
|
if (elapsed > stats.maxDuration) {
|
||||||
return { level: 'record', message: `🏆 新纪录!${elapsed}秒` }
|
return { level: 'record', message: `新纪录!${elapsed}秒` }
|
||||||
}
|
}
|
||||||
return { level: 'normal', message: '完成!' }
|
return { level: 'normal', message: '完成!' }
|
||||||
},
|
},
|
||||||
@@ -454,6 +454,7 @@ Page({
|
|||||||
this._lastCountdown = 0
|
this._lastCountdown = 0
|
||||||
this._lastTipIndex = -1
|
this._lastTipIndex = -1
|
||||||
this._clearVibrateTimers()
|
this._clearVibrateTimers()
|
||||||
|
this._countUpTask = null
|
||||||
this.setData({
|
this.setData({
|
||||||
showCompletion: false,
|
showCompletion: false,
|
||||||
isCompleted: false,
|
isCompleted: false,
|
||||||
@@ -462,9 +463,7 @@ Page({
|
|||||||
isPaused: false,
|
isPaused: false,
|
||||||
remaining: this.data.duration,
|
remaining: this.data.duration,
|
||||||
overtime: 0,
|
overtime: 0,
|
||||||
goalReached: false,
|
goalReached: false
|
||||||
// Re-init countUp so the big number rolls again on next start
|
|
||||||
_countUpTask: null
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
+12
-12
@@ -29,21 +29,21 @@
|
|||||||
duration="{{duration}}"
|
duration="{{duration}}"
|
||||||
remaining="{{isCompleted ? 0 : remaining}}"
|
remaining="{{isCompleted ? 0 : remaining}}"
|
||||||
size="{{360}}"
|
size="{{360}}"
|
||||||
ringWidth="{{16}}"
|
ringWidth="{{22}}"
|
||||||
status="{{status}}"
|
status="{{status}}"
|
||||||
primaryColor="{{theme.primary}}"
|
primaryColor="{{theme.primary}}"
|
||||||
primaryLightColor="{{theme.primaryLight}}"
|
primaryLightColor="{{theme.primaryLight}}"
|
||||||
trackColor="{{isDark ? '#2C2C2E' : '#EEEEEE'}}"
|
trackColor="{{isDark ? '#3A3A40' : '#EEEEEE'}}"
|
||||||
></progress-ring>
|
></progress-ring>
|
||||||
|
|
||||||
<!-- 完成庆祝粒子 -->
|
<!-- 完成庆祝粒子 -->
|
||||||
<view class="celebrate-burst" wx:if="{{isCompleted}}">
|
<view class="celebrate-burst" wx:if="{{isCompleted}}">
|
||||||
<view class="spark s1">✨</view>
|
<image class="spark s1" src="{{icons.sparkleFill}}" mode="aspectFit"></image>
|
||||||
<view class="spark s2">⭐</view>
|
<image class="spark s2" src="{{icons.sparkleFill}}" mode="aspectFit"></image>
|
||||||
<view class="spark s3">💫</view>
|
<image class="spark s3" src="{{icons.crownFill}}" mode="aspectFit"></image>
|
||||||
<view class="spark s4">🌟</view>
|
<image class="spark s4" src="{{icons.sparkleFill}}" mode="aspectFit"></image>
|
||||||
<view class="spark s5">✨</view>
|
<image class="spark s5" src="{{icons.sparkleFill}}" mode="aspectFit"></image>
|
||||||
<view class="spark s6">💪</view>
|
<image class="spark s6" src="{{icons.likeFill}}" mode="aspectFit"></image>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="overtime-badge" wx:if="{{overtime > 0}}">
|
<view class="overtime-badge" wx:if="{{overtime > 0}}">
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
size="xl"
|
size="xl"
|
||||||
block
|
block
|
||||||
text="开始"
|
text="开始"
|
||||||
icon-src="{{icons.playFill}}"
|
icon-src="{{icons.playWhite}}"
|
||||||
bindtap="onStart"
|
bindtap="onStart"
|
||||||
></ui-btn>
|
></ui-btn>
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
size="xl"
|
size="xl"
|
||||||
text="继续"
|
text="继续"
|
||||||
icon-src="{{icons.playFill}}"
|
icon-src="{{icons.playWhite}}"
|
||||||
custom-style="margin-right: 28rpx;"
|
custom-style="margin-right: 28rpx;"
|
||||||
bindtap="onStart"
|
bindtap="onStart"
|
||||||
></ui-btn>
|
></ui-btn>
|
||||||
@@ -137,7 +137,7 @@
|
|||||||
<view class="completion-mask" wx:if="{{showCompletion}}" catchtouchmove="onNoop">
|
<view class="completion-mask" wx:if="{{showCompletion}}" catchtouchmove="onNoop">
|
||||||
<view class="completion-card">
|
<view class="completion-card">
|
||||||
<view class="completion-emoji-wrap">
|
<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>
|
</view>
|
||||||
<text class="completion-title">{{completionMessage}}</text>
|
<text class="completion-title">{{completionMessage}}</text>
|
||||||
|
|
||||||
@@ -153,7 +153,7 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="completion-streak" wx:if="{{completionStreak > 0}}">
|
<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>
|
<text class="streak-text">连续打卡 {{completionStreak}} 天</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
|||||||
+17
-14
@@ -17,24 +17,25 @@
|
|||||||
justify-content: center;
|
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 {
|
.breathe-ring {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 50%;
|
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;
|
animation: breathePulse 3.5s ease-in-out infinite;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.breathe-ring-1 { width: 480rpx; height: 480rpx; animation-delay: 0s; }
|
.breathe-ring-1 { width: 520rpx; height: 520rpx; 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-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: 320rpx; height: 320rpx; animation-delay: 1s; border-color: rgba(var(--primary-rgb), 0.35); border-width: 5rpx; }
|
.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 { 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-1 { animation-delay: 0s; }
|
||||||
.breathe-ring.fast.breathe-ring-2 { animation-delay: 0.25s; border-color: rgba(var(--primary-rgb), 0.45); }
|
.breathe-ring.fast.breathe-ring-2 { animation-delay: 0.25s; }
|
||||||
.breathe-ring.fast.breathe-ring-3 { animation-delay: 0.5s; border-color: rgba(var(--primary-rgb), 0.6); }
|
.breathe-ring.fast.breathe-ring-3 { animation-delay: 0.5s; }
|
||||||
|
|
||||||
/* celebration burst */
|
/* celebration burst */
|
||||||
.celebrate-burst {
|
.celebrate-burst {
|
||||||
@@ -47,11 +48,12 @@
|
|||||||
|
|
||||||
.spark {
|
.spark {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
font-size: 36rpx;
|
width: 44rpx;
|
||||||
|
height: 44rpx;
|
||||||
animation: sparkBurst 1s ease-out forwards;
|
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; }
|
.s2 { top: 30rpx; right: -10rpx; animation-delay: 0.1s; }
|
||||||
.s3 { bottom: -10rpx; right: 50rpx; animation-delay: 0.2s; }
|
.s3 { bottom: -10rpx; right: 50rpx; animation-delay: 0.2s; }
|
||||||
.s4 { bottom: 20rpx; left: 30rpx; animation-delay: 0.15s; }
|
.s4 { bottom: 20rpx; left: 30rpx; animation-delay: 0.15s; }
|
||||||
@@ -264,9 +266,9 @@
|
|||||||
50% { transform: scale(1.08); }
|
50% { transform: scale(1.08); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.completion-emoji {
|
.completion-icon {
|
||||||
font-size: 80rpx;
|
width: 80rpx;
|
||||||
line-height: 1;
|
height: 80rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.completion-title {
|
.completion-title {
|
||||||
@@ -346,7 +348,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.streak-flame {
|
.streak-flame {
|
||||||
font-size: 32rpx;
|
width: 32rpx;
|
||||||
|
height: 32rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.streak-text {
|
.streak-text {
|
||||||
|
|||||||
+6
-6
@@ -2,15 +2,15 @@
|
|||||||
"light": {
|
"light": {
|
||||||
"navigationBarBackgroundColor": "#FF6B35",
|
"navigationBarBackgroundColor": "#FF6B35",
|
||||||
"navigationBarTextStyle": "white",
|
"navigationBarTextStyle": "white",
|
||||||
"backgroundColor": "#F5F5F5",
|
"backgroundColor": "#FFF3ED",
|
||||||
"backgroundColorTop": "#F5F5F5",
|
"backgroundColorTop": "#FFF3ED",
|
||||||
"backgroundColorBottom": "#F5F5F5"
|
"backgroundColorBottom": "#FFF3ED"
|
||||||
},
|
},
|
||||||
"dark": {
|
"dark": {
|
||||||
"navigationBarBackgroundColor": "#FF6B35",
|
"navigationBarBackgroundColor": "#FF6B35",
|
||||||
"navigationBarTextStyle": "white",
|
"navigationBarTextStyle": "white",
|
||||||
"backgroundColor": "#0F0F12",
|
"backgroundColor": "#131316",
|
||||||
"backgroundColorTop": "#0F0F12",
|
"backgroundColorTop": "#131316",
|
||||||
"backgroundColorBottom": "#0F0F12"
|
"backgroundColorBottom": "#131316"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -7,7 +7,8 @@ const STORAGE_KEY = 'dark_mode_pref'
|
|||||||
|
|
||||||
function _readSystemTheme() {
|
function _readSystemTheme() {
|
||||||
try {
|
try {
|
||||||
const info = wx.getSystemInfoSync()
|
// wx.getSystemInfoSync 已废弃,改用 wx.getWindowInfo(同样返回 theme 字段)
|
||||||
|
const info = wx.getWindowInfo()
|
||||||
return info.theme === 'dark'
|
return info.theme === 'dark'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return false
|
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"/>',
|
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"/>',
|
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"/>',
|
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"/>',
|
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"/>',
|
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
|
// Notification
|
||||||
@@ -132,6 +141,19 @@ const build = (theme) => {
|
|||||||
out.deleteInactive = _svg(PATHS.delete, INACTIVE)
|
out.deleteInactive = _svg(PATHS.delete, INACTIVE)
|
||||||
out.successFill = _svg(PATHS.roundCheckFill, s)
|
out.successFill = _svg(PATHS.roundCheckFill, s)
|
||||||
out.successLine = _svg(PATHS.roundCheck, INACTIVE)
|
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
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -75,6 +75,9 @@ const getPlan = (planId, customPlans) => {
|
|||||||
|
|
||||||
const getTodayTarget = (planId, currentDay) => {
|
const getTodayTarget = (planId, currentDay) => {
|
||||||
const plan = getPlan(planId)
|
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)
|
const day = plan.days.find(d => d.day === currentDay)
|
||||||
return day ? day.target : plan.days[0].target
|
return day ? day.target : plan.days[0].target
|
||||||
}
|
}
|
||||||
@@ -95,7 +98,9 @@ const getPlanDay = (planId, records, planStartDate, customPlans) => {
|
|||||||
return true
|
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)
|
return Math.min(trainedDays + 1, plan.totalDays)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-4
@@ -38,6 +38,14 @@ const THEMES = [
|
|||||||
primaryLight: '#F06A9A',
|
primaryLight: '#F06A9A',
|
||||||
primaryBg: '#FDEEF3',
|
primaryBg: '#FDEEF3',
|
||||||
primaryRgb: '236,64,122'
|
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 getThemeStyle = (theme, isDark = false) => {
|
||||||
const t = theme || getCurrentTheme()
|
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
|
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;'
|
? '--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;'
|
||||||
: BASE_VARS
|
: `--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};`
|
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 只响应系统暗黑模式。用户手动选择暗黑
|
// theme.json 的 dark section 只响应系统暗黑模式。用户手动选择暗黑
|
||||||
// 但系统是明亮时,窗口背景仍为浅色,切 tab 会闪白。这里用 API 强制设置
|
// 但系统是明亮时,窗口背景仍为浅色,切 tab 会闪白。这里用 API 强制设置
|
||||||
// 窗口背景,让 tab 切换时的瞬间底色与当前模式一致。
|
// 窗口背景,让 tab 切换时的瞬间底色与当前模式一致。
|
||||||
const bg = isDark ? '#0F0F12' : '#F5F5F5'
|
const bg = isDark ? '#131316' : theme.primaryBg
|
||||||
try { wx.setBackgroundColor({ backgroundColor: bg, backgroundColorTop: bg, backgroundColorBottom: bg }) } catch (e) {}
|
try { wx.setBackgroundColor({ backgroundColor: bg, backgroundColorTop: bg, backgroundColorBottom: bg }) } catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +136,7 @@ const applyWindowBg = () => {
|
|||||||
try {
|
try {
|
||||||
const darkMod = require('./darkMode')
|
const darkMod = require('./darkMode')
|
||||||
const isDark = darkMod.getInitialDarkMode()
|
const isDark = darkMod.getInitialDarkMode()
|
||||||
const bg = isDark ? '#0F0F12' : '#F5F5F5'
|
const bg = isDark ? '#131316' : getCurrentTheme().primaryBg
|
||||||
wx.setBackgroundColor({ backgroundColor: bg, backgroundColorTop: bg, backgroundColorBottom: bg })
|
wx.setBackgroundColor({ backgroundColor: bg, backgroundColorTop: bg, backgroundColorBottom: bg })
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-2
@@ -36,14 +36,25 @@ class Timer {
|
|||||||
this._tick()
|
this._tick()
|
||||||
this._intervalId = setInterval(() => { this._tick() }, 1000)
|
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)
|
// 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 {
|
try {
|
||||||
wx.onAppShow(this._onAppShow)
|
wx.onAppShow(this._onAppShow)
|
||||||
this._appShowBound = true
|
this._appShowBound = true
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_unbindAppShow() {
|
||||||
|
if (!this._appShowBound || typeof wx.offAppShow !== 'function') return
|
||||||
|
try {
|
||||||
|
wx.offAppShow(this._onAppShow)
|
||||||
|
} catch (e) {}
|
||||||
|
this._appShowBound = false
|
||||||
}
|
}
|
||||||
|
|
||||||
pause() {
|
pause() {
|
||||||
@@ -67,6 +78,7 @@ class Timer {
|
|||||||
this._paused = false
|
this._paused = false
|
||||||
clearInterval(this._intervalId)
|
clearInterval(this._intervalId)
|
||||||
this._intervalId = null
|
this._intervalId = null
|
||||||
|
this._unbindAppShow()
|
||||||
return this._elapsed
|
return this._elapsed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user