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 COLLECTION = 'plank_data'
const PAGE_SIZE = 100 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') 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) => { 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' } if (!period) return { err: 'missing period' }
const now = new Date() 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 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 const myOpenid = cloud.getWXContext().OPENID
let prefix, exact let prefix, exact
@@ -41,7 +51,11 @@ exports.main = async (event) => {
if (prefix && !monthKey.startsWith(prefix)) continue if (prefix && !monthKey.startsWith(prefix)) continue
for (const r of records[monthKey]) { for (const r of records[monthKey]) {
if (period === 'day') { 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 { } else {
if (r.date.startsWith(prefix)) { duration += r.duration; sessions++ } if (r.date.startsWith(prefix)) { duration += r.duration; sessions++ }
} }
@@ -51,11 +65,19 @@ exports.main = async (event) => {
if (duration > 0) { if (duration > 0) {
const openid = doc._openid || 'unknown' const openid = doc._openid || 'unknown'
const entry = userMap.get(openid) const entry = userMap.get(openid)
const profile = doc.profile || {}
if (entry) { if (entry) {
entry.duration += duration entry.duration += duration
entry.sessions += sessions entry.sessions += sessions
// Refresh nickname in case the user updated it since last write
if (profile.nickname) entry.nickname = profile.nickname
} else { } 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 myEntry = userMap.get(myOpenid)
const myRank = myEntry ? allSorted.findIndex(e => e.openid === myOpenid) + 1 : 0 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 // 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, rank: i + 1,
openid: item.openid, openid: item.openid,
name: maskOpenid(item.openid), nickname: item.nickname || '',
name: _displayName(item),
duration: item.duration, duration: item.duration,
sessions: item.sessions sessions: item.sessions
})) }))
@@ -88,7 +117,8 @@ exports.main = async (event) => {
myEntry: myEntry ? { myEntry: myEntry ? {
rank: myRank, rank: myRank,
openid: myEntry.openid, openid: myEntry.openid,
name: maskOpenid(myEntry.openid), nickname: myEntry.nickname || '',
name: _displayName(myEntry),
duration: myEntry.duration, duration: myEntry.duration,
sessions: myEntry.sessions sessions: myEntry.sessions
} : null, } : null,
+23 -15
View File
@@ -1,18 +1,11 @@
// Cloud function: synthesize the 4 fixed training-voice prompts. // Cloud function: synthesize the 4 fixed training-voice prompts.
// //
// We do NOT cache on the server. Two reasons: // Caching strategy: the client stores the permanent `fileID` returned by
// 1. Caching via cloud storage requires self-constructing a fileID of // the first successful synthesis in local storage. On subsequent calls it
// the form `cloud://<full-env-id>/...`, but `getWXContext().ENV` // passes `fileID` back to us; we call `getTempFileURL` with it to get a
// only returns the simple env id. Mismatched → unopenable fileID. // fresh temporary URL instantly (no synthesis, no upload). If the cached
// 2. Caching via the cloud database requires `cloud.database()` at // fileID is stale (file deleted from cloud storage), we fall through and
// top level, which on this WeChat cloud runtime crashes the SCF // re-synthesize, returning the new fileID so the client can update its cache.
// 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.
// //
// Configuration (云开发控制台 → 云函数 → tts → 函数配置 → 环境变量): // Configuration (云开发控制台 → 云函数 → tts → 函数配置 → 环境变量):
// TTS_SECRET_ID — Tencent Cloud API key id // TTS_SECRET_ID — Tencent Cloud API key id
@@ -76,12 +69,27 @@ const _upload = async (key, buffer) => {
} }
exports.main = async (event) => { exports.main = async (event) => {
const { promptKey } = event || {} const { promptKey, fileID } = event || {}
const text = PROMPTS[promptKey] const text = PROMPTS[promptKey]
if (!text) { if (!text) {
return { success: false, error: `Unknown prompt key: ${promptKey}` } 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 { try {
const buffer = await _synthesize(text, promptKey) const buffer = await _synthesize(text, promptKey)
const newFileID = await _upload(promptKey, buffer) const newFileID = await _upload(promptKey, buffer)
@@ -90,7 +98,7 @@ exports.main = async (event) => {
if (!url) { if (!url) {
return { success: false, error: 'getTempFileURL returned empty URL', text } 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) { } catch (e) {
return { success: false, error: e.message, text } return { success: false, error: e.message, text }
} }
+1 -1
View File
@@ -38,7 +38,7 @@ Component({
.exec((res) => { .exec((res) => {
if (!res || !res[0] || !res[0].node) return if (!res || !res[0] || !res[0].node) return
const canvas = res[0].node const canvas = res[0].node
const dpr = wx.getSystemInfoSync().pixelRatio || 2 const dpr = wx.getWindowInfo().pixelRatio || 2
const displaySize = this.data.size const displaySize = this.data.size
canvas.width = displaySize * dpr canvas.width = displaySize * dpr
canvas.height = 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 iconsMod = require('../../utils/icons')
const util = require('../../utils/util') const util = require('../../utils/util')
const storage = require('../../utils/storage') const storage = require('../../utils/storage')
const config = require('../../config')
Page({ Page({
data: { data: {
@@ -56,7 +57,10 @@ Page({
this.setData({ loading: true, empty: false }) this.setData({ loading: true, empty: false })
wx.cloud.callFunction({ wx.cloud.callFunction({
name: 'leaderboard', name: 'leaderboard',
data: { period: this.data.activePeriod } data: {
period: this.data.activePeriod,
maxRank: config.leaderboardMaxRank
}
}).then(res => { }).then(res => {
this._applyResult(res.result || {}) this._applyResult(res.result || {})
if (cb) cb() if (cb) cb()
@@ -92,6 +96,8 @@ Page({
const today = storage.getToday() const today = storage.getToday()
const thisMonth = today.substring(0, 7) const thisMonth = today.substring(0, 7)
const thisYear = String(new Date().getFullYear()) const thisYear = String(new Date().getFullYear())
const profile = storage.getProfile()
const localName = profile.nickname || '我'
let duration = 0 let duration = 0
let sessions = 0 let sessions = 0
@@ -103,7 +109,8 @@ Page({
if (duration > 0) { if (duration > 0) {
const entry = { 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 }) this.setData({ rankedList: [entry], myEntry: { ...entry, isMe: true }, loading: false, empty: false })
} else { } else {
+85 -36
View File
@@ -3,6 +3,7 @@ const themeMod = require('../../utils/theme')
const planMod = require('../../utils/plan') const planMod = require('../../utils/plan')
const iconsMod = require('../../utils/icons') const iconsMod = require('../../utils/icons')
const cloud = require('../../utils/cloud') const cloud = require('../../utils/cloud')
const config = require('../../config')
/** /**
* Build the simplified plans list shown in the settings UI by overlaying * Build the simplified plans list shown in the settings UI by overlaying
@@ -51,18 +52,27 @@ Page({
voiceGuide: true, voiceGuide: true,
vibrate: true, vibrate: true,
icons: iconsMod.build(), icons: iconsMod.build(),
nickName: '',
avatarUrl: '',
// Editor state // Editor state
showPlanEditor: false, showPlanEditor: false,
editingPlanId: '', editingPlanId: '',
editingIsCustom: false, editingIsCustom: false,
editingDraft: null, // { name, totalDays, startTarget, increment, cycleDays } 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() { onLoad() {
const s = storage.getSettings() const s = storage.getSettings()
const theme = themeMod.getCurrentTheme() const theme = themeMod.getCurrentTheme()
themeMod.applyThemeToPage(this) themeMod.applyThemeToPage(this)
const profile = storage.getProfile()
this.setData({ this.setData({
currentPlanId: s.planId, currentPlanId: s.planId,
voiceGuide: s.voiceGuide, voiceGuide: s.voiceGuide,
@@ -70,7 +80,9 @@ Page({
currentThemeId: theme.id, currentThemeId: theme.id,
theme, theme,
icons: iconsMod.build(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) {} } catch (e) {}
themeMod.applyThemeToPage(this) themeMod.applyThemeToPage(this)
const theme = themeMod.getCurrentTheme() const theme = themeMod.getCurrentTheme()
const profile = storage.getProfile()
this.setData({ this.setData({
currentThemeId: theme.id, currentThemeId: theme.id,
theme, theme,
icons: iconsMod.build(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) { onSelectTheme(e) {
const id = e.currentTarget.dataset.id const id = e.currentTarget.dataset.id
const theme = themeMod.setTheme(id) const theme = themeMod.setTheme(id)
@@ -136,47 +179,53 @@ Page({
onCopyWechat() { onCopyWechat() {
wx.setClipboardData({ wx.setClipboardData({
data: '刘承', data: config.developer,
success: () => { success: () => {
wx.showToast({ title: '已复制', icon: 'success', duration: 1500 }) 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() { onClearData() {
wx.showModal({ this.setData({ showClearConfirm: true })
title: '清除数据', },
content: '将删除所有训练记录和连续打卡数据,此操作不可恢复。确定继续吗?',
confirmText: '确定清除', onCancelClear() {
confirmColor: '#E53935', this.setData({ showClearConfirm: false })
success: async (res) => { },
if (!res.confirm) return
wx.removeStorageSync('training_records') onConfirmClear() {
wx.removeStorageSync('current_streak') this.setData({ showClearConfirm: false })
wx.removeStorageSync('user_settings') this._doClearData()
wx.removeStorageSync('app_theme') },
wx.removeStorageSync('custom_plans')
try { await cloud.clearAll() } catch (e) {} async _doClearData() {
wx.setStorageSync('user_settings', { wx.removeStorageSync('training_records')
planId: 'beginner', wx.removeStorageSync('current_streak')
voiceGuide: true, wx.removeStorageSync('user_settings')
vibrate: true, wx.removeStorageSync('app_theme')
planStartDate: storage.getToday() wx.removeStorageSync('custom_plans')
}) try { await cloud.clearAll() } catch (e) {}
wx.setStorageSync('current_streak', { count: 0, lastDate: '' }) wx.setStorageSync('user_settings', {
themeMod.setTheme('orange') planId: 'beginner',
this.setData({ voiceGuide: true,
currentPlanId: 'beginner', vibrate: true,
voiceGuide: true, planStartDate: storage.getToday()
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 })
}
}) })
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 -------- // -------- Plan editor --------
+2 -1
View File
@@ -1,7 +1,8 @@
{ {
"usingComponents": { "usingComponents": {
"ui-card": "/components/ui-card/ui-card", "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": "设置" "navigationBarTitleText": "设置"
} }
+59 -3
View File
@@ -1,4 +1,38 @@
<view class="container" style="{{themeStyle}}"> <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"> <ui-card variant="in">
<view class="section-header"> <view class="section-header">
@@ -98,15 +132,15 @@
<view class="about-list"> <view class="about-list">
<view class="about-row"> <view class="about-row">
<text class="about-label">版本</text> <text class="about-label">版本</text>
<text class="about-value">v1.5</text> <text class="about-value">{{appVersion}}</text>
</view> </view>
<view class="about-row"> <view class="about-row">
<text class="about-label">更新日期</text> <text class="about-label">更新日期</text>
<text class="about-value">2026-06-10</text> <text class="about-value">{{appUpdatedAt}}</text>
</view> </view>
<view class="about-row" bindtap="onCopyWechat"> <view class="about-row" bindtap="onCopyWechat">
<text class="about-label">开发者</text> <text class="about-label">开发者</text>
<text class="about-value about-link">刘承</text> <text class="about-value about-link">{{appDeveloper}}</text>
</view> </view>
</view> </view>
<view class="about-footer"> <view class="about-footer">
@@ -207,4 +241,26 @@
<text class="editor-cancel" bindtap="onCloseEditor">取消</text> <text class="editor-cancel" bindtap="onCloseEditor">取消</text>
</view> </view>
</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> </view>
+137
View File
@@ -226,6 +226,89 @@
font-weight: 500; 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 { .about-footer {
margin-top: 24rpx; margin-top: 24rpx;
padding-top: 20rpx; padding-top: 20rpx;
@@ -440,3 +523,57 @@
color: var(--text-secondary); color: var(--text-secondary);
padding: 12rpx 0 0; 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, "compileHotReLoad": false,
"lazyloadPlaceholderEnable": false, "lazyloadPlaceholderEnable": false,
"preloadBackgroundData": false, "preloadBackgroundData": false,
"minified": true, "minified": false,
"autoAudits": false, "autoAudits": false,
"newFeature": false, "newFeature": false,
"uglifyFileName": false, "uglifyFileName": false,
+99 -43
View File
@@ -4,7 +4,10 @@ const ENV_ID = 'cloudbase-d1g56kl2q8f4f7d8a'
let _db = null let _db = null
let _pushTimer = null let _pushTimer = null
let _enabled = false 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 = () => { const getDb = () => {
if (!_enabled) return null if (!_enabled) return null
@@ -27,36 +30,55 @@ const pullAll = async () => {
const db = getDb() const db = getDb()
if (!db) return null if (!db) return null
try { try {
// First try cached doc id // Always need the openid to safely locate OUR doc under the custom
if (_docId) { // security rule. Cache it on first call so subsequent pulls are fast.
try { if (!_openid) {
const res = await db.collection(DB_COLLECTION).doc(_docId).get() _openid = await _fetchOpenid()
if (res && res.data) return res.data
} catch (e) {
_docId = null // doc deleted or inaccessible
}
} }
// Query: rely on security rules to scope to current user if (!_openid) return null // cloud function call failed; bail
const res = await db.collection(DB_COLLECTION).limit(1).get()
if (res && res.data && res.data.length > 0) { // Locate our doc by _openid, not by limit(1) which can pick someone
_docId = res.data[0]._id // else's doc when the custom rule is in effect.
return res.data[0] 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 return null
} catch (e) { } catch (e) {
return null 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 = () => { const pushAll = () => {
if (!_enabled) return if (!_enabled) { console.log('[cloud] pushAll skipped (not enabled)'); return }
if (_pushTimer) clearTimeout(_pushTimer) if (_pushTimer) clearTimeout(_pushTimer)
console.log('[cloud] pushAll scheduled')
_pushTimer = setTimeout(() => _doPush(), 2000) _pushTimer = setTimeout(() => _doPush(), 2000)
} }
const _doPush = async () => { const _doPush = async () => {
console.log('[cloud] _doPush starting...')
const db = getDb() const db = getDb()
if (!db) return if (!db) { console.log('[cloud] _doPush aborted (no db)'); return }
try { try {
const storage = require('./storage') const storage = require('./storage')
const themeMod = require('./theme') const themeMod = require('./theme')
@@ -66,55 +88,89 @@ const _doPush = async () => {
settings: storage.getSettings(), settings: storage.getSettings(),
streak: storage.getStreak(), streak: storage.getStreak(),
customPlans: storage.getCustomPlans(), customPlans: storage.getCustomPlans(),
profile: storage.getProfile(),
themeId: themeMod.getCurrentTheme().id, themeId: themeMod.getCurrentTheme().id,
updatedAt: db.serverDate() updatedAt: db.serverDate()
} }
console.log('[cloud] _doPush records keys:', Object.keys(data.records || {}))
// Use cached doc id if available // Use cached doc id if available
if (_docId) { if (_docId) {
try { try {
await db.collection(DB_COLLECTION).doc(_docId).update({ data }) await db.collection(DB_COLLECTION).doc(_docId).update({ data })
console.log('[cloud] _doPush update ok')
return return
} catch (e) { } 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) // Fallback: locate OUR doc by _openid (cached from a previous add) and
const existing = await db.collection(DB_COLLECTION).limit(1).get() // update it. If we have no cached openid yet (first ever push) just add.
if (existing && existing.data && existing.data.length > 0) { if (_openid) {
_docId = existing.data[0]._id const existing = await db.collection(DB_COLLECTION)
await db.collection(DB_COLLECTION).doc(_docId).update({ data }) .where({ _openid: _openid })
} else { .limit(1)
const res = await db.collection(DB_COLLECTION).add({ data }) .get()
if (res && res._id) _docId = res._id 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) { } catch (e) {
// Silently retry on next write console.log('[cloud] _doPush error:', e.message || e)
} }
} }
const clearAll = async () => { const clearAll = async () => {
const db = getDb() const db = getDb()
if (!db) return if (!db) return
try {
if (_docId) { // Ensure _openid is cached before trying to delete — without it we
try { // can't reliably locate the user's doc (add() only returns _id).
await db.collection(DB_COLLECTION).doc(_docId).remove() if (!_openid) {
_docId = null _openid = await _fetchOpenid()
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
} }
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 } } 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 cloud = require('./cloud')
const RECORDS_KEY = 'training_records' const RECORDS_KEY = 'training_records'
const SETTINGS_KEY = 'user_settings' const SETTINGS_KEY = 'user_settings'
const STREAK_KEY = 'current_streak' const STREAK_KEY = 'current_streak'
const CUSTOM_PLANS_KEY = 'custom_plans' const CUSTOM_PLANS_KEY = 'custom_plans'
const PROFILE_KEY = 'user_profile'
/** /**
* Backfill an `id` for any record that doesn't have one. * Backfill an `id` for any record that doesn't have one.
@@ -52,6 +53,7 @@ const saveRecord = (record) => {
records[month].push(record) records[month].push(record)
records[month].sort((a, b) => b.date.localeCompare(a.date)) records[month].sort((a, b) => b.date.localeCompare(a.date))
wx.setStorageSync(RECORDS_KEY, records) wx.setStorageSync(RECORDS_KEY, records)
console.log('[storage] saveRecord date=' + record.date + ' duration=' + record.duration)
cloud.pushAll() cloud.pushAll()
} }
@@ -105,6 +107,34 @@ const saveSettings = (settings) => {
cloud.pushAll() 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: '' } const getStreak = () => wx.getStorageSync(STREAK_KEY) || { count: 0, lastDate: '' }
/** /**
@@ -142,25 +172,30 @@ const resetCustomPlan = (planId) => {
* Validate streak against actual training records. * Validate streak against actual training records.
* Call on app launch to correct any inconsistencies * Call on app launch to correct any inconsistencies
* (e.g. after deleting the only record for a day). * (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 validateStreak = () => {
const streak = getStreak() const streak = getStreak()
if (!streak.lastDate) return streak if (!streak.lastDate) return streak
const today = getToday() const today = getToday()
const yesterday = getDateOffset(today, -1) const todayOnly = dateOnly(today)
const yesterdayOnly = dateOnly(getDateOffset(today, -1))
const records = getRecords() const records = getRecords()
const allDates = new Set() const allDates = new Set()
Object.values(records).forEach(monthRecs => { 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 lastDate is today, check today still has records
if (streak.lastDate === today) { if (dateOnly(streak.lastDate) === todayOnly) {
if (!allDates.has(today)) { if (!allDates.has(todayOnly)) {
// Today's records were deleted // Today's records were deleted
if (allDates.has(yesterday)) { if (allDates.has(yesterdayOnly)) {
streak.lastDate = yesterday streak.lastDate = yesterdayOnly
streak.count = Math.max(1, streak.count - 1) streak.count = Math.max(1, streak.count - 1)
} else { } else {
streak.count = 0 streak.count = 0
@@ -178,17 +213,20 @@ const validateStreak = () => {
const updateStreak = (date) => { const updateStreak = (date) => {
const streak = getStreak() const streak = getStreak()
const today = date || getToday() const todayFull = date || getToday()
const yesterday = getDateOffset(today, -1) 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 streak.count += 1
} else { } else {
streak.count = 1 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) wx.setStorageSync(STREAK_KEY, streak)
cloud.pushAll() cloud.pushAll()
return streak return streak
@@ -197,8 +235,10 @@ const updateStreak = (date) => {
const getToday = () => formatDate(new Date()) const getToday = () => formatDate(new Date())
const getDateOffset = (dateStr, offset) => { const getDateOffset = (dateStr, offset) => {
// Use YYYY/MM/DD for iOS compatibility (YYYY-MM-DD may fail on some iOS) // Strip the time portion (formatDate now includes HH:MM:SS) and convert
const d = new Date(dateStr.replace(/-/g, '/')) // 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) d.setDate(d.getDate() + offset)
return formatDate(d) return formatDate(d)
} }
@@ -230,6 +270,8 @@ module.exports = {
getCustomPlans, getCustomPlans,
saveCustomPlan, saveCustomPlan,
resetCustomPlan, resetCustomPlan,
getProfile,
saveProfile,
getStreak, getStreak,
validateStreak, validateStreak,
updateStreak, updateStreak,
+18 -3
View File
@@ -33,17 +33,32 @@ const getMonthCalendar = (year, month) => {
return weeks 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 formatDate = (date) => {
const y = date.getFullYear() 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') 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 = { module.exports = {
formatTime, formatTime,
formatDuration, formatDuration,
getDaysInMonth, getDaysInMonth,
getMonthCalendar, getMonthCalendar,
formatDate formatDate,
dateOnly: _dateOnly
} }
+43 -10
View File
@@ -2,12 +2,15 @@
* Voice prompt utility for the timer. * Voice prompt utility for the timer.
* *
* 4 fixed, polished prompts live in `cloudfunctions/tts`. We call that * 4 fixed, polished prompts live in `cloudfunctions/tts`. We call that
* cloud function with a key, get back an audio URL (cached in cloud * cloud function with a key, get back an audio URL and permanent fileID,
* storage after first synthesis), and play it via createInnerAudioContext. * and play it via createInnerAudioContext.
* *
* The in-memory `_urlCache` avoids hitting the cloud function more than * Two-level caching so TTS synthesis happens at most once per prompt,
* once per prompt per session. The audio context is a singleton so * ever (not once per app launch):
* overlapping prompts interrupt each other cleanly. * 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 * If the cloud function is unconfigured (no TTS credentials) and returns
* `success: false`, we fall back to a silent no-op — better than spamming * `success: false`, we fall back to a silent no-op — better than spamming
@@ -21,8 +24,24 @@ const PROMPTS = {
complete: '太棒了!今天的目标已完成,继续加油!' complete: '太棒了!今天的目标已完成,继续加油!'
} }
const FILEID_CACHE_KEY = 'tts_fileid_cache'
let _ctx = null 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 = () => { const _getCtx = () => {
if (!_ctx) { if (!_ctx) {
@@ -30,23 +49,37 @@ const _getCtx = () => {
// Play even when the device is in silent mode — we want the user to // Play even when the device is in silent mode — we want the user to
// actually hear training cues during a session. // actually hear training cues during a session.
_ctx.obeyMuteSwitch = false _ctx.obeyMuteSwitch = false
_ctx.onError((err) => {
console.log('[voice] audio error:', err.errCode, err.errMsg)
})
} }
return _ctx return _ctx
} }
const _fetchUrl = async (promptKey) => { const _fetchUrl = async (promptKey) => {
// 1. In-memory cache: same-session instant return
if (_urlCache[promptKey]) return _urlCache[promptKey] if (_urlCache[promptKey]) return _urlCache[promptKey]
// 2. Persistent fileID cache: pass to cloud function for instant getTempFileURL
_loadFileIDCache()
const cachedFileID = _fileIDCache[promptKey] || null
try { try {
const res = await wx.cloud.callFunction({ const res = await wx.cloud.callFunction({
name: 'tts', name: 'tts',
data: { promptKey }, data: { promptKey, fileID: cachedFileID },
// Default WeChat cloud timeout is 3s, which is exactly the TTS // Timeout: 30s for first synthesis (cold start), <1s for cached
// first-call budget (synth + upload + tempURL). Bump to 30s for
// cold starts; cached calls return in <500ms.
config: { timeout: 30000 } config: { timeout: 30000 }
}) })
if (res && res.result && res.result.success && res.result.audioUrl) { if (res && res.result && res.result.success && res.result.audioUrl) {
_urlCache[promptKey] = 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 return res.result.audioUrl
} }
} catch (e) { } catch (e) {