fix: 振动定时器清理、完成保存守卫、暗黑冷启动、头像云存储
- timer: 振动 setTimeout 入栈追踪,暂停/停止/卸载/重练统一清理, 避免页面销毁后残留震动(_clearVibrateTimers) - timer: _saving 守卫统一到 _saveAndShowCompletion,自然完成与手动 结束两路径都防重复保存;修复 onTrainAgain 不重置会话状态导致 再练一次后第二次无语音/振动且保存被挡 - app.wxss: @media dark 补全 --text/--card-bg/--bg-soft/--border/ --success/--danger,冷启动即为完整深色,不再背景深卡片白的割裂闪烁 - settings: 头像优先上传云存储存 fileID,三级回退(fileID->base64-> 临时URL),pushAll 不再每次传输图片字节 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,12 +16,24 @@ page {
|
|||||||
transition: background-color 0.3s ease, color 0.3s ease;
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* dark mode overrides (only for system dark mode; user choice goes through themeStyle) */
|
/* dark mode overrides (only for system dark mode; user choice goes through
|
||||||
|
themeStyle at runtime). Kept in sync with the dark palette in
|
||||||
|
utils/theme.js getThemeStyle(isDark=true) so cold-start (before JS runs)
|
||||||
|
already shows a complete dark palette, not just the background. */
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
page {
|
page {
|
||||||
|
--text: #E5E5E7;
|
||||||
|
--text-secondary: #98989F;
|
||||||
--bg: #0F0F12;
|
--bg: #0F0F12;
|
||||||
--bg-gradient-start: #0F0F12;
|
--bg-gradient-start: #0F0F12;
|
||||||
--bg-gradient-end: #14141A;
|
--bg-gradient-end: #14141A;
|
||||||
|
--card-bg: #1C1C1E;
|
||||||
|
--bg-soft: #2C2C2E;
|
||||||
|
--border: #2C2C2E;
|
||||||
|
--success: #30D158;
|
||||||
|
--success-rgb: 48,209,88;
|
||||||
|
--danger: #FF6B6B;
|
||||||
|
--danger-rgb: 255,107,107;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+29
-14
@@ -140,11 +140,24 @@ Page({
|
|||||||
async onChooseAvatar(e) {
|
async onChooseAvatar(e) {
|
||||||
const avatarUrl = e.detail.avatarUrl
|
const avatarUrl = e.detail.avatarUrl
|
||||||
if (!avatarUrl) return
|
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 {
|
try {
|
||||||
// chooseAvatar returns a wxfile:// temporary URL that expires in a few
|
permanentUrl = await this._uploadAvatar(avatarUrl)
|
||||||
// days. Read the bytes and store as a data URI so the avatar survives
|
} catch (err) {
|
||||||
// long-term. Falls back to the raw URL on size/read error.
|
console.warn('[avatar] cloud upload failed, trying base64:', err)
|
||||||
const permanentUrl = await util.fileToDataURI(avatarUrl)
|
try {
|
||||||
|
permanentUrl = await util.fileToDataURI(avatarUrl)
|
||||||
|
} catch (err2) {
|
||||||
|
console.warn('[avatar] base64 convert failed, saving temp URL:', err2)
|
||||||
|
permanentUrl = avatarUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.setData({ avatarUrl: permanentUrl })
|
this.setData({ avatarUrl: permanentUrl })
|
||||||
const saved = storage.saveProfile({
|
const saved = storage.saveProfile({
|
||||||
nickname: this.data.nickName,
|
nickname: this.data.nickName,
|
||||||
@@ -155,16 +168,18 @@ Page({
|
|||||||
avatarUrl: saved.avatarUrl
|
avatarUrl: saved.avatarUrl
|
||||||
})
|
})
|
||||||
wx.showToast({ title: '头像已更新', icon: 'success', duration: 1200 })
|
wx.showToast({ title: '头像已更新', icon: 'success', duration: 1200 })
|
||||||
} catch (err) {
|
},
|
||||||
console.warn('avatar convert failed, saving temp URL:', err)
|
|
||||||
// Fallback: save the raw wxfile URL. Will eventually expire.
|
/**
|
||||||
this.setData({ avatarUrl })
|
* Upload the chosen avatar to cloud storage and return its fileID.
|
||||||
storage.saveProfile({
|
* Throws if cloud is disabled or the upload fails - caller falls back.
|
||||||
nickname: this.data.nickName,
|
*/
|
||||||
avatarUrl
|
async _uploadAvatar(filePath) {
|
||||||
})
|
if (!cloud.enabled) throw new Error('cloud not enabled')
|
||||||
wx.showToast({ title: '头像已更新', icon: 'success', duration: 1200 })
|
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) {
|
onNicknameBlur(e) {
|
||||||
|
|||||||
+39
-3
@@ -94,6 +94,7 @@ Page({
|
|||||||
this._lastMinuteAt = 0
|
this._lastMinuteAt = 0
|
||||||
this._lastCountdown = 0
|
this._lastCountdown = 0
|
||||||
this._saving = false
|
this._saving = false
|
||||||
|
this._clearVibrateTimers()
|
||||||
|
|
||||||
this._timer = new Timer({
|
this._timer = new Timer({
|
||||||
onTick: (tick) => {
|
onTick: (tick) => {
|
||||||
@@ -135,7 +136,14 @@ Page({
|
|||||||
if (!s.vibrate) return
|
if (!s.vibrate) return
|
||||||
try {
|
try {
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
setTimeout(() => wx.vibrateShort(), i * (ms + 30))
|
// Track timers so pause/stop/unload can cancel pending bursts
|
||||||
|
// instead of firing vibrations on a page that's already torn down.
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
wx.vibrateShort()
|
||||||
|
const idx = this._vibrateTimers.indexOf(id)
|
||||||
|
if (idx > -1) this._vibrateTimers.splice(idx, 1)
|
||||||
|
}, i * (ms + 30))
|
||||||
|
this._vibrateTimers.push(id)
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
@@ -174,6 +182,19 @@ Page({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel any pending vibration timers. Tracked so pause/stop/unload can
|
||||||
|
* abort scheduled bursts instead of firing on a torn-down page.
|
||||||
|
*/
|
||||||
|
_clearVibrateTimers() {
|
||||||
|
if (!this._vibrateTimers) {
|
||||||
|
this._vibrateTimers = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this._vibrateTimers.forEach((id) => clearTimeout(id))
|
||||||
|
this._vibrateTimers = []
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect which celebration level to show. Called at onComplete (BEFORE
|
* Detect which celebration level to show. Called at onComplete (BEFORE
|
||||||
* saveRecord fires in finishTraining), so stats reflect pre-save state.
|
* saveRecord fires in finishTraining), so stats reflect pre-save state.
|
||||||
@@ -224,6 +245,7 @@ Page({
|
|||||||
onUnload() {
|
onUnload() {
|
||||||
voice.stop()
|
voice.stop()
|
||||||
voice.destroy()
|
voice.destroy()
|
||||||
|
this._clearVibrateTimers()
|
||||||
if (this._timer) {
|
if (this._timer) {
|
||||||
this._timer.stop()
|
this._timer.stop()
|
||||||
this._timer = null
|
this._timer = null
|
||||||
@@ -257,6 +279,7 @@ Page({
|
|||||||
if (!this.data.isRunning || this.data.isPaused) return
|
if (!this.data.isRunning || this.data.isPaused) return
|
||||||
this._timer.pause()
|
this._timer.pause()
|
||||||
voice.stop()
|
voice.stop()
|
||||||
|
this._clearVibrateTimers()
|
||||||
this.setData({ isPaused: true, status: 'paused' })
|
this.setData({ isPaused: true, status: 'paused' })
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -281,11 +304,10 @@ Page({
|
|||||||
// a second tap on the underlying 结束 button is harmless — but we
|
// a second tap on the underlying 结束 button is harmless — but we
|
||||||
// still need to guard the save itself.
|
// still need to guard the save itself.
|
||||||
if (this._saving) return
|
if (this._saving) return
|
||||||
this._saving = true
|
|
||||||
|
|
||||||
const elapsed = this._timer.stop()
|
const elapsed = this._timer.stop()
|
||||||
|
this._clearVibrateTimers()
|
||||||
if (elapsed < 3) {
|
if (elapsed < 3) {
|
||||||
this._saving = false
|
|
||||||
wx.showToast({ title: '训练时间太短', icon: 'none', duration: 1500 })
|
wx.showToast({ title: '训练时间太短', icon: 'none', duration: 1500 })
|
||||||
setTimeout(() => { wx.navigateBack() }, 1500)
|
setTimeout(() => { wx.navigateBack() }, 1500)
|
||||||
return
|
return
|
||||||
@@ -300,6 +322,11 @@ Page({
|
|||||||
* pre-save stats so the level is decided before the data changes.
|
* pre-save stats so the level is decided before the data changes.
|
||||||
*/
|
*/
|
||||||
_saveAndShowCompletion(elapsed) {
|
_saveAndShowCompletion(elapsed) {
|
||||||
|
// Unified re-entry guard: covers both the natural-complete (onComplete)
|
||||||
|
// and manual-stop (finishTraining) paths so the save never runs twice.
|
||||||
|
if (this._saving) return
|
||||||
|
this._saving = true
|
||||||
|
|
||||||
const { level, message } = this._detectCelebrationLevel(elapsed)
|
const { level, message } = this._detectCelebrationLevel(elapsed)
|
||||||
const confettiPieces = this._buildConfettiPieces(level)
|
const confettiPieces = this._buildConfettiPieces(level)
|
||||||
|
|
||||||
@@ -377,6 +404,15 @@ Page({
|
|||||||
this._completionCountUp.cancel()
|
this._completionCountUp.cancel()
|
||||||
this._completionCountUp = null
|
this._completionCountUp = null
|
||||||
}
|
}
|
||||||
|
// Reset per-session state so the next run fires reminders/saves
|
||||||
|
// correctly (previously these stayed set, muting cues on the 2nd run).
|
||||||
|
this._saving = false
|
||||||
|
this._halfwaySignaled = false
|
||||||
|
this._last30Signaled = false
|
||||||
|
this._last10Signaled = false
|
||||||
|
this._lastMinuteAt = 0
|
||||||
|
this._lastCountdown = 0
|
||||||
|
this._clearVibrateTimers()
|
||||||
this.setData({
|
this.setData({
|
||||||
showCompletion: false,
|
showCompletion: false,
|
||||||
isCompleted: false,
|
isCompleted: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user