187c426243
- 移除全项目 34 处调试 console.* 输出 - project.config.json: minified true、uploadWithSourceMap false、启用 lazyCodeLoading(requiredComponents 组件按需注入) - pages/records/records.json 补全 ui-skeleton 组件声明(此前漏声明,WXML 已使用) - 删除根目录残留 preview-buttons.html
596 lines
18 KiB
JavaScript
596 lines
18 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: '',
|
||
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) {
|
||
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
|
||
}
|
||
}
|
||
})
|