Files
wx_pbzc/pages/settings/settings.js
T
lc 187c426243 chore: 上线前优化 — 清理调试日志/启用组件按需注入/补全组件声明
- 移除全项目 34 处调试 console.* 输出
- project.config.json: minified true、uploadWithSourceMap false、启用 lazyCodeLoading(requiredComponents 组件按需注入)
- pages/records/records.json 补全 ui-skeleton 组件声明(此前漏声明,WXML 已使用)
- 删除根目录残留 preview-buttons.html
2026-08-04 14:05:16 +08:00

596 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 生成的主题色变体 keyicons.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) {
try {
permanentUrl = await util.fileToDataURI(avatarUrl)
} catch (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
}
// 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
}
}
})