diff --git a/app.wxss b/app.wxss index 933547f..0f3859d 100644 --- a/app.wxss +++ b/app.wxss @@ -16,12 +16,24 @@ page { 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) { page { + --text: #E5E5E7; + --text-secondary: #98989F; --bg: #0F0F12; --bg-gradient-start: #0F0F12; --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; } } diff --git a/pages/settings/settings.js b/pages/settings/settings.js index e2e190d..375446d 100644 --- a/pages/settings/settings.js +++ b/pages/settings/settings.js @@ -140,31 +140,46 @@ Page({ 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 { - // chooseAvatar returns a wxfile:// temporary URL that expires in a few - // days. Read the bytes and store as a data URI so the avatar survives - // long-term. Falls back to the raw URL on size/read error. - const permanentUrl = await util.fileToDataURI(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 }) + permanentUrl = await this._uploadAvatar(avatarUrl) } catch (err) { - console.warn('avatar convert failed, saving temp URL:', err) - // Fallback: save the raw wxfile URL. Will eventually expire. - this.setData({ avatarUrl }) - storage.saveProfile({ - nickname: this.data.nickName, - avatarUrl - }) - wx.showToast({ title: '头像已更新', icon: 'success', duration: 1200 }) + 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) { diff --git a/pages/timer/timer.js b/pages/timer/timer.js index 4f40ae6..0d82d0d 100644 --- a/pages/timer/timer.js +++ b/pages/timer/timer.js @@ -94,6 +94,7 @@ Page({ this._lastMinuteAt = 0 this._lastCountdown = 0 this._saving = false + this._clearVibrateTimers() this._timer = new Timer({ onTick: (tick) => { @@ -135,7 +136,14 @@ Page({ if (!s.vibrate) return try { 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) {} } @@ -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 * saveRecord fires in finishTraining), so stats reflect pre-save state. @@ -224,6 +245,7 @@ Page({ onUnload() { voice.stop() voice.destroy() + this._clearVibrateTimers() if (this._timer) { this._timer.stop() this._timer = null @@ -257,6 +279,7 @@ Page({ if (!this.data.isRunning || this.data.isPaused) return this._timer.pause() voice.stop() + this._clearVibrateTimers() this.setData({ isPaused: true, status: 'paused' }) }, @@ -281,11 +304,10 @@ Page({ // a second tap on the underlying 结束 button is harmless — but we // still need to guard the save itself. if (this._saving) return - this._saving = true const elapsed = this._timer.stop() + this._clearVibrateTimers() if (elapsed < 3) { - this._saving = false wx.showToast({ title: '训练时间太短', icon: 'none', duration: 1500 }) setTimeout(() => { wx.navigateBack() }, 1500) return @@ -300,6 +322,11 @@ Page({ * pre-save stats so the level is decided before the data changes. */ _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 confettiPieces = this._buildConfettiPieces(level) @@ -377,6 +404,15 @@ Page({ this._completionCountUp.cancel() 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({ showCompletion: false, isCompleted: false,