feat: 修复清除数据、语音缓存、排行榜限制,新增项目配置文件

- 修复 cloud.clearAll 静默失败导致重启后数据恢复
- 清除数据弹窗改用自定义 ui-modal 替代 wx.showModal
- TTS 语音合成增加 fileID 持久化缓存,重启不再重新合成
- 排行榜限制前15名,maxRank 参数化到 config.js
- 新增 config.js 统一管理版本号/更新日期/开发者/排行榜限制
- progress-ring 消除 getSystemInfoSync 弃用警告
- voice.js 增加 InnerAudioContext 错误监听
- storage/util 完善用户资料、打卡日期精度、记录日志
This commit is contained in:
2026-06-11 11:30:47 +08:00
parent 1d55df72b3
commit 2e2594d154
16 changed files with 604 additions and 139 deletions
+6
View File
@@ -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 }
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "getOpenid",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"wx-server-sdk": "latest"
}
}
+39 -9
View File
@@ -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,
+23 -15
View File
@@ -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://<full-env-id>/...`, 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 }
}
+1 -1
View File
@@ -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
+17
View File
@@ -0,0 +1,17 @@
/**
* 项目全局配置 — 集中管理版本号、更新日期、开发者等信息。
* 页面和组件通过 require('../config') 引用,打包时静态替换。
*/
module.exports = {
/** 应用版本号,外显在设置页「关于」 */
version: 'v1.5',
/** 最后更新日期,外显在设置页「关于」 */
updatedAt: '2026-06-11',
/** 开发者名称 / 微信号,设置页点击可复制 */
developer: '刘承',
/** 排行榜最大显示人数 */
leaderboardMaxRank: 15
}
+9 -2
View File
@@ -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 {
+62 -13
View File
@@ -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,21 +179,29 @@ 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
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')
@@ -175,8 +226,6 @@ Page({
// Reset plans list — custom_plans are gone
this.setData({ plans: buildPlansList(storage.getCustomPlans()) })
wx.showToast({ title: '已清除', icon: 'success', duration: 1500 })
}
})
},
// -------- Plan editor --------
+2 -1
View File
@@ -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": "设置"
}
+59 -3
View File
@@ -1,4 +1,38 @@
<view class="container" style="{{themeStyle}}">
<!-- 个人资料 -->
<ui-card variant="in">
<view class="section-header">
<image class="section-icon" src="{{icons.peopleFill}}" mode="aspectFit"></image>
<text class="section-title">个人资料</text>
</view>
<view class="profile-row">
<button class="avatar-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
<image class="avatar-img" src="{{avatarUrl || icons.peopleFill}}" mode="aspectFit"></image>
<view class="avatar-overlay">
<text>点击设置</text>
</view>
</button>
<view class="profile-info">
<text class="profile-name">{{nickName || '未设置'}}</text>
<text class="profile-sub">排行榜将显示此昵称</text>
</view>
</view>
<view class="setting-row">
<view class="setting-left">
<image class="setting-icon" src="{{icons.formFill}}" mode="aspectFit"></image>
<text class="setting-label">昵称</text>
</view>
<input
type="nickname"
class="nickname-input"
placeholder="点击设置昵称"
value="{{nickName}}"
bindblur="onNicknameBlur"
maxlength="16"
/>
</view>
</ui-card>
<!-- 配色方案 -->
<ui-card variant="in">
<view class="section-header">
@@ -98,15 +132,15 @@
<view class="about-list">
<view class="about-row">
<text class="about-label">版本</text>
<text class="about-value">v1.5</text>
<text class="about-value">{{appVersion}}</text>
</view>
<view class="about-row">
<text class="about-label">更新日期</text>
<text class="about-value">2026-06-10</text>
<text class="about-value">{{appUpdatedAt}}</text>
</view>
<view class="about-row" bindtap="onCopyWechat">
<text class="about-label">开发者</text>
<text class="about-value about-link">刘承</text>
<text class="about-value about-link">{{appDeveloper}}</text>
</view>
</view>
<view class="about-footer">
@@ -207,4 +241,26 @@
<text class="editor-cancel" bindtap="onCloseEditor">取消</text>
</view>
</view>
<!-- 清除数据确认弹窗:用自定义 ui-modal 替代 wx.showModal
避免开发者工具中 wx.showModal 首次点击不关闭的问题 -->
<ui-modal
visible="{{showClearConfirm}}"
position="center"
showHandle="{{false}}"
bind:close="onCancelClear"
>
<view class="confirm-dialog" catchtap="onNoop">
<text class="confirm-title">清除数据</text>
<text class="confirm-desc">将删除所有训练记录和连续打卡数据,此操作不可恢复。确定继续吗?</text>
<view class="confirm-actions">
<view class="confirm-btn confirm-btn--cancel" bindtap="onCancelClear">
<text>取消</text>
</view>
<view class="confirm-btn confirm-btn--danger" bindtap="onConfirmClear">
<text>确定清除</text>
</view>
</view>
</view>
</ui-modal>
</view>
+137
View File
@@ -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;
}
+1 -1
View File
@@ -15,7 +15,7 @@
"compileHotReLoad": false,
"lazyloadPlaceholderEnable": false,
"preloadBackgroundData": false,
"minified": true,
"minified": false,
"autoAudits": false,
"newFeature": false,
"uglifyFileName": false,
+93 -37
View File
@@ -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()
}
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]
}
// 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]
}
// 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()
// 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 })
} else {
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
// 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
}
}
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
}
}
module.exports = { init, pullAll, pushAll, clearAll, get enabled() { return _enabled } }
+56 -14
View File
@@ -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,
+18 -3
View File
@@ -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
}
+43 -10
View File
@@ -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) {