Files
wx_pbzc/pages/records/records.js
T
lc 3532d0111b feat: 循环训练模式 + 语音补全 / 屏幕常亮 / 卡片渲染修复
循环训练(circuit)
- 新增 utils/circuitTimer.js 状态机: 工作->休息->...->完成, stop() 返回有效撑总秒(不含休息)
- 首页新增「循环训练·分组练习」入口 + stepper 配置弹窗
- 配置本地持久化 circuit_config(每组时长/组数/休息/每周目标), 下次自动预填
- 记录以 planId:'circuit' + mode:'circuit' 落库, duration 语义不变 -> 排行榜/统计/每日计划进度零改动
- 记录页展示「循环 N组xMs·休Rs」

计时器顶部循环进度面板
- 组进度点阵按组数等分铺满(flex:1 1 0, 去掉 max-width 封顶), 当前组以增高而非加宽强调
- 大号「X/Y 组」+ 四态阶段徽章(撑住/休息中/准备开始/已暂停) + 累计秒数副行
- 状态灯用纯 CSS 圆点(SVG 图标颜色烤死在 data URI 里, CSS 改不动)
- 超过 15 组自动降级为线性进度条; 面板不设固定高度, 规避真机大字体档裁字

修复: 循环训练全程无语音
- 根因: circuit 的 onTick 走 _circuitCue(只有震动), 从未调用 _remind
- 接入整场进度播报, 基准为 sets x holdPerSet(而非每组), 避免 4 组播 4 次「已完成一半」
- 新增 restStart/nextSet/lastSet 三条不含数字的词条, 客户端与 tts 云函数词表同步
- 修复必然撞车的时序: 偶数组时 halfway 与休息切换同一 tick, 加 2.6s 优先窗口让位
- 防吵闸门: 每组 <10s 不播过场语音, 休息 <5s 不播; 预载按模式取词条

训练期间屏幕常亮
- wx.setKeepScreenOn: onStart 开 / onUnload 关 / onShow 在 isRunning 时重新武装
- 切后台再回来该标志会被系统清掉, 故必须重新武装, 否则后半程丢语音与震动
- 低基础库与 Android 省电模式静默降级, 不弹 toast

修复: 首页打卡卡片左侧两个竖条
- 根因: ui-card 宿主节点无 display 声明为 inline, 页面侧加的 border 被打断成两个零宽行盒碎片
- ui-card 加 :host{display:block}, margin-bottom 从内部 view 上提到宿主
- 高亮由 border 改为 box-shadow spread 描边(不占布局, 无跳动) + 补 border-radius
- 连带修复失效的交错入场: nth-child 跨组件边界恒匹配 1, 改为 index 属性驱动内联 animation-delay, 14 处调用点补序号

注意: tts 云函数需重新部署, 否则新增的 3 条词条返回 Unknown prompt key(静默降级不报错)
2026-08-03 16:34:22 +08:00

323 lines
10 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')
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',
contentMode: 'records',
trendMode: 'week',
trendData: [],
trendSummary: {},
bestTrendData: [],
bestTrendSummary: {},
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() {
const streak = storage.validateStreak()
const stats = storage.getTotalStats()
const monthKey = `${this.data.currentYear}-${String(this.data.currentMonth).padStart(2, '0')}`
// Decorate each record with a display string for circuit (循环) sessions.
const withText = (r) => {
const base = { ...r, durationText: util.formatTime(r.duration) }
if (r.mode === 'circuit') {
const rest = r.restPerSet ? ` · 休${r.restPerSet}s` : ''
base.circuitText = `循环 ${r.sets}组×${r.holdPerSet}s${rest}`
}
return base
}
const records = (storage.getRecordsByMonth(monthKey) || []).map(withText)
// 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(withText(r))
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()
const trendMode = this.data.trendMode || 'week'
const trendData = this._computeTrend(trendMode)
const bestTrendData = this._computeBestTrend(trendMode)
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,
trendSummary: { ...util.getTrendSummary(trendData, trendMode), streakCount: Number(streak.count) || 0 },
bestTrendData,
bestTrendSummary: util.getTrendSummary(bestTrendData, trendMode),
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()
},
onToggleContentMode(e) {
const mode = e.currentTarget.dataset.mode
if (!['records', 'trend'].includes(mode) || mode === this.data.contentMode) return
this.setData({ contentMode: mode })
},
onToggleTrendMode(e) {
const mode = e.currentTarget.dataset.mode
if (!mode || mode === this.data.trendMode) return
const trendData = this._computeTrend(mode)
const bestTrendData = this._computeBestTrend(mode)
this.setData({
trendMode: mode,
trendData,
trendSummary: { ...util.getTrendSummary(trendData, mode), streakCount: Number(storage.getStreak().count) || 0 },
bestTrendData,
bestTrendSummary: util.getTrendSummary(bestTrendData, mode)
})
},
/**
* 训练趋势数据:
* week - 近 7 天每天的训练时长(秒)
* month - 近 6 个月每月的训练时长(秒)
* 返回 [{ label, value, valueText, highlight }] 供 trend-chart 渲染。
*/
_computeTrend(mode) {
return util.getTrendData(storage.getRecords(), mode)
},
_computeBestTrend(mode) {
return util.getBestTrendData(storage.getRecords(), mode)
},
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)
}
})