Files
wx_pbzc/pages/index/index.js
T
lc 05c4763e49 feat(timer): 循环训练组进度改为圆环分段弧 + 信息气泡三行合一
- progress-ring 新增 sets/currentSet/resting/successColor 属性,
  圆环外圈绘制分段弧(已完成=主色/当前=呼吸光点/休息下一组=绿),单组模式零渲染
- timer 删除顶部 circuit-bar 进度面板,布局骨架三模式统一
- 呼吸环淡化(描边 0.55→0.20,柔光渐变强度减半)
- 目标卡与提示气泡合并为 info-bubble,状态文案(撑住/休息中/已暂停)并入气泡顶部
2026-08-12 09:39:41 +08:00

306 lines
9.2 KiB
JavaScript

const storage = require('../../utils/storage')
const planMod = require('../../utils/plan')
const util = require('../../utils/util')
const themeMod = require('../../utils/theme')
const iconsMod = require('../../utils/icons')
const config = require('../../config')
const MAX_CUSTOM_DURATION = 3600 // 1 hour max
Page({
data: {
theme: { primary: '#FF6B35', primaryLight: '#FF8C5A', primaryBg: '#FFF3ED', primaryRgb: '255,107,53' },
themeStyle: themeMod.BASE_VARS,
todayTarget: 0,
todayDone: false,
todayDuration: 0,
todayTargetText: '',
// 顶部问候区的用户资料(头像 + 昵称),来自 storage.getProfile()。
// 空则回退占位(人形图标 + "运动达人"),settings 改完回首页即刷新。
nickName: '',
avatarUrl: '',
streakCount: 0,
planName: '',
planDay: 1,
planTotal: 7,
// hero / 进度地图 / 时长展示相关字段(由 refresh 生成)
greetText: '你好',
streakText: '今天开启坚持之旅',
questNodes: [],
questTip: '',
estKcal: 0,
showPicker: false,
pickerInputFocus: false,
presets: [30, 60, 90, 120, 180, 300],
customDuration: 60,
customInput: '',
// --- Circuit (循环训练) config panel (persisted via storage.saveCircuitConfig) ---
showCircuitPicker: false,
cHold: 30,
cSets: 4,
cRest: 15,
cSessions: 3,
icons: iconsMod.build()
},
onLoad() {
themeMod.applyThemeToPage(this)
this._firstShow = true
},
onShow() {
themeMod.applyThemeToPage(this)
try {
const tb = this.getTabBar()
if (tb) tb.setData({ selected: 0 })
} catch (e) {}
// Skip the first onShow to avoid double-refresh; subsequent tab switches still refresh.
if (this._firstShow) {
this._firstShow = false
this.refresh()
this._checkFreshTrain()
return
}
this.refresh()
this._checkFreshTrain()
},
/**
* If user just returned from completing a training, briefly highlight
* the "今日已完成" badge so the success feels tangible. The flag is
* timestamped; older than 10s is treated as stale (e.g. user reopened
* the app later).
*/
_checkFreshTrain() {
try {
const at = wx.getStorageSync('_last_train_at')
if (!at) return
const age = Date.now() - at
if (age < 0 || age > 10 * 1000) {
wx.removeStorageSync('_last_train_at')
return
}
wx.removeStorageSync('_last_train_at')
this.setData({ justCompleted: true })
if (this._justCompletedTimer) clearTimeout(this._justCompletedTimer)
this._justCompletedTimer = setTimeout(() => {
this.setData({ justCompleted: false })
}, 3000)
} catch (e) {}
},
onPullDownRefresh() {
this.refresh()
wx.stopPullDownRefresh()
},
refresh() {
const settings = storage.getSettings()
const streak = storage.validateStreak()
const todayRecord = storage.getTodayRecord()
const allRecords = Object.values(storage.getRecords()).flat()
const customPlans = storage.getCustomPlans()
const plan = planMod.getPlan(settings.planId, customPlans)
const planDay = planMod.getPlanDay(settings.planId, allRecords, settings.planStartDate, customPlans)
const clampedDay = Math.min(planDay, plan.totalDays)
const target = planMod.getTodayTarget(settings.planId, clampedDay, customPlans)
const theme = themeMod.getCurrentTheme()
const profile = storage.getProfile()
const greetText = this._greetByHour()
const streakText = streak.count > 0
? `已连续 ${streak.count} 天打卡`
: '今天开启坚持之旅'
const questNodes = this._buildQuestMap(plan.totalDays, clampedDay)
const questTip = clampedDay >= plan.totalDays
? '🎉 本期计划已完成,去记录页看看战绩'
: `⚡ 再坚持 ${plan.totalDays - clampedDay} 天,完成本期计划`
// 千卡粗算:按约 4 千卡/分钟估算(1分30秒≈6千卡)。仅作激励性参考,非精确值。
const estKcal = Math.max(1, Math.round((target / 60) * 4))
this.setData({
theme,
icons: iconsMod.build(theme),
todayTarget: target,
todayDone: todayRecord && todayRecord.duration >= target,
todayDuration: todayRecord ? todayRecord.duration : 0,
todayTargetText: util.formatTime(target),
streakCount: streak.count,
planName: plan.name,
planDay: clampedDay,
planTotal: plan.totalDays,
greetText,
streakText,
questNodes,
questTip,
estKcal,
nickName: profile.nickname || '',
avatarUrl: profile.avatarUrl || ''
})
},
_greetByHour() {
const h = new Date().getHours()
if (h < 6) return '夜深了'
if (h < 12) return '早上好'
if (h < 14) return '中午好'
if (h < 18) return '下午好'
if (h < 22) return '晚上好'
return '夜深了'
},
/**
* 生成闯关进度地图节点。计划天数 <= 8 时画每天节点;> 8 时退化为
* 5 个里程碑节点(0/25/50/75/100%),避免节点过密。state: done|now|todo。
*/
_buildQuestMap(total, day) {
const nodes = []
if (total > 0 && total <= 8) {
for (let i = 1; i <= total; i++) {
nodes.push({
label: i === total ? '🏆' : String(i),
state: i < day ? 'done' : (i === day ? 'now' : 'todo'),
final: i === total
})
}
return nodes
}
const marks = [0, 0.25, 0.5, 0.75, 1]
let nowAssigned = false
marks.forEach((m, idx) => {
const dayAt = Math.round(m * total)
const isFinal = idx === marks.length - 1
let state
if (dayAt <= day) state = 'done'
else if (!nowAssigned) { state = 'now'; nowAssigned = true }
else state = 'todo'
nodes.push({
label: isFinal ? '🏆' : (m === 0 ? '起' : String(dayAt)),
state,
final: isFinal
})
})
return nodes
},
onStartTrain() {
wx.navigateTo({ url: '/pages/timer/timer' })
},
onFreeTrain() {
this.setData({ showPicker: true, customInput: '', customDuration: 60 })
},
onClosePicker() {
this.setData({ showPicker: false })
},
onSelectPreset(e) {
const val = e.currentTarget.dataset.value
// 收起键盘(失焦 input),避免清空 customInput 时触发 adjust-position 让页面向上跳
this.setData({ pickerInputFocus: false, customDuration: val, customInput: '' })
},
onPickerInputFocus() {
this.setData({ pickerInputFocus: true })
},
onCustomInput(e) {
let val = parseInt(e.detail.value) || 0
if (val > MAX_CUSTOM_DURATION) val = MAX_CUSTOM_DURATION
this.setData({ customInput: e.detail.value, customDuration: val })
},
onConfirmFree() {
const dur = this.data.customDuration
if (!dur || dur <= 0) {
wx.showToast({ title: '请输入有效时长', icon: 'none' })
return
}
if (dur > MAX_CUSTOM_DURATION) {
wx.showToast({ title: '最大时长为1小时', icon: 'none' })
return
}
this.setData({ showPicker: false })
wx.navigateTo({ url: `/pages/timer/timer?free=${dur}` })
},
/**
* 循环训练入口:打开配置弹窗,预填上次保存的设置(getCircuitConfig 含默认值
* + 校验),用户改完确认后持久化并跳计时器。设置因此被保存下来——下次打开直接
* 沿用,无需重填。
*/
onCircuitTrain() {
const cfg = storage.getCircuitConfig()
this.setData({
showCircuitPicker: true,
cHold: cfg.holdPerSet,
cSets: cfg.sets,
cRest: cfg.restPerSet,
cSessions: cfg.sessionsPerWeek
})
},
onCloseCircuitPicker() {
this.setData({ showCircuitPicker: false })
},
onCircuitStep(e) {
const field = e.currentTarget.dataset.field
const delta = Number(e.currentTarget.dataset.delta) || 0
const keyMap = { hold: 'cHold', sets: 'cSets', rest: 'cRest', sessions: 'cSessions' }
const key = keyMap[field]
if (!key) return
const bounds = {
hold: [5, 600],
sets: [1, 50],
rest: [0, 600],
sessions: [1, 14]
}
let v = this.data[key] + delta
const min = bounds[field][0]
const max = bounds[field][1]
if (v < min) v = min
if (v > max) v = max
this.setData({ [key]: v })
},
onConfirmCircuit() {
const cfg = {
holdPerSet: this.data.cHold,
sets: this.data.cSets,
restPerSet: this.data.cRest,
sessionsPerWeek: this.data.cSessions
}
storage.saveCircuitConfig(cfg)
this.setData({ showCircuitPicker: false })
wx.navigateTo({
url: `/pages/timer/timer?circuit=1&hold=${cfg.holdPerSet}&sets=${cfg.sets}&rest=${cfg.restPerSet}&sessions=${cfg.sessionsPerWeek}`
})
},
/**
* 分享给朋友。微信在用户点击右上角"···"菜单里的"转发给朋友"、
* 或者页面内任何 `<button open-type="share">` / `<button open-type="share">`
* 时,都会调用这个钩子,返回值即分享卡片的内容。
* 没声明这个方法时分享按钮就是灰色的,见上轮分析。
*/
onShareAppMessage() {
const s = config.share.default
return {
title: s.title,
path: s.path
}
},
/**
* 分享到朋友圈。基础库 2.11.3+ 可用。同样不实现就让"分享到朋友圈"按钮
* 灰掉。各页统一用 config.share.timelineTitle 作为朋友圈文案。
*/
onShareTimeline() {
return {
title: config.share.timelineTitle
}
}
})