d5121d349f
- theme/app.wxss: 暗黑背景 #0F0F12->#2C2C2E,卡片 #1C1C1E->#3A3A3C, bg-soft/border 同步提亮,拉开卡片与背景对比(原太黑看不到卡片) - custom-tab-bar: dock 注册 wx.onThemeChange,系统切暗黑/明亮实时跟随 (组件不在 getCurrentPages,app.js watchDarkMode 通知不到) - custom-tab-bar: 新增 updateTheme,settings 手动切深色时通知 dock - custom-tab-bar.wxss: dock 图标 44->50rpx,文字 20->23rpx,未激活 opacity 0.5->0.7(更醒目) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
509 lines
15 KiB
JavaScript
509 lines
15 KiB
JavaScript
const storage = require('../../utils/storage')
|
|
const themeMod = require('../../utils/theme')
|
|
const planMod = require('../../utils/plan')
|
|
const iconsMod = require('../../utils/icons')
|
|
const cloud = require('../../utils/cloud')
|
|
const config = require('../../config')
|
|
const darkMod = require('../../utils/darkMode')
|
|
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
|
|
},
|
|
|
|
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')
|
|
const cloudPath = `avatar/${Date.now()}-${Math.floor(Math.random() * 1e6)}.png`
|
|
const res = await wx.cloud.uploadFile({ cloudPath, filePath })
|
|
if (!res || !res.fileID) throw new Error('uploadFile returned no fileID')
|
|
return res.fileID
|
|
},
|
|
|
|
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) })
|
|
} 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 })
|
|
}
|
|
})
|
|
},
|
|
|
|
// 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 cloud doc (awaited — order matters: clear cloud
|
|
// after cancelling push, before resetting defaults)
|
|
try { await cloud.clearAll() } catch (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,
|
|
currentThemeId: 'orange',
|
|
nickName: '',
|
|
avatarUrl: ''
|
|
})
|
|
themeMod.applyThemeToPage(this)
|
|
this.setData({ plans: buildPlansList(storage.getCustomPlans()) })
|
|
wx.showToast({ title: '已清除', icon: 'success', duration: 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 })
|
|
}
|
|
})
|
|
}
|
|
})
|