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:
@@ -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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "getOpenid",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "index.js",
|
||||||
|
"dependencies": {
|
||||||
|
"wx-server-sdk": "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
@@ -100,4 +130,4 @@ function maskOpenid(openid) {
|
|||||||
if (!openid || openid === 'unknown') return '未知用户'
|
if (!openid || openid === 'unknown') return '未知用户'
|
||||||
if (openid.length <= 4) return '****' + openid
|
if (openid.length <= 4) return '****' + openid
|
||||||
return '****' + openid.slice(-4)
|
return '****' + openid.slice(-4)
|
||||||
}
|
}
|
||||||
+23
-15
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* 项目全局配置 — 集中管理版本号、更新日期、开发者等信息。
|
||||||
|
* 页面和组件通过 require('../config') 引用,打包时静态替换。
|
||||||
|
*/
|
||||||
|
module.exports = {
|
||||||
|
/** 应用版本号,外显在设置页「关于」 */
|
||||||
|
version: 'v1.5',
|
||||||
|
|
||||||
|
/** 最后更新日期,外显在设置页「关于」 */
|
||||||
|
updatedAt: '2026-06-11',
|
||||||
|
|
||||||
|
/** 开发者名称 / 微信号,设置页点击可复制 */
|
||||||
|
developer: '刘承',
|
||||||
|
|
||||||
|
/** 排行榜最大显示人数 */
|
||||||
|
leaderboardMaxRank: 15
|
||||||
|
}
|
||||||
@@ -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
@@ -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 --------
|
||||||
|
|||||||
@@ -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": "设置"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user