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:
+262
-39
@@ -1,5 +1,44 @@
|
||||
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: {
|
||||
@@ -7,16 +46,17 @@ Page({
|
||||
themeStyle: themeMod.BASE_VARS,
|
||||
themes: themeMod.THEMES,
|
||||
currentThemeId: 'orange',
|
||||
plans: [
|
||||
{ id: 'beginner', name: '初级', desc: '7天计划 · 30秒起步', days: 7 },
|
||||
{ id: 'intermediate', name: '中级', desc: '14天计划 · 60秒起步', days: 14 },
|
||||
{ id: 'advanced', name: '高级', desc: '30天计划 · 90秒起步', days: 30 }
|
||||
],
|
||||
plans: [],
|
||||
currentPlanId: 'beginner',
|
||||
dailyReminder: true,
|
||||
reminderTime: '08:00',
|
||||
voiceGuide: true,
|
||||
vibrate: true
|
||||
vibrate: true,
|
||||
icons: iconsMod.build(),
|
||||
// Editor state
|
||||
showPlanEditor: false,
|
||||
editingPlanId: '',
|
||||
editingIsCustom: false,
|
||||
editingDraft: null, // { name, totalDays, startTarget, increment, cycleDays }
|
||||
editorPreview: [] // [{ day, target }, ...]
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
@@ -25,58 +65,59 @@ Page({
|
||||
themeMod.applyThemeToPage(this)
|
||||
this.setData({
|
||||
currentPlanId: s.planId,
|
||||
dailyReminder: s.dailyReminder,
|
||||
reminderTime: s.reminderTime,
|
||||
voiceGuide: s.voiceGuide,
|
||||
vibrate: s.vibrate,
|
||||
currentThemeId: theme.id
|
||||
currentThemeId: theme.id,
|
||||
theme,
|
||||
icons: iconsMod.build(theme),
|
||||
plans: buildPlansList(storage.getCustomPlans())
|
||||
})
|
||||
},
|
||||
|
||||
onShow() {
|
||||
try {
|
||||
const tb = this.getTabBar()
|
||||
if (tb) tb.setData({ selected: 2 })
|
||||
if (tb) tb.setData({ selected: 3 })
|
||||
} catch (e) {}
|
||||
themeMod.applyThemeToPage(this)
|
||||
// sync highlighted theme in case it was changed elsewhere
|
||||
this.setData({ currentThemeId: themeMod.getCurrentTheme().id })
|
||||
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()
|
||||
s.planId = planId
|
||||
storage.saveSettings(s)
|
||||
this.setData({ currentPlanId: planId })
|
||||
wx.showToast({ title: '计划已切换', icon: 'success', duration: 1200 })
|
||||
},
|
||||
if (s.planId === planId) return
|
||||
|
||||
onToggleReminder(e) {
|
||||
const s = storage.getSettings()
|
||||
s.dailyReminder = e.detail.value
|
||||
storage.saveSettings(s)
|
||||
this.setData({ dailyReminder: e.detail.value })
|
||||
},
|
||||
|
||||
onReminderTimeChange(e) {
|
||||
const s = storage.getSettings()
|
||||
s.reminderTime = e.detail.value
|
||||
storage.saveSettings(s)
|
||||
this.setData({ reminderTime: e.detail.value })
|
||||
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) {
|
||||
@@ -108,14 +149,196 @@ Page({
|
||||
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) {
|
||||
wx.removeStorageSync('training_records')
|
||||
wx.removeStorageSync('current_streak')
|
||||
// also clear cloud data
|
||||
try { require('../../utils/cloud').clearAll() } catch (e) {}
|
||||
wx.showToast({ title: '已清除', icon: 'success', duration: 1500 })
|
||||
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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user