From 2e2594d154ae0ad9d2c3b727ec111787f7618b60 Mon Sep 17 00:00:00 2001 From: cnliucheng Date: Thu, 11 Jun 2026 11:30:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BF=AE=E5=A4=8D=E6=B8=85=E9=99=A4?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E3=80=81=E8=AF=AD=E9=9F=B3=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E3=80=81=E6=8E=92=E8=A1=8C=E6=A6=9C=E9=99=90=E5=88=B6=EF=BC=8C?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E9=A1=B9=E7=9B=AE=E9=85=8D=E7=BD=AE=E6=96=87?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 cloud.clearAll 静默失败导致重启后数据恢复 - 清除数据弹窗改用自定义 ui-modal 替代 wx.showModal - TTS 语音合成增加 fileID 持久化缓存,重启不再重新合成 - 排行榜限制前15名,maxRank 参数化到 config.js - 新增 config.js 统一管理版本号/更新日期/开发者/排行榜限制 - progress-ring 消除 getSystemInfoSync 弃用警告 - voice.js 增加 InnerAudioContext 错误监听 - storage/util 完善用户资料、打卡日期精度、记录日志 --- cloudfunctions/getOpenid/index.js | 6 + cloudfunctions/getOpenid/package.json | 8 ++ cloudfunctions/leaderboard/index.js | 50 ++++++-- cloudfunctions/tts/index.js | 38 +++--- components/progress-ring/progress-ring.js | 2 +- config.js | 17 +++ pages/leaderboard/leaderboard.js | 11 +- pages/settings/settings.js | 121 ++++++++++++------ pages/settings/settings.json | 3 +- pages/settings/settings.wxml | 62 +++++++++- pages/settings/settings.wxss | 137 +++++++++++++++++++++ project.config.json | 2 +- utils/cloud.js | 142 +++++++++++++++------- utils/storage.js | 70 ++++++++--- utils/util.js | 21 +++- utils/voice.js | 53 ++++++-- 16 files changed, 604 insertions(+), 139 deletions(-) create mode 100644 cloudfunctions/getOpenid/index.js create mode 100644 cloudfunctions/getOpenid/package.json create mode 100644 config.js diff --git a/cloudfunctions/getOpenid/index.js b/cloudfunctions/getOpenid/index.js new file mode 100644 index 0000000..7197f8c --- /dev/null +++ b/cloudfunctions/getOpenid/index.js @@ -0,0 +1,6 @@ +const cloud = require('wx-server-sdk') +cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) + +exports.main = async () => { + return { openid: cloud.getWXContext().OPENID } +} \ No newline at end of file diff --git a/cloudfunctions/getOpenid/package.json b/cloudfunctions/getOpenid/package.json new file mode 100644 index 0000000..2fddb65 --- /dev/null +++ b/cloudfunctions/getOpenid/package.json @@ -0,0 +1,8 @@ +{ + "name": "getOpenid", + "version": "1.0.0", + "main": "index.js", + "dependencies": { + "wx-server-sdk": "latest" + } +} \ No newline at end of file diff --git a/cloudfunctions/leaderboard/index.js b/cloudfunctions/leaderboard/index.js index 0b51d6c..0a1d7fa 100644 --- a/cloudfunctions/leaderboard/index.js +++ b/cloudfunctions/leaderboard/index.js @@ -5,18 +5,28 @@ const _ = db.command const COLLECTION = 'plank_data' const PAGE_SIZE = 100 -const MAX_RANK = 100 +const DEFAULT_MAX_RANK = 100 // fallback if client doesn't pass maxRank const pad = (n) => String(n).padStart(2, '0') +// Fixed UTC+8 offset — the user base is China-only and `new Date()` in +// the cloud function runs in UTC, so without this shift "today" rolls +// over at 08:00 Beijing instead of 00:00. If you ever support users in +// other timezones, switch back to a client-provided date. +const TZ_OFFSET_MS = 8 * 60 * 60 * 1000 + exports.main = async (event) => { - const { period } = event + const { period, maxRank } = event || {} + const limit = Math.max(1, Math.min(parseInt(maxRank) || DEFAULT_MAX_RANK, 500)) if (!period) return { err: 'missing period' } const now = new Date() - const today = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` + const local = new Date(now.getTime() + TZ_OFFSET_MS) + const today = `${local.getUTCFullYear()}-${pad(local.getUTCMonth() + 1)}-${pad(local.getUTCDate())}` const thisMonth = today.substring(0, 7) - const thisYear = String(now.getFullYear()) + // Year is computed from the same shifted date so year boundaries also + // align with Beijing's midnight, not UTC's. + const thisYear = String(local.getUTCFullYear()) const myOpenid = cloud.getWXContext().OPENID let prefix, exact @@ -41,7 +51,11 @@ exports.main = async (event) => { if (prefix && !monthKey.startsWith(prefix)) continue for (const r of records[monthKey]) { if (period === 'day') { - if (r.date === exact) { duration += r.duration; sessions++ } + // Use startsWith rather than === to tolerate date strings with + // trailing time/zone info (e.g. "2026-06-11T09:11:24.587Z" if + // some legacy path stored an ISO date) and to be timezone- + // agnostic when client local date differs from server UTC date. + if (r.date.startsWith(exact)) { duration += r.duration; sessions++ } } else { if (r.date.startsWith(prefix)) { duration += r.duration; sessions++ } } @@ -51,11 +65,19 @@ exports.main = async (event) => { if (duration > 0) { const openid = doc._openid || 'unknown' const entry = userMap.get(openid) + const profile = doc.profile || {} if (entry) { entry.duration += duration entry.sessions += sessions + // Refresh nickname in case the user updated it since last write + if (profile.nickname) entry.nickname = profile.nickname } else { - userMap.set(openid, { openid, duration, sessions }) + userMap.set(openid, { + openid, + duration, + sessions, + nickname: profile.nickname || '' + }) } } } @@ -72,11 +94,18 @@ exports.main = async (event) => { const myEntry = userMap.get(myOpenid) const myRank = myEntry ? allSorted.findIndex(e => e.openid === myOpenid) + 1 : 0 + // Resolve display name: prefer the user's chosen nickname, fall back to + // a masked openid for users who haven't set one. + const _displayName = (entry) => + (entry && entry.nickname && entry.nickname.trim()) || + maskOpenid(entry && entry.openid) + // Top N for the board - const ranked = allSorted.slice(0, MAX_RANK).map((item, i) => ({ + const ranked = allSorted.slice(0, limit).map((item, i) => ({ rank: i + 1, openid: item.openid, - name: maskOpenid(item.openid), + nickname: item.nickname || '', + name: _displayName(item), duration: item.duration, sessions: item.sessions })) @@ -88,7 +117,8 @@ exports.main = async (event) => { myEntry: myEntry ? { rank: myRank, openid: myEntry.openid, - name: maskOpenid(myEntry.openid), + nickname: myEntry.nickname || '', + name: _displayName(myEntry), duration: myEntry.duration, sessions: myEntry.sessions } : null, @@ -100,4 +130,4 @@ function maskOpenid(openid) { if (!openid || openid === 'unknown') return '未知用户' if (openid.length <= 4) return '****' + openid return '****' + openid.slice(-4) -} +} \ No newline at end of file diff --git a/cloudfunctions/tts/index.js b/cloudfunctions/tts/index.js index f92b0ca..c89c25b 100644 --- a/cloudfunctions/tts/index.js +++ b/cloudfunctions/tts/index.js @@ -1,18 +1,11 @@ // Cloud function: synthesize the 4 fixed training-voice prompts. // -// We do NOT cache on the server. Two reasons: -// 1. Caching via cloud storage requires self-constructing a fileID of -// the form `cloud:///...`, but `getWXContext().ENV` -// only returns the simple env id. Mismatched → unopenable fileID. -// 2. Caching via the cloud database requires `cloud.database()` at -// top level, which on this WeChat cloud runtime crashes the SCF -// framework's `writeRuntimeFile` step (it can't toString() the -// error object the SDK throws). -// -// The client (`utils/voice.js`) caches audioUrls in memory per session, -// so each of the 4 prompts is synthesized at most once per app launch. -// Re-synthesizing on app restart is fine — 800万 chars/month free tier -// covers 4 × ~30 chars × thousands of restarts per month. +// Caching strategy: the client stores the permanent `fileID` returned by +// the first successful synthesis in local storage. On subsequent calls it +// passes `fileID` back to us; we call `getTempFileURL` with it to get a +// fresh temporary URL instantly (no synthesis, no upload). If the cached +// fileID is stale (file deleted from cloud storage), we fall through and +// re-synthesize, returning the new fileID so the client can update its cache. // // Configuration (云开发控制台 → 云函数 → tts → 函数配置 → 环境变量): // TTS_SECRET_ID — Tencent Cloud API key id @@ -76,12 +69,27 @@ const _upload = async (key, buffer) => { } exports.main = async (event) => { - const { promptKey } = event || {} + const { promptKey, fileID } = event || {} const text = PROMPTS[promptKey] if (!text) { return { success: false, error: `Unknown prompt key: ${promptKey}` } } + // If the client passed a cached fileID, try to get a fresh temp URL + // from it — this is the fast path (no synthesis, no upload). + if (fileID) { + try { + const urlRes = await cloud.getTempFileURL({ fileList: [fileID] }) + const url = urlRes.fileList[0].tempFileURL + if (url) { + return { success: true, audioUrl: url, fileID, text, cached: true } + } + } catch (e) { + // fileID stale or file deleted — fall through to re-synthesize + } + } + + // Slow path: synthesize + upload + return fresh fileID for caching try { const buffer = await _synthesize(text, promptKey) const newFileID = await _upload(promptKey, buffer) @@ -90,7 +98,7 @@ exports.main = async (event) => { if (!url) { return { success: false, error: 'getTempFileURL returned empty URL', text } } - return { success: true, audioUrl: url, text } + return { success: true, audioUrl: url, fileID: newFileID, text, cached: false } } catch (e) { return { success: false, error: e.message, text } } diff --git a/components/progress-ring/progress-ring.js b/components/progress-ring/progress-ring.js index 30da3c6..c4f710a 100644 --- a/components/progress-ring/progress-ring.js +++ b/components/progress-ring/progress-ring.js @@ -38,7 +38,7 @@ Component({ .exec((res) => { if (!res || !res[0] || !res[0].node) return const canvas = res[0].node - const dpr = wx.getSystemInfoSync().pixelRatio || 2 + const dpr = wx.getWindowInfo().pixelRatio || 2 const displaySize = this.data.size canvas.width = displaySize * dpr canvas.height = displaySize * dpr diff --git a/config.js b/config.js new file mode 100644 index 0000000..6179239 --- /dev/null +++ b/config.js @@ -0,0 +1,17 @@ +/** + * 项目全局配置 — 集中管理版本号、更新日期、开发者等信息。 + * 页面和组件通过 require('../config') 引用,打包时静态替换。 + */ +module.exports = { + /** 应用版本号,外显在设置页「关于」 */ + version: 'v1.5', + + /** 最后更新日期,外显在设置页「关于」 */ + updatedAt: '2026-06-11', + + /** 开发者名称 / 微信号,设置页点击可复制 */ + developer: '刘承', + + /** 排行榜最大显示人数 */ + leaderboardMaxRank: 15 +} diff --git a/pages/leaderboard/leaderboard.js b/pages/leaderboard/leaderboard.js index fd89db8..7ff1811 100644 --- a/pages/leaderboard/leaderboard.js +++ b/pages/leaderboard/leaderboard.js @@ -2,6 +2,7 @@ const themeMod = require('../../utils/theme') const iconsMod = require('../../utils/icons') const util = require('../../utils/util') const storage = require('../../utils/storage') +const config = require('../../config') Page({ data: { @@ -56,7 +57,10 @@ Page({ this.setData({ loading: true, empty: false }) wx.cloud.callFunction({ name: 'leaderboard', - data: { period: this.data.activePeriod } + data: { + period: this.data.activePeriod, + maxRank: config.leaderboardMaxRank + } }).then(res => { this._applyResult(res.result || {}) if (cb) cb() @@ -92,6 +96,8 @@ Page({ const today = storage.getToday() const thisMonth = today.substring(0, 7) const thisYear = String(new Date().getFullYear()) + const profile = storage.getProfile() + const localName = profile.nickname || '我' let duration = 0 let sessions = 0 @@ -103,7 +109,8 @@ Page({ if (duration > 0) { const entry = { - rank: 1, openid: 'local', name: '我', duration, durationText: util.formatDuration(duration), sessions + rank: 1, openid: 'local', name: localName, nickname: profile.nickname || '', + duration, durationText: util.formatDuration(duration), sessions } this.setData({ rankedList: [entry], myEntry: { ...entry, isMe: true }, loading: false, empty: false }) } else { diff --git a/pages/settings/settings.js b/pages/settings/settings.js index d6ddad7..510d55d 100644 --- a/pages/settings/settings.js +++ b/pages/settings/settings.js @@ -3,6 +3,7 @@ const themeMod = require('../../utils/theme') const planMod = require('../../utils/plan') const iconsMod = require('../../utils/icons') const cloud = require('../../utils/cloud') +const config = require('../../config') /** * Build the simplified plans list shown in the settings UI by overlaying @@ -51,18 +52,27 @@ Page({ voiceGuide: true, vibrate: true, icons: iconsMod.build(), + nickName: '', + avatarUrl: '', // Editor state showPlanEditor: false, editingPlanId: '', editingIsCustom: false, editingDraft: null, // { name, totalDays, startTarget, increment, cycleDays } - editorPreview: [] // [{ day, target }, ...] + 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, @@ -70,7 +80,9 @@ Page({ currentThemeId: theme.id, theme, icons: iconsMod.build(theme), - plans: buildPlansList(storage.getCustomPlans()) + plans: buildPlansList(storage.getCustomPlans()), + nickName: profile.nickname || '', + avatarUrl: profile.avatarUrl || '' }) }, @@ -81,14 +93,45 @@ Page({ } 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()) + plans: buildPlansList(storage.getCustomPlans()), + nickName: profile.nickname || '', + avatarUrl: profile.avatarUrl || '' }) }, + // --- Profile handlers --- + + onChooseAvatar(e) { + const avatarUrl = e.detail.avatarUrl + if (!avatarUrl) return + this.setData({ avatarUrl }) + const saved = storage.saveProfile({ + nickname: this.data.nickName, + avatarUrl + }) + this.setData({ + nickName: saved.nickname || this.data.nickName, + avatarUrl: saved.avatarUrl + }) + wx.showToast({ title: '头像已更新', icon: 'success', duration: 1200 }) + }, + + 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) @@ -136,47 +179,53 @@ Page({ onCopyWechat() { wx.setClipboardData({ - data: '刘承', + 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() { - wx.showModal({ - title: '清除数据', - content: '将删除所有训练记录和连续打卡数据,此操作不可恢复。确定继续吗?', - confirmText: '确定清除', - confirmColor: '#E53935', - success: async (res) => { - if (!res.confirm) return - wx.removeStorageSync('training_records') - wx.removeStorageSync('current_streak') - wx.removeStorageSync('user_settings') - wx.removeStorageSync('app_theme') - wx.removeStorageSync('custom_plans') - try { await cloud.clearAll() } catch (e) {} - wx.setStorageSync('user_settings', { - planId: 'beginner', - voiceGuide: true, - vibrate: true, - planStartDate: storage.getToday() - }) - wx.setStorageSync('current_streak', { count: 0, lastDate: '' }) - themeMod.setTheme('orange') - this.setData({ - currentPlanId: 'beginner', - voiceGuide: true, - vibrate: true, - currentThemeId: 'orange' - }) - themeMod.applyThemeToPage(this) - // Reset plans list — custom_plans are gone - this.setData({ plans: buildPlansList(storage.getCustomPlans()) }) - wx.showToast({ title: '已清除', icon: 'success', duration: 1500 }) - } + this.setData({ showClearConfirm: true }) + }, + + onCancelClear() { + this.setData({ showClearConfirm: false }) + }, + + onConfirmClear() { + this.setData({ showClearConfirm: false }) + this._doClearData() + }, + + async _doClearData() { + wx.removeStorageSync('training_records') + wx.removeStorageSync('current_streak') + wx.removeStorageSync('user_settings') + wx.removeStorageSync('app_theme') + wx.removeStorageSync('custom_plans') + try { await cloud.clearAll() } catch (e) {} + wx.setStorageSync('user_settings', { + planId: 'beginner', + voiceGuide: true, + vibrate: true, + planStartDate: storage.getToday() }) + wx.setStorageSync('current_streak', { count: 0, lastDate: '' }) + themeMod.setTheme('orange') + this.setData({ + currentPlanId: 'beginner', + voiceGuide: true, + vibrate: true, + currentThemeId: 'orange' + }) + themeMod.applyThemeToPage(this) + // Reset plans list — custom_plans are gone + this.setData({ plans: buildPlansList(storage.getCustomPlans()) }) + wx.showToast({ title: '已清除', icon: 'success', duration: 1500 }) }, // -------- Plan editor -------- diff --git a/pages/settings/settings.json b/pages/settings/settings.json index 55dade1..60b2119 100644 --- a/pages/settings/settings.json +++ b/pages/settings/settings.json @@ -1,7 +1,8 @@ { "usingComponents": { "ui-card": "/components/ui-card/ui-card", - "ui-btn": "/components/ui-btn/ui-btn" + "ui-btn": "/components/ui-btn/ui-btn", + "ui-modal": "/components/ui-modal/ui-modal" }, "navigationBarTitleText": "设置" } diff --git a/pages/settings/settings.wxml b/pages/settings/settings.wxml index 18deda7..ec46d35 100644 --- a/pages/settings/settings.wxml +++ b/pages/settings/settings.wxml @@ -1,4 +1,38 @@ + + + + + 个人资料 + + + + + {{nickName || '未设置'}} + 排行榜将显示此昵称 + + + + + + 昵称 + + + + + @@ -98,15 +132,15 @@ 版本 - v1.5 + {{appVersion}} 更新日期 - 2026-06-10 + {{appUpdatedAt}} 开发者 - 刘承 + {{appDeveloper}} @@ -207,4 +241,26 @@ 取消 + + + + + 清除数据 + 将删除所有训练记录和连续打卡数据,此操作不可恢复。确定继续吗? + + + 取消 + + + 确定清除 + + + + diff --git a/pages/settings/settings.wxss b/pages/settings/settings.wxss index 8880dbf..53fe91b 100644 --- a/pages/settings/settings.wxss +++ b/pages/settings/settings.wxss @@ -226,6 +226,89 @@ font-weight: 500; } +/* ---- profile ---- */ +.profile-row { + display: flex; + align-items: center; + padding: 16rpx 0 24rpx; +} + +.avatar-btn { + position: relative; + width: 120rpx; + height: 120rpx; + padding: 0; + margin: 0; + background: #F5F5F5; + border-radius: 50%; + border: none; + line-height: 1; + overflow: hidden; + flex-shrink: 0; +} + +.avatar-btn::after { + border: none; +} + +.avatar-img { + width: 100%; + height: 100%; + border-radius: 50%; + /* peopleFill svg is mostly transparent; give it a soft tint */ + background: #EEE; + padding: 24rpx; + box-sizing: border-box; +} + +.avatar-overlay { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 36rpx; + background: rgba(0, 0, 0, 0.55); + color: #FFFFFF; + font-size: 20rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.profile-info { + flex: 1; + margin-left: 24rpx; + display: flex; + flex-direction: column; + min-width: 0; +} + +.profile-name { + font-size: 32rpx; + font-weight: 600; + color: var(--text); + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.profile-sub { + font-size: 22rpx; + color: var(--text-secondary); + margin-top: 8rpx; + line-height: 1; +} + +.nickname-input { + flex: 1; + text-align: right; + font-size: 28rpx; + color: var(--text); + padding: 0; + background: transparent; +} + .about-footer { margin-top: 24rpx; padding-top: 20rpx; @@ -440,3 +523,57 @@ color: var(--text-secondary); padding: 12rpx 0 0; } + +/* ---- 清除数据确认弹窗 ---- */ + +.confirm-dialog { + padding: 48rpx 40rpx 32rpx; + text-align: center; +} + +.confirm-title { + display: block; + font-size: 34rpx; + font-weight: 600; + color: var(--text); + margin-bottom: 16rpx; +} + +.confirm-desc { + display: block; + font-size: 26rpx; + color: var(--text-secondary); + line-height: 1.6; + margin-bottom: 40rpx; +} + +.confirm-actions { + display: flex; + gap: 20rpx; +} + +.confirm-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + height: 88rpx; + border-radius: 16rpx; + font-size: 30rpx; + font-weight: 500; + transition: opacity 0.15s; +} + +.confirm-btn:active { + opacity: 0.8; +} + +.confirm-btn--cancel { + background: var(--bg); + color: var(--text-secondary); +} + +.confirm-btn--danger { + background: #E53935; + color: #FFFFFF; +} diff --git a/project.config.json b/project.config.json index bc22419..373955a 100644 --- a/project.config.json +++ b/project.config.json @@ -15,7 +15,7 @@ "compileHotReLoad": false, "lazyloadPlaceholderEnable": false, "preloadBackgroundData": false, - "minified": true, + "minified": false, "autoAudits": false, "newFeature": false, "uglifyFileName": false, diff --git a/utils/cloud.js b/utils/cloud.js index 64b0aad..1576c89 100644 --- a/utils/cloud.js +++ b/utils/cloud.js @@ -4,7 +4,10 @@ const ENV_ID = 'cloudbase-d1g56kl2q8f4f7d8a' let _db = null let _pushTimer = null let _enabled = false -let _docId = null // cache doc id after first successful query +let _docId = null // cache doc id after first successful push +let _openid = null // cache openid from first successful add, used to + // re-locate our doc when _docId goes stale + // (e.g. user manually deleted the doc in the console) const getDb = () => { if (!_enabled) return null @@ -27,36 +30,55 @@ const pullAll = async () => { const db = getDb() if (!db) return null try { - // First try cached doc id - if (_docId) { - try { - const res = await db.collection(DB_COLLECTION).doc(_docId).get() - if (res && res.data) return res.data - } catch (e) { - _docId = null // doc deleted or inaccessible - } + // Always need the openid to safely locate OUR doc under the custom + // security rule. Cache it on first call so subsequent pulls are fast. + if (!_openid) { + _openid = await _fetchOpenid() } - // Query: rely on security rules to scope to current user - const res = await db.collection(DB_COLLECTION).limit(1).get() - if (res && res.data && res.data.length > 0) { - _docId = res.data[0]._id - return res.data[0] + if (!_openid) return null // cloud function call failed; bail + + // Locate our doc by _openid, not by limit(1) which can pick someone + // else's doc when the custom rule is in effect. + const mine = await db.collection(DB_COLLECTION) + .where({ _openid: _openid }) + .limit(1) + .get() + if (mine && mine.data && mine.data.length > 0) { + _docId = mine.data[0]._id + return mine.data[0] } + // No doc for us — that's a fresh install on a new device with no + // history, or the user cleared their cloud data. Either way, nothing + // to restore. + _docId = null return null } catch (e) { return null } } +// One-shot helper: call the getOpenid cloud function and return the +// openid string. Returns null on any failure (function not deployed, +// network error, etc.) — pullAll then bails cleanly. +const _fetchOpenid = async () => { + try { + const res = await wx.cloud.callFunction({ name: 'getOpenid' }) + if (res && res.result && res.result.openid) return res.result.openid + } catch (e) { /* function not deployed, etc. */ } + return null +} + const pushAll = () => { - if (!_enabled) return + if (!_enabled) { console.log('[cloud] pushAll skipped (not enabled)'); return } if (_pushTimer) clearTimeout(_pushTimer) + console.log('[cloud] pushAll scheduled') _pushTimer = setTimeout(() => _doPush(), 2000) } const _doPush = async () => { + console.log('[cloud] _doPush starting...') const db = getDb() - if (!db) return + if (!db) { console.log('[cloud] _doPush aborted (no db)'); return } try { const storage = require('./storage') const themeMod = require('./theme') @@ -66,55 +88,89 @@ const _doPush = async () => { settings: storage.getSettings(), streak: storage.getStreak(), customPlans: storage.getCustomPlans(), + profile: storage.getProfile(), themeId: themeMod.getCurrentTheme().id, updatedAt: db.serverDate() } + console.log('[cloud] _doPush records keys:', Object.keys(data.records || {})) // Use cached doc id if available if (_docId) { try { await db.collection(DB_COLLECTION).doc(_docId).update({ data }) + console.log('[cloud] _doPush update ok') return } catch (e) { - _docId = null // doc may have been deleted + // Most commonly: doc was deleted out from under us (user cleared + // the collection in the console). The custom write rule evaluates + // `doc._openid == auth.openid` → `undefined == openid` → false, + // so this surfaces as -502003 rather than "not found". Either way, + // fall through and re-locate the doc via cached _openid (or add). + console.log('[cloud] _doPush cached _docId stale, clearing:', e.message) + _docId = null } } - // Query for existing doc (relies on security rules to scope to current user) - const existing = await db.collection(DB_COLLECTION).limit(1).get() - if (existing && existing.data && existing.data.length > 0) { - _docId = existing.data[0]._id - await db.collection(DB_COLLECTION).doc(_docId).update({ data }) - } else { - const res = await db.collection(DB_COLLECTION).add({ data }) - if (res && res._id) _docId = res._id + // Fallback: locate OUR doc by _openid (cached from a previous add) and + // update it. If we have no cached openid yet (first ever push) just add. + if (_openid) { + const existing = await db.collection(DB_COLLECTION) + .where({ _openid: _openid }) + .limit(1) + .get() + if (existing && existing.data && existing.data.length > 0) { + _docId = existing.data[0]._id + await db.collection(DB_COLLECTION).doc(_docId).update({ data }) + console.log('[cloud] _doPush update ok (re-located by _openid)') + return + } } + + // First push ever, or our doc was deleted and no other doc exists. + const res = await db.collection(DB_COLLECTION).add({ data }) + if (res && res._id) _docId = res._id + if (res && res._openid) _openid = res._openid + console.log('[cloud] _doPush add ok, docId=', _docId) } catch (e) { - // Silently retry on next write + console.log('[cloud] _doPush error:', e.message || e) } } const clearAll = async () => { const db = getDb() if (!db) return - try { - if (_docId) { - try { - await db.collection(DB_COLLECTION).doc(_docId).remove() - _docId = null - return - } catch (e) { - _docId = null - } - } - const existing = await db.collection(DB_COLLECTION).limit(1).get() - if (existing && existing.data && existing.data.length > 0) { - await db.collection(DB_COLLECTION).doc(existing.data[0]._id).remove() - } - _docId = null - } catch (e) { - // Nothing to clear + + // Ensure _openid is cached before trying to delete — without it we + // can't reliably locate the user's doc (add() only returns _id). + if (!_openid) { + _openid = await _fetchOpenid() } + if (!_openid) { + console.log('[cloud] clearAll: cannot fetch openid, giving up') + return + } + + // Always locate by _openid (never by cached _docId alone) so we're + // guaranteed to target OUR doc under the custom permission rule. + const mine = await db.collection(DB_COLLECTION) + .where({ _openid: _openid }) + .limit(1) + .get() + if (mine && mine.data && mine.data.length > 0) { + const myDocId = mine.data[0]._id + try { + await db.collection(DB_COLLECTION).doc(myDocId).remove() + console.log('[cloud] clearAll: removed doc', myDocId) + } catch (e) { + console.log('[cloud] clearAll: remove failed:', e.message || e) + } + } else { + console.log('[cloud] clearAll: no doc found for openid') + } + + // Always invalidate cached ids so the next _doPush starts fresh + // instead of trying to update a now-deleted or foreign doc. + _docId = null } module.exports = { init, pullAll, pushAll, clearAll, get enabled() { return _enabled } } diff --git a/utils/storage.js b/utils/storage.js index bbd8b79..3cfd1d9 100644 --- a/utils/storage.js +++ b/utils/storage.js @@ -1,10 +1,11 @@ -const { formatDate } = require('./util') +const { formatDate, dateOnly } = require('./util') const cloud = require('./cloud') const RECORDS_KEY = 'training_records' const SETTINGS_KEY = 'user_settings' const STREAK_KEY = 'current_streak' const CUSTOM_PLANS_KEY = 'custom_plans' +const PROFILE_KEY = 'user_profile' /** * Backfill an `id` for any record that doesn't have one. @@ -52,6 +53,7 @@ const saveRecord = (record) => { records[month].push(record) records[month].sort((a, b) => b.date.localeCompare(a.date)) wx.setStorageSync(RECORDS_KEY, records) + console.log('[storage] saveRecord date=' + record.date + ' duration=' + record.duration) cloud.pushAll() } @@ -105,6 +107,34 @@ const saveSettings = (settings) => { cloud.pushAll() } +/** + * User profile (nickname + avatar). Stored locally and pushed to the + * cloud alongside records/settings so the leaderboard can show real + * names instead of masked openids. + * + * Shape: { nickname: string, avatarUrl: string (wxfile://...) } + * Both fields are optional; absent profile returns `{}`. + */ +const getProfile = () => { + const raw = wx.getStorageSync(PROFILE_KEY) + if (raw && typeof raw === 'object' && !Array.isArray(raw)) return raw + return {} +} + +const saveProfile = (profile) => { + // Strip empties so we don't push {nickname: '', avatarUrl: ''} + const clean = {} + if (profile && typeof profile.nickname === 'string' && profile.nickname.trim()) { + clean.nickname = profile.nickname.trim().slice(0, 16) + } + if (profile && typeof profile.avatarUrl === 'string' && profile.avatarUrl) { + clean.avatarUrl = profile.avatarUrl + } + wx.setStorageSync(PROFILE_KEY, clean) + cloud.pushAll() + return clean +} + const getStreak = () => wx.getStorageSync(STREAK_KEY) || { count: 0, lastDate: '' } /** @@ -142,25 +172,30 @@ const resetCustomPlan = (planId) => { * Validate streak against actual training records. * Call on app launch to correct any inconsistencies * (e.g. after deleting the only record for a day). + * + * Streak compares date-ONLY portions of `lastDate` so that training + * twice on the same day (with different seconds in `record.date`) + * doesn't accidentally reset the streak. */ const validateStreak = () => { const streak = getStreak() if (!streak.lastDate) return streak const today = getToday() - const yesterday = getDateOffset(today, -1) + const todayOnly = dateOnly(today) + const yesterdayOnly = dateOnly(getDateOffset(today, -1)) const records = getRecords() const allDates = new Set() Object.values(records).forEach(monthRecs => { - monthRecs.forEach(r => allDates.add(r.date)) + monthRecs.forEach(r => allDates.add(dateOnly(r.date))) }) // If lastDate is today, check today still has records - if (streak.lastDate === today) { - if (!allDates.has(today)) { + if (dateOnly(streak.lastDate) === todayOnly) { + if (!allDates.has(todayOnly)) { // Today's records were deleted - if (allDates.has(yesterday)) { - streak.lastDate = yesterday + if (allDates.has(yesterdayOnly)) { + streak.lastDate = yesterdayOnly streak.count = Math.max(1, streak.count - 1) } else { streak.count = 0 @@ -178,17 +213,20 @@ const validateStreak = () => { const updateStreak = (date) => { const streak = getStreak() - const today = date || getToday() - const yesterday = getDateOffset(today, -1) + const todayFull = date || getToday() + const todayOnly = dateOnly(todayFull) + const yesterdayOnly = dateOnly(getDateOffset(todayFull, -1)) - if (streak.lastDate === today) return streak + if (dateOnly(streak.lastDate) === todayOnly) return streak - if (streak.lastDate === yesterday) { + if (dateOnly(streak.lastDate) === yesterdayOnly) { streak.count += 1 } else { streak.count = 1 } - streak.lastDate = today + // Store the date-ONLY portion so subsequent comparisons are stable + // across multiple same-day trainings with different seconds. + streak.lastDate = todayOnly wx.setStorageSync(STREAK_KEY, streak) cloud.pushAll() return streak @@ -197,8 +235,10 @@ const updateStreak = (date) => { const getToday = () => formatDate(new Date()) const getDateOffset = (dateStr, offset) => { - // Use YYYY/MM/DD for iOS compatibility (YYYY-MM-DD may fail on some iOS) - const d = new Date(dateStr.replace(/-/g, '/')) + // Strip the time portion (formatDate now includes HH:MM:SS) and convert + // dashes to slashes — `new Date("YYYY/MM/DD")` is the only reliably- + // parsed form across both V8 (WeChat dev tools) and JSCore (iOS). + const d = new Date(dateOnly(dateStr).replace(/-/g, '/')) d.setDate(d.getDate() + offset) return formatDate(d) } @@ -230,6 +270,8 @@ module.exports = { getCustomPlans, saveCustomPlan, resetCustomPlan, + getProfile, + saveProfile, getStreak, validateStreak, updateStreak, diff --git a/utils/util.js b/utils/util.js index dd4efab..048ae57 100644 --- a/utils/util.js +++ b/utils/util.js @@ -33,17 +33,32 @@ const getMonthCalendar = (year, month) => { return weeks } +/** + * Format a Date as `YYYY-MM-DD HH:MM:SS` in local time. + * + * Used as the canonical `record.date` string so each record has a + * precise timestamp (e.g. "2026-06-11 14:30:45"). Use `_dateOnly()` + * to strip back to YYYY-MM-DD for date-level comparisons (streak, + * leaderboard day filter, etc.). + */ const formatDate = (date) => { const y = date.getFullYear() - const m = String(date.getMonth() + 1).padStart(2, '0') + const mo = String(date.getMonth() + 1).padStart(2, '0') const d = String(date.getDate()).padStart(2, '0') - return `${y}-${m}-${d}` + const h = String(date.getHours()).padStart(2, '0') + const mi = String(date.getMinutes()).padStart(2, '0') + const s = String(date.getSeconds()).padStart(2, '0') + return `${y}-${mo}-${d} ${h}:${mi}:${s}` } +/** Strip the time portion of a formatDate string → "YYYY-MM-DD". */ +const _dateOnly = (s) => (typeof s === 'string' && s.length >= 10) ? s.substring(0, 10) : '' + module.exports = { formatTime, formatDuration, getDaysInMonth, getMonthCalendar, - formatDate + formatDate, + dateOnly: _dateOnly } diff --git a/utils/voice.js b/utils/voice.js index fce9e64..97350b2 100644 --- a/utils/voice.js +++ b/utils/voice.js @@ -2,12 +2,15 @@ * Voice prompt utility for the timer. * * 4 fixed, polished prompts live in `cloudfunctions/tts`. We call that - * cloud function with a key, get back an audio URL (cached in cloud - * storage after first synthesis), and play it via createInnerAudioContext. + * cloud function with a key, get back an audio URL and permanent fileID, + * and play it via createInnerAudioContext. * - * The in-memory `_urlCache` avoids hitting the cloud function more than - * once per prompt per session. The audio context is a singleton so - * overlapping prompts interrupt each other cleanly. + * Two-level caching so TTS synthesis happens at most once per prompt, + * ever (not once per app launch): + * 1. In-memory `_urlCache` — avoids cloud calls within a single session + * 2. Persistent `_fileIDCache` in wx storage — survives app restarts; + * we pass the permanent fileID back to the cloud function so it can + * call getTempFileURL directly instead of re-synthesizing. * * If the cloud function is unconfigured (no TTS credentials) and returns * `success: false`, we fall back to a silent no-op — better than spamming @@ -21,8 +24,24 @@ const PROMPTS = { complete: '太棒了!今天的目标已完成,继续加油!' } +const FILEID_CACHE_KEY = 'tts_fileid_cache' + let _ctx = null -const _urlCache = {} // { promptKey: audioUrl } +const _urlCache = {} // { promptKey: audioUrl } — per-session, volatile +let _fileIDCache = null // { promptKey: fileID } — persisted, lazy-loaded + +const _loadFileIDCache = () => { + if (_fileIDCache) return + try { + _fileIDCache = wx.getStorageSync(FILEID_CACHE_KEY) || {} + } catch (e) { + _fileIDCache = {} + } +} + +const _saveFileIDCache = () => { + try { wx.setStorageSync(FILEID_CACHE_KEY, _fileIDCache) } catch (e) {} +} const _getCtx = () => { if (!_ctx) { @@ -30,23 +49,37 @@ const _getCtx = () => { // Play even when the device is in silent mode — we want the user to // actually hear training cues during a session. _ctx.obeyMuteSwitch = false + _ctx.onError((err) => { + console.log('[voice] audio error:', err.errCode, err.errMsg) + }) } return _ctx } const _fetchUrl = async (promptKey) => { + // 1. In-memory cache: same-session instant return if (_urlCache[promptKey]) return _urlCache[promptKey] + + // 2. Persistent fileID cache: pass to cloud function for instant getTempFileURL + _loadFileIDCache() + const cachedFileID = _fileIDCache[promptKey] || null + try { const res = await wx.cloud.callFunction({ name: 'tts', - data: { promptKey }, - // Default WeChat cloud timeout is 3s, which is exactly the TTS - // first-call budget (synth + upload + tempURL). Bump to 30s for - // cold starts; cached calls return in <500ms. + data: { promptKey, fileID: cachedFileID }, + // Timeout: 30s for first synthesis (cold start), <1s for cached config: { timeout: 30000 } }) if (res && res.result && res.result.success && res.result.audioUrl) { _urlCache[promptKey] = res.result.audioUrl + + // Persist the fileID if we got a new one (first synthesis or + // re-synthesis after cache invalidation). + if (res.result.fileID && res.result.fileID !== cachedFileID) { + _fileIDCache[promptKey] = res.result.fileID + _saveFileIDCache() + } return res.result.audioUrl } } catch (e) {