const storage = require('../../utils/storage') const themeMod = require('../../utils/theme') const darkMod = require('../../utils/darkMode') const planMod = require('../../utils/plan') const iconsMod = require('../../utils/icons') const cloud = require('../../utils/cloud') const config = require('../../config') const util = require('../../utils/util') /** * 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, voiceGender: 'female', voiceGenderOptions: [ { key: 'female', name: '女声' }, { key: 'male', name: '男声' } ], vibrateIntensity: 'heavy', vibrateIntensityOptions: [ { key: 'light', name: '短促' }, { key: 'medium', name: '标准' }, { key: 'heavy', name: '强烈' } ], icons: iconsMod.build(), nickName: '', avatarUrl: '', unlockedBadges: [], // Dark mode darkModePref: 'system', darkModeOptions: [ { key: 'system', name: '跟随系统' }, { key: 'light', name: '浅色' }, { key: 'dark', name: '深色' } ], // Editor state showPlanEditor: false, editingPlanId: '', editingIsCustom: false, editingDraft: null, // { name, totalDays, startTarget, increment, cycleDays } editorPreview: [], // [{ day, target }, ...] // Clear-data confirmation showClearConfirm: false, // App info from config appVersion: config.version, appUpdatedAt: config.updatedAt, appDeveloper: config.developer, // 公众号名称(来自 config,关于页直接展示) appOfficialAccount: config.officialAccount }, onLoad() { const s = storage.getSettings() const theme = themeMod.getCurrentTheme() themeMod.applyThemeToPage(this) const profile = storage.getProfile() this.setData({ currentPlanId: s.planId, voiceGuide: s.voiceGuide, vibrate: s.vibrate, voiceGender: s.voiceGender || 'female', vibrateIntensity: s.vibrateIntensity || 'heavy', currentThemeId: theme.id, theme, icons: iconsMod.build(theme), plans: buildPlansList(storage.getCustomPlans()), nickName: profile.nickname || '', avatarUrl: profile.avatarUrl || '' }) }, onShow() { try { const tb = this.getTabBar() if (tb) tb.setData({ selected: 3 }) } catch (e) {} themeMod.applyThemeToPage(this) const theme = themeMod.getCurrentTheme() const profile = storage.getProfile() this.setData({ currentThemeId: theme.id, theme, icons: iconsMod.build(theme), plans: buildPlansList(storage.getCustomPlans()), nickName: profile.nickname || '', avatarUrl: profile.avatarUrl || '', darkModePref: darkMod.getPref(), // 已解锁勋章:个人资料昵称旁展示(主题色版,浅底可见) unlockedBadges: this._getUnlockedBadges() }) }, /** * 已解锁勋章列表 = 累计去重训练日达到阈值的 badge。 * iconFill 是 icons.build 生成的主题色变体 key(icons.js PATHS.*Fill)。 */ _getUnlockedBadges() { try { const totalDays = storage.getTotalStats().totalDays return config.trainingDayBadges .filter(b => totalDays >= b.days) .map(b => ({ ...b, iconFill: b.icon + 'Fill' })) } catch (e) { return [] } }, applyThemeToPage() { themeMod.applyThemeToPage(this) }, onSelectDarkMode(e) { const key = e.currentTarget.dataset.key darkMod.setPref(key) this.setData({ darkModePref: key }) // 通知所有已打开页面重渲染 const pages = getCurrentPages() pages.forEach(p => { if (p && typeof p.applyThemeToPage === 'function') { p.applyThemeToPage() } else if (p && p.route) { // Tab page without method: re-apply via themeMod try { themeMod.applyThemeToPage(p) } catch (e) {} } // 自定义 tabBar 不在 pages 里,单独通知它切换暗黑/明亮 try { const tb = p.getTabBar && p.getTabBar() if (tb && typeof tb.updateTheme === 'function') tb.updateTheme() } catch (e) {} }) themeMod.applyThemeToPage(this) }, // --- Profile handlers --- async onChooseAvatar(e) { const avatarUrl = e.detail.avatarUrl if (!avatarUrl) return // Store the avatar durably. Prefer a cloud-storage fileID: it keeps the // profile doc small and avoids re-transferring the image bytes on every // pushAll. Fall back to an inline base64 data URI if cloud storage is // unavailable, and to the raw temp URL as a last resort. let permanentUrl = null try { permanentUrl = await this._uploadAvatar(avatarUrl) } catch (err) { console.warn('[avatar] cloud upload failed, trying base64:', err) try { permanentUrl = await util.fileToDataURI(avatarUrl) } catch (err2) { console.warn('[avatar] base64 convert failed, saving temp URL:', err2) permanentUrl = avatarUrl } } this.setData({ avatarUrl: permanentUrl }) const saved = storage.saveProfile({ nickname: this.data.nickName, avatarUrl: permanentUrl }) this.setData({ nickName: saved.nickname || this.data.nickName, avatarUrl: saved.avatarUrl }) wx.showToast({ title: '头像已更新', icon: 'success', duration: 1200 }) }, /** * Upload the chosen avatar to cloud storage and return its fileID. * Throws if cloud is disabled or the upload fails - caller falls back. */ async _uploadAvatar(filePath) { if (!cloud.enabled) throw new Error('cloud not enabled') // Shrink the chosen avatar (chooseAvatar returns a full-res PNG) before // upload — a 200px-wide file is tens of KB vs the original's MB, which // is what lets remote avatars load in well under a second on the // leaderboard. Falls back to the original file if compression is // unavailable (e.g. old base lib / devtools) so upload never breaks. const compressed = await this._compressAvatar(filePath) const cloudPath = `avatar/${Date.now()}-${Math.floor(Math.random() * 1e6)}.png` const res = await wx.cloud.uploadFile({ cloudPath, filePath: compressed }) if (!res || !res.fileID) throw new Error('uploadFile returned no fileID') return res.fileID }, /** * Compress an avatar temp file to ~200px wide before upload. Uses * wx.compressImage (base lib 2.21.0+ honours `compressedWidth`; older * libs ignore it and just apply `quality`, still a win). Resolves to the * original path on any failure so the caller degrades gracefully. */ _compressAvatar(filePath) { return new Promise((resolve) => { if (!wx.compressImage || !filePath) { resolve(filePath); return } wx.compressImage({ src: filePath, quality: 80, compressedWidth: 200, success: (res) => resolve((res && res.tempFilePath) || filePath), fail: () => resolve(filePath) }) }) }, onNicknameBlur(e) { const value = (e.detail.value || '').trim() if (!value) return this.setData({ nickName: value }) storage.saveProfile({ nickname: value, avatarUrl: this.data.avatarUrl }) wx.showToast({ title: '昵称已保存', icon: 'success', duration: 1200 }) }, 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, darkMod.getInitialDarkMode()) }) } 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 }) }, onSelectVoiceGender(e) { const key = e.currentTarget.dataset.key if (key === this.data.voiceGender) return const s = storage.getSettings() s.voiceGender = key storage.saveSettings(s) this.setData({ voiceGender: key }) }, onSelectVibrateIntensity(e) { const key = e.currentTarget.dataset.key if (key === this.data.vibrateIntensity) return const s = storage.getSettings() s.vibrateIntensity = key storage.saveSettings(s) this.setData({ vibrateIntensity: key }) }, onCopyWechat() { wx.setClipboardData({ data: config.developer, success: () => { wx.showToast({ title: '已复制', icon: 'success', duration: 1500 }) } }) }, // Copy the official account name so the user can paste it into WeChat search onCopyOfficialAccount() { if (!config.officialAccount) return wx.setClipboardData({ data: config.officialAccount, success: () => { wx.showToast({ title: '已复制,去微信搜索关注', icon: 'none', duration: 1800 }) } }) }, // Show custom confirmation instead of wx.showModal to avoid the dev-tools // bug where the native dialog refuses to dismiss on first tap. onClearData() { this.setData({ showClearConfirm: true }) }, onCancelClear() { this.setData({ showClearConfirm: false }) }, onConfirmClear() { this.setData({ showClearConfirm: false }) this._doClearData() }, async _doClearData() { // 1. Cancel any pending push BEFORE clearing, to prevent the // scheduled _doPush from reading the freshly-reset defaults // and recreating the cloud doc via add(). cloud.cancelPendingPush() // 2. Clear all local keys (including user_profile which was // previously missed) wx.removeStorageSync('training_records') wx.removeStorageSync('current_streak') wx.removeStorageSync('user_settings') wx.removeStorageSync('app_theme') wx.removeStorageSync('custom_plans') wx.removeStorageSync('user_profile') wx.removeStorageSync('badge_celebrated') // 3. Clear every cloud doc for this openid. Local data is already gone // even if this fails, so surface that partial failure honestly. let cloudClearFailed = false try { await cloud.clearAll() } catch (e) { cloudClearFailed = true console.warn('[settings] cloud clear failed:', e) } // 4. Reinstate default settings and streak wx.setStorageSync('user_settings', { planId: 'beginner', voiceGuide: true, vibrate: true, planStartDate: storage.getToday() }) wx.setStorageSync('current_streak', { count: 0, lastDate: '' }) // 5. Reset theme and UI (include nickName/avatarUrl to clear // profile state now that user_profile is gone) themeMod.setTheme('orange') this.setData({ currentPlanId: 'beginner', voiceGuide: true, vibrate: true, voiceGender: 'female', vibrateIntensity: 'heavy', currentThemeId: 'orange', nickName: '', avatarUrl: '' }) themeMod.applyThemeToPage(this) this.setData({ plans: buildPlansList(storage.getCustomPlans()) }) wx.showToast({ title: cloudClearFailed ? '本地已清除,云端未清除' : '已清除', icon: cloudClearFailed ? 'none' : 'success', duration: cloudClearFailed ? 2200 : 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 }) } }) }, /** * 分享给朋友 — 设置页(很少有人会从这里分享,但微信要求每个 Page 都 * 实现钩子,否则对应页面的右上角菜单的转发按钮就是灰色的)。 */ onShareAppMessage() { const s = config.share.settings return { title: s.title, path: s.path } }, /** * 分享到朋友圈。 */ onShareTimeline() { return { title: config.share.timelineTitle } } })