Files
wx_pbzc/pages/settings/settings.js
T
2026-07-29 11:03:44 +08:00

580 lines
17 KiB
JavaScript

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: '',
// 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()
})
},
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')
// 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
}
}
})