c5ae332cbe
- 勋章图标从 emoji 改为矢量 SVG 线性图标(utils/icons.js 新增 bolt/moon/diamond 路径及 white 变体) - 命名改为累计语义(一周累计/半月累计/月度累计/双月累计/百日累计),并标注"非连续打卡" - 进度条改回等距节点,解锁时填充正好抵达节点、满级满格 - 新增单枚解锁庆祝:持久化已见解锁集合,进入页面弹 toast + 节点 pop 动画 - 已解锁节点常驻显示勋章名 + 右上角 ✓ 角标,直观体现"已拥有" - utils/storage.js 新增 totalDays(累计去重训练日),为勋章统计提供数据源
334 lines
11 KiB
JavaScript
334 lines
11 KiB
JavaScript
const storage = require('../../utils/storage')
|
|
const util = require('../../utils/util')
|
|
const themeMod = require('../../utils/theme')
|
|
const iconsMod = require('../../utils/icons')
|
|
const config = require('../../config')
|
|
|
|
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: [],
|
|
badgeProgress: 0,
|
|
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 n = badgeDefs.length
|
|
const maxDays = badgeDefs[n - 1].days
|
|
const TRACK_PAD = 4 // 首尾留白,保证首尾节点/标签不溢出
|
|
const segWidth = n > 1 ? (100 - TRACK_PAD * 2) / (n - 1) : 0
|
|
const posOf = (i) => TRACK_PAD + i * segWidth
|
|
const trainingBadges = badgeDefs.map((b, i) => ({
|
|
...b,
|
|
unlocked: totalDays >= b.days,
|
|
pos: Math.round(posOf(i) * 10) / 10
|
|
}))
|
|
let badgeProgress
|
|
if (totalDays <= 0) {
|
|
badgeProgress = 0
|
|
} else if (totalDays >= maxDays) {
|
|
badgeProgress = 100
|
|
} else {
|
|
let i = 0
|
|
while (i < n - 1 && totalDays >= badgeDefs[i + 1].days) i++
|
|
const ratio = (totalDays - badgeDefs[i].days) / (badgeDefs[i + 1].days - badgeDefs[i].days)
|
|
badgeProgress = Math.round((posOf(i) + ratio * segWidth) * 10) / 10
|
|
}
|
|
|
|
// 单枚解锁庆祝反馈:对比持久化"已见解锁集合",只庆祝新增勋章。
|
|
// 首次运行(无存储)仅初始化集合,避免老用户一打开就被历史勋章刷屏。
|
|
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,
|
|
badgeProgress,
|
|
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 prevSet = new Set(prev)
|
|
const newly = unlockedDays.filter(d => !prevSet.has(d))
|
|
wx.setStorageSync(KEY, unlockedDays)
|
|
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)
|
|
}
|
|
})
|