feat: v1.5 — plan editor, voice prompts, UI polish

Major features:
- Training plan editor: edit preset days/duration per plan (in-place
  override via custom_plans storage; preset ids preserved so existing
  records stay valid)
- Voice prompts at 4 fixed points during training (halfway / 30s /
  10s / done) via Tencent Cloud TTS (cloudfunctions/tts + utils/voice.js)
- Free-training button: switched from outline (transparent) to ghost
  variant (theme-tinted) for visual weight

Bug fixes:
- 4 functional pages: SVG data URIs failed to render in WeChat because
  '\#' in colors was parsed as a data-URI fragment delimiter. encode the
  whole SVG via encodeURIComponent in utils/icons.js build().
- Old records (saved before id field was added) could not be deleted
  (data-id was empty, triggered defensive guard). Backfill stable
  legacy-<month>-<index>-<duration> ids in getRecords() and persist.
- settings.js plan-row editor button event was bubbling up to the row's
  bindtap (which also fired onSelectPlan). Wrapped in catch:tap.

UI:
- Settings page: 3 hardcoded plans replaced with dynamic buildPlansList
  that overlays custom_plans on top of presets
- Plan editor: bottom-sheet modal in settings page (regular view, not
  ui-modal — WeChat custom component root element drops position:fixed
  in this runtime)
- Free-training button: rounded pill style, full-width, 24rpx gap from
  primary action; bottom sheet uses max-height: 88vh + internal scroll
- Version bumped to v1.5, last updated 2026-06-10

Removed:
- Daily reminder section (dailyReminder / reminderTime) — replaced by
  the 4 voice prompts which cover the same user need without requiring
  long-term scheduling that WeChat mini-programs can't actually do

Misc:
- utils/plan.js refactored to formula-driven: presets declare
  totalDays/startTarget/increment/cycleDays, days[] is generated. Same
  formula applies to custom plans.
- Timer _remind() guards each prompt on minimum duration so short
  free-mode sessions don't fire 'last30' at the start
- Cloud storage cloud_plans field added to data push payload; restored
  on first install via _restoreFromCloud
- .gitignore added for local AI tool caches (.reasonix/, reasonix.toml,
  .codegraph/daemon.pid)
This commit is contained in:
2026-06-10 17:23:13 +08:00
parent 90e0e64156
commit 1d55df72b3
54 changed files with 2710 additions and 934 deletions
+48 -8
View File
@@ -1,6 +1,7 @@
const storage = require('../../utils/storage')
const util = require('../../utils/util')
const themeMod = require('../../utils/theme')
const iconsMod = require('../../utils/icons')
Page({
data: {
@@ -16,12 +17,13 @@ Page({
monthRecords: [],
historyList: [],
showDayDetail: false,
dayDetail: {}
dayDetail: {},
icons: iconsMod.build()
},
onLoad() {
themeMod.applyThemeToPage(this)
this.refresh()
this._firstShow = true
},
onShow() {
@@ -30,6 +32,11 @@ Page({
const tb = this.getTabBar()
if (tb) tb.setData({ selected: 1 })
} catch (e) {}
if (this._firstShow) {
this._firstShow = false
this.refresh()
return
}
this.refresh()
},
@@ -39,21 +46,35 @@ Page({
},
refresh() {
storage.validateStreak()
const stats = storage.getTotalStats()
const monthKey = `${this.data.currentYear}-${String(this.data.currentMonth).padStart(2, '0')}`
const records = storage.getRecordsByMonth(monthKey)
const allRecords = Object.values(storage.getRecords()).flat()
allRecords.sort((a, b) => b.date.localeCompare(a.date))
// 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)
if (historyList.length >= 30) break
}
if (historyList.length >= 30) break
}
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),
monthRecords: records,
historyList: allRecords.slice(0, 30)
historyList
})
},
@@ -84,12 +105,10 @@ Page({
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()
const sessions = allRecords.filter(r => 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: {
@@ -106,10 +125,31 @@ Page({
this.setData({ showDayDetail: false })
},
noop() {},
onLongPressDelete(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 })
}
}
})
},
onDeleteRecord(e) {
const id = e.currentTarget.dataset.id
if (!id && id !== 0) {
wx.showToast({ title: '记录数据异常', icon: 'none', duration: 1200 })
return
}
wx.showModal({
title: '删除记录',
content: '确定要删除这条训练记录吗?',