6f89494c0b
- 全宽橙渐变 hero:头像+昵称+按小时问候+「时长」敢不敢来挑战+连胜(昵称同行) - 进度点地图 quest-card 上浮叠 hero,圆点+连线 done/now/final 三态,与 CTA 等宽 - 计时环 dial 虚线刻度环+呼吸动画,整体放大(320rpx/数字96rpx) - CTA「立即挑战+时长」带脉冲,保留自由/循环入口 - 千卡按 4 千卡/分钟估算(激励参考);删除「今天还没开始」整段与连胜前图标 - 循环训练秒数步进 ±10;取消每日语录模块
354 lines
12 KiB
JavaScript
354 lines
12 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: '',
|
|
// 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: 96,
|
|
streakCount: 0,
|
|
planName: '',
|
|
planDay: 1,
|
|
planTotal: 7,
|
|
// hero / 进度地图 / dial 相关展示字段(由 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._readFontScale()
|
|
this._firstShow = true
|
|
},
|
|
|
|
/**
|
|
* Read the device's WeChat font-size scale and compensate the ring number
|
|
* so it never overflows the fixed circular clip on real devices.
|
|
*
|
|
* WeChat auto-applies `fontSizeScaleFactor` to ALL text (including rpx font
|
|
* sizes) but leaves rpx box dimensions untouched. A user who bumps up the
|
|
* system font therefore sees enlarged text inside a non-enlarged ring.
|
|
*
|
|
* CRITICAL asymmetry: the DevTools simulator reports the SAME
|
|
* `fontSizeScaleFactor` as a real device (the user's actual WeChat setting)
|
|
* but does NOT actually scale rendered text. So if we compensate there, the
|
|
* number ends up too small in the simulator. We therefore SKIP the
|
|
* compensation under DevTools (host.env === 'devtools') and keep the design
|
|
* size — matching what the simulator renders literally.
|
|
*
|
|
* On real devices, dividing our design size by the factor cancels the
|
|
* auto-scale, keeping the number a constant 96rpx-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 = 96
|
|
let size = Math.round(DESIGN / factor)
|
|
size = Math.max(48, Math.min(96, size))
|
|
this.setData({ targetTimeFontSize: size })
|
|
},
|
|
|
|
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
|
|
}
|
|
}
|
|
})
|