Files
wx_pbzc/pages/settings/settings.js
T
lc b52db87ddf feat: 排行榜虚拟领奖台 + 勋章对齐与尺寸统一
- 领奖台数据不足 3 名时显示虚拟空位(虚线轮廓+虚位以待)
- 领奖台勋章与昵称对齐改为等高盒结构性修复(移除 margin 盲推)
- 全 app 成就勋章图标尺寸统一为 30rpx(前三名/列表/设置页/记录页一致)
- 勋章 SVG 按包围盒精确居中(utils/icons.js)
- 设置页/记录页勋章展示配套调整
2026-08-04 11:23:48 +08:00

599 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) {
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
}
}
})