const storage = require('../../utils/storage') const util = require('../../utils/util') const themeMod = require('../../utils/theme') const iconsMod = require('../../utils/icons') const config = require('../../config') const { getBadgePositions, getBadgeSegments, getBadgeDisplayLabel, getBadgeCelebration } = require('../../utils/badge-state') Page({ data: { theme: { primary: '#FF6B35', primaryLight: '#FF8C5A', primaryBg: '#FFF3ED', primaryRgb: '255,107,53' }, themeStyle: themeMod.BASE_VARS, loading: true, totalDuration: 0, totalDurationText: '', totalSessions: 0, maxDuration: 0, maxDurationText: '', totalDays: 0, trainingBadges: [], badgeSegments: [], nextBadgeHint: '', currentYear: new Date().getFullYear(), currentMonth: new Date().getMonth() + 1, monthRecords: [], historyList: [], listMode: 'month', trendMode: 'week', trendData: [], showDayDetail: false, dayDetail: {}, icons: iconsMod.build() }, onLoad() { themeMod.applyThemeToPage(this) this._firstShow = true }, onShow() { themeMod.applyThemeToPage(this) try { const tb = this.getTabBar() if (tb) tb.setData({ selected: 1 }) } catch (e) {} if (this._firstShow) { this._firstShow = false this.refresh() return } this.refresh() }, onPullDownRefresh() { this.refresh() wx.stopPullDownRefresh() }, refresh() { storage.validateStreak() const stats = storage.getTotalStats() const monthKey = `${this.data.currentYear}-${String(this.data.currentMonth).padStart(2, '0')}` const records = (storage.getRecordsByMonth(monthKey) || []).map(r => ({ ...r, durationText: util.formatTime(r.duration) })) // Each month is already sorted newest-first in storage. To get top 30 most recent, // walk months in reverse chrono order and concat — O(months*30) instead of O(n log n). const byMonth = storage.getRecords() const monthKeys = Object.keys(byMonth).sort().reverse() const historyList = [] for (const mk of monthKeys) { for (const r of byMonth[mk]) { historyList.push({ ...r, durationText: util.formatTime(r.duration) }) if (historyList.length >= 30) break } if (historyList.length >= 30) break } // 累计训练天数勋章(里程碑进度条):统计口径=累计去重训练日(可中断)。 // 节点等距排列;每个连接段独立填充,绿色铺满即解锁下一枚勋章。 const totalDays = stats.totalDays const badgeDefs = config.trainingDayBadges const badgePositions = getBadgePositions(badgeDefs) const trainingBadges = badgeDefs.map((b, i) => ({ ...b, unlocked: totalDays >= b.days, pos: badgePositions[i], label: getBadgeDisplayLabel(b, totalDays >= b.days) })) const badgeSegments = getBadgeSegments(badgeDefs, totalDays) // 单枚解锁庆祝反馈:对比持久化"已见解锁集合",只庆祝新增勋章。 // 首次运行(无存储)仅初始化集合,避免老用户一打开就被历史勋章刷屏。 const unlockedDays = trainingBadges.filter(b => b.unlocked).map(b => b.days) const newlySet = this._checkBadgeCelebration(unlockedDays) if (newlySet) trainingBadges.forEach(b => { b.justUnlocked = newlySet.has(b.days) }) const nextIdx = trainingBadges.findIndex(b => !b.unlocked) const nextBadgeHint = nextIdx === -1 ? '全部勋章已解锁!' : `还差 ${badgeDefs[nextIdx].days - totalDays} 天解锁「${badgeDefs[nextIdx].name}」` const theme = themeMod.getCurrentTheme() this.setData({ theme, icons: iconsMod.build(theme), totalDuration: stats.totalDuration, totalDurationText: util.formatDuration(stats.totalDuration), totalSessions: stats.totalSessions, maxDuration: stats.maxDuration, maxDurationText: util.formatDuration(stats.maxDuration), totalDays, trainingBadges, badgeSegments, nextBadgeHint, monthRecords: records, historyList, listMode: this.data.listMode || 'month', displayList: (this.data.listMode || 'month') === 'month' ? records : historyList, trendData: this._computeTrend(this.data.trendMode || 'week'), loading: false }) }, onPrevMonth() { let { currentYear, currentMonth } = this.data if (currentMonth === 1) { currentYear-- currentMonth = 12 } else { currentMonth-- } this.setData({ currentYear, currentMonth }) this.refresh() }, onNextMonth() { let { currentYear, currentMonth } = this.data if (currentMonth === 12) { currentYear++ currentMonth = 1 } else { currentMonth++ } this.setData({ currentYear, currentMonth }) this.refresh() }, onToggleListMode(e) { const mode = e.currentTarget.dataset.mode if (!mode || mode === this.data.listMode) return this.setData({ listMode: mode }) this.refresh() }, onToggleTrendMode(e) { const mode = e.currentTarget.dataset.mode if (!mode || mode === this.data.trendMode) return this.setData({ trendMode: mode, trendData: this._computeTrend(mode) }) }, /** * 训练趋势数据: * week - 近 7 天每天的训练时长(秒) * month - 近 6 个月每月的训练时长(秒) * 返回 [{ label, value, valueText, highlight }] 供 trend-chart 渲染。 */ _computeTrend(mode) { const allRecords = Object.values(storage.getRecords()).flat() const today = new Date() const weekdays = ['日', '一', '二', '三', '四', '五', '六'] const pad = (n) => String(n).padStart(2, '0') if (mode === 'month') { const data = [] for (let i = 5; i >= 0; i--) { const d = new Date(today.getFullYear(), today.getMonth() - i, 1) const monthKey = `${d.getFullYear()}-${pad(d.getMonth() + 1)}` const monthRecords = storage.getRecordsByMonth(monthKey) || [] const total = monthRecords.reduce((s, r) => s + (Number(r.duration) || 0), 0) data.push({ label: `${d.getMonth() + 1}月`, value: total, valueText: total > 0 ? util.formatDuration(total) : '', highlight: i === 0 }) } return data } // default: week (近 7 天) const data = [] for (let i = 6; i >= 0; i--) { const d = new Date(today) d.setDate(d.getDate() - i) const dateStr = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` const total = allRecords .filter((r) => util.dateOnly(r.date) === dateStr) .reduce((s, r) => s + (Number(r.duration) || 0), 0) const isToday = i === 0 data.push({ label: isToday ? '今天' : weekdays[d.getDay()], value: total, valueText: total > 0 ? util.formatDuration(total) : '', highlight: isToday }) } return data }, onDayTap(e) { const { year, month, day } = e.detail const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` const allRecords = Object.values(storage.getRecords()).flat() // record.date 是 "YYYY-MM-DD HH:MM:SS"(formatDate 生成),dateStr 只有 // 日期部分,直接 === 永远不匹配,必须用 dateOnly 截取后再比。 const sessions = allRecords.filter(r => util.dateOnly(r.date) === dateStr) const totalDur = sessions.reduce((sum, r) => sum + r.duration, 0) const calories = Math.round(totalDur * 0.068) this.setData({ showDayDetail: true, dayDetail: { date: dateStr, sessions: sessions.length, duration: totalDur, durationText: util.formatDuration(totalDur), calories } }) }, onCloseDayDetail() { this.setData({ showDayDetail: false }) }, // Swallow touchmove on the popup so the background page doesn't scroll. onNoop() { /* swallow */ }, onLongPressDelete(e) { this.onDeleteRecord(e) }, onDeleteRecord(e) { const id = e.currentTarget.dataset.id if (!id && id !== 0) { wx.showToast({ title: '记录数据异常', icon: 'none', duration: 1200 }) return } wx.showModal({ title: '删除记录', content: '确定要删除这条训练记录吗?', success: (res) => { if (res.confirm) { storage.deleteRecord(id) this.refresh() wx.showToast({ title: '已删除', icon: 'none', duration: 1200 }) } } }) }, /** * 分享给朋友 — 训练记录页。把累计训练时长带进标题, * 让分享卡片有点击欲(看到对方"已经练了 X 小时"会想点开)。 */ onShareAppMessage() { const stats = storage.getTotalStats() const minutes = Math.round((stats.totalDuration || 0) / 60) const s = config.share.records return { title: s.titleTemplate.replace('{minutes}', minutes), path: s.path } }, /** * 分享到朋友圈。 */ onShareTimeline() { return { title: config.share.timelineTitle } }, /** * 点击勋章节点:显示勋章名称与解锁状态。 */ onBadgeTap(e) { const days = Number(e.currentTarget.dataset.days) const b = config.trainingDayBadges.find(x => x.days === days) if (!b) return const unlocked = this.data.totalDays >= days wx.showToast({ title: unlocked ? `已解锁:${b.name}` : `再练 ${days - this.data.totalDays} 天解锁「${b.name}」`, icon: 'none', duration: 1800 }) }, /** * 单枚勋章解锁庆祝:对比持久化的"已见解锁集合",只对新增的解锁弹 toast * 并标记 justUnlocked(节点高亮动画)。首次运行(无存储)仅初始化集合、不庆祝, * 避免老用户一打开就刷一堆历史勋章。返回新增天数集合(无新增返回 null)。 */ _checkBadgeCelebration(unlockedDays) { const KEY = 'badge_celebrated' const prev = wx.getStorageSync(KEY) if (!Array.isArray(prev)) { wx.setStorageSync(KEY, unlockedDays) return null } const { newly, seen } = getBadgeCelebration(prev, unlockedDays) wx.setStorageSync(KEY, seen) if (newly.length === 0) return null const names = newly.map(d => { const b = config.trainingDayBadges.find(x => x.days === d) return b ? b.name : (d + '天') }) wx.showToast({ title: '解锁勋章:' + names.join('、'), icon: 'none', duration: 2000 }) return new Set(newly) } })