Files
wx_pbzc/pages/settings/settings.js
T
lc 1d55df72b3 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)
2026-06-10 17:23:13 +08:00

346 lines
9.9 KiB
JavaScript

const storage = require('../../utils/storage')
const themeMod = require('../../utils/theme')
const planMod = require('../../utils/plan')
const iconsMod = require('../../utils/icons')
const cloud = require('../../utils/cloud')
/**
* Build the simplified plans list shown in the settings UI by overlaying
* user-customized plans on top of the 3 presets. Each item carries
* everything the editor needs to repopulate its form.
*/
const buildPlansList = (customPlans) => {
const presets = planMod.plans // { beginner, intermediate, advanced }
const custom = customPlans || {}
const order = ['beginner', 'intermediate', 'advanced']
return order.map((id) => {
const p = custom[id] || presets[id]
return {
id: p.id,
name: p.name,
desc: p.description,
isCustom: !!custom[id],
totalDays: p.totalDays,
startTarget: p.startTarget,
increment: p.increment,
cycleDays: p.cycleDays
}
})
}
const MAX_DAYS = 365
const MIN_DAYS = 1
const MAX_TARGET = 3600
const MIN_TARGET = 5
const MAX_INCREMENT = 300
const MAX_CYCLE = 30
const _toInt = (v, fallback) => {
const n = parseInt(v, 10)
return Number.isFinite(n) ? n : fallback
}
Page({
data: {
theme: { primary: '#FF6B35', primaryLight: '#FF8C5A', primaryBg: '#FFF3ED', primaryRgb: '255,107,53' },
themeStyle: themeMod.BASE_VARS,
themes: themeMod.THEMES,
currentThemeId: 'orange',
plans: [],
currentPlanId: 'beginner',
voiceGuide: true,
vibrate: true,
icons: iconsMod.build(),
// Editor state
showPlanEditor: false,
editingPlanId: '',
editingIsCustom: false,
editingDraft: null, // { name, totalDays, startTarget, increment, cycleDays }
editorPreview: [] // [{ day, target }, ...]
},
onLoad() {
const s = storage.getSettings()
const theme = themeMod.getCurrentTheme()
themeMod.applyThemeToPage(this)
this.setData({
currentPlanId: s.planId,
voiceGuide: s.voiceGuide,
vibrate: s.vibrate,
currentThemeId: theme.id,
theme,
icons: iconsMod.build(theme),
plans: buildPlansList(storage.getCustomPlans())
})
},
onShow() {
try {
const tb = this.getTabBar()
if (tb) tb.setData({ selected: 3 })
} catch (e) {}
themeMod.applyThemeToPage(this)
const theme = themeMod.getCurrentTheme()
this.setData({
currentThemeId: theme.id,
theme,
icons: iconsMod.build(theme),
plans: buildPlansList(storage.getCustomPlans())
})
},
onSelectTheme(e) {
const id = e.currentTarget.dataset.id
const theme = themeMod.setTheme(id)
this.setData({ currentThemeId: id })
try {
const tb = this.getTabBar()
if (tb) tb.setData({ themeStyle: themeMod.getThemeStyle(theme) })
} catch (e) {}
themeMod.applyThemeToPage(this)
},
onSelectPlan(e) {
const planId = e.currentTarget.dataset.id
const s = storage.getSettings()
if (s.planId === planId) return
wx.showModal({
title: '切换训练计划',
content: '切换计划会重置训练进度,原计划的历史记录仍会保留在训练记录中。确定继续吗?',
confirmText: '确定切换',
success: (res) => {
if (!res.confirm) return
s.planId = planId
s.planStartDate = storage.getToday()
storage.saveSettings(s)
this.setData({ currentPlanId: planId })
wx.showToast({ title: '计划已切换', icon: 'success', duration: 1200 })
}
})
},
onToggleVoice(e) {
const s = storage.getSettings()
s.voiceGuide = e.detail.value
storage.saveSettings(s)
this.setData({ voiceGuide: e.detail.value })
},
onToggleVibrate(e) {
const s = storage.getSettings()
s.vibrate = e.detail.value
storage.saveSettings(s)
this.setData({ vibrate: e.detail.value })
},
onCopyWechat() {
wx.setClipboardData({
data: '刘承',
success: () => {
wx.showToast({ title: '已复制', icon: 'success', duration: 1500 })
}
})
},
onClearData() {
wx.showModal({
title: '清除数据',
content: '将删除所有训练记录和连续打卡数据,此操作不可恢复。确定继续吗?',
confirmText: '确定清除',
confirmColor: '#E53935',
success: async (res) => {
if (!res.confirm) return
wx.removeStorageSync('training_records')
wx.removeStorageSync('current_streak')
wx.removeStorageSync('user_settings')
wx.removeStorageSync('app_theme')
wx.removeStorageSync('custom_plans')
try { await cloud.clearAll() } catch (e) {}
wx.setStorageSync('user_settings', {
planId: 'beginner',
voiceGuide: true,
vibrate: true,
planStartDate: storage.getToday()
})
wx.setStorageSync('current_streak', { count: 0, lastDate: '' })
themeMod.setTheme('orange')
this.setData({
currentPlanId: 'beginner',
voiceGuide: true,
vibrate: true,
currentThemeId: 'orange'
})
themeMod.applyThemeToPage(this)
// Reset plans list — custom_plans are gone
this.setData({ plans: buildPlansList(storage.getCustomPlans()) })
wx.showToast({ title: '已清除', icon: 'success', duration: 1500 })
}
})
},
// -------- Plan editor --------
onTapEditPlan(e) {
// stopPropagation: prevent the row's onSelectPlan from also firing
const id = e.currentTarget.dataset.id
if (!id) return
const plan = planMod.getPlan(id, storage.getCustomPlans())
const isCustom = !!storage.getCustomPlans()[id]
const draft = {
name: plan.name,
totalDays: plan.totalDays,
startTarget: plan.startTarget,
increment: plan.increment,
cycleDays: plan.cycleDays
}
this.setData({
showPlanEditor: true,
editingPlanId: id,
editingIsCustom: isCustom,
editingDraft: draft,
editorPreview: plan.days
})
},
onCloseEditor() {
this.setData({ showPlanEditor: false })
},
// Swallow bubble for inner touches (e.g. tapping the sheet body
// shouldn't dismiss the modal — only the backdrop should).
onNoop() { /* no-op */ },
_recomputePreview(draft) {
return planMod.generatePlanDays({
totalDays: draft.totalDays,
startTarget: draft.startTarget,
increment: draft.increment,
cycleDays: draft.cycleDays
})
},
onEditName(e) {
const draft = { ...this.data.editingDraft, name: e.detail.value }
this.setData({ editingDraft: draft })
},
onEditTotalDays(e) {
const draft = {
...this.data.editingDraft,
totalDays: _toInt(e.detail.value, 0)
}
this.setData({
editingDraft: draft,
editorPreview: this._recomputePreview(draft)
})
},
onEditStartTarget(e) {
const draft = {
...this.data.editingDraft,
startTarget: _toInt(e.detail.value, 0)
}
this.setData({
editingDraft: draft,
editorPreview: this._recomputePreview(draft)
})
},
onEditIncrement(e) {
const draft = {
...this.data.editingDraft,
increment: _toInt(e.detail.value, 0)
}
this.setData({
editingDraft: draft,
editorPreview: this._recomputePreview(draft)
})
},
onEditCycleDays(e) {
const draft = {
...this.data.editingDraft,
cycleDays: _toInt(e.detail.value, 0)
}
this.setData({
editingDraft: draft,
editorPreview: this._recomputePreview(draft)
})
},
_validateDraft(draft) {
if (!draft.name || !draft.name.trim()) return '请填写计划名称'
if (draft.totalDays < MIN_DAYS || draft.totalDays > MAX_DAYS) {
return `总天数需在 ${MIN_DAYS}-${MAX_DAYS} 之间`
}
if (draft.startTarget < MIN_TARGET || draft.startTarget > MAX_TARGET) {
return `起始时长需在 ${MIN_TARGET}-${MAX_TARGET} 秒之间`
}
if (draft.increment < 0 || draft.increment > MAX_INCREMENT) {
return `每次增加需在 0-${MAX_INCREMENT} 秒之间`
}
if (draft.cycleDays < 1 || draft.cycleDays > MAX_CYCLE) {
return `每几天增加需在 1-${MAX_CYCLE} 之间`
}
return null
},
onSavePlan() {
const { editingPlanId, editingDraft } = this.data
if (!editingPlanId || !editingDraft) return
const err = this._validateDraft(editingDraft)
if (err) {
wx.showToast({ title: err, icon: 'none', duration: 1800 })
return
}
const planData = {
id: editingPlanId,
name: editingDraft.name.trim(),
totalDays: editingDraft.totalDays,
startTarget: editingDraft.startTarget,
increment: editingDraft.increment,
cycleDays: editingDraft.cycleDays,
days: this._recomputePreview(editingDraft),
description: `${editingDraft.totalDays}天计划 · ${editingDraft.startTarget}秒起步`
}
storage.saveCustomPlan(editingPlanId, planData)
this.setData({
showPlanEditor: false,
plans: buildPlansList(storage.getCustomPlans())
})
wx.showToast({ title: '已保存', icon: 'success', duration: 1200 })
},
onResetPlan() {
const { editingPlanId } = this.data
if (!editingPlanId) return
wx.showModal({
title: '恢复默认',
content: '将恢复此计划的默认设置,自定义内容会丢失。确定继续吗?',
confirmText: '确定恢复',
confirmColor: '#E53935',
success: (res) => {
if (!res.confirm) return
storage.resetCustomPlan(editingPlanId)
// Reopen editor pointed at the preset version
const preset = planMod.getPlan(editingPlanId)
const draft = {
name: preset.name,
totalDays: preset.totalDays,
startTarget: preset.startTarget,
increment: preset.increment,
cycleDays: preset.cycleDays
}
this.setData({
editingIsCustom: false,
editingDraft: draft,
editorPreview: preset.days,
plans: buildPlansList(storage.getCustomPlans())
})
wx.showToast({ title: '已恢复默认', icon: 'success', duration: 1200 })
}
})
}
})