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"
}
}
+40 -10
View File
@@ -5,18 +5,28 @@ const _ = db.command
const COLLECTION = 'plank_data'
const PAGE_SIZE = 100
const MAX_RANK = 100
const DEFAULT_MAX_RANK = 100 // fallback if client doesn't pass maxRank
const pad = (n) => String(n).padStart(2, '0')
// Fixed UTC+8 offset — the user base is China-only and `new Date()` in
// the cloud function runs in UTC, so without this shift "today" rolls
// over at 08:00 Beijing instead of 00:00. If you ever support users in
// other timezones, switch back to a client-provided date.
const TZ_OFFSET_MS = 8 * 60 * 60 * 1000
exports.main = async (event) => {
const { period } = event
const { period, maxRank } = event || {}
const limit = Math.max(1, Math.min(parseInt(maxRank) || DEFAULT_MAX_RANK, 500))
if (!period) return { err: 'missing period' }
const now = new Date()
const today = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
const local = new Date(now.getTime() + TZ_OFFSET_MS)
const today = `${local.getUTCFullYear()}-${pad(local.getUTCMonth() + 1)}-${pad(local.getUTCDate())}`
const thisMonth = today.substring(0, 7)
const thisYear = String(now.getFullYear())
// Year is computed from the same shifted date so year boundaries also
// align with Beijing's midnight, not UTC's.
const thisYear = String(local.getUTCFullYear())
const myOpenid = cloud.getWXContext().OPENID
let prefix, exact
@@ -41,7 +51,11 @@ exports.main = async (event) => {
if (prefix && !monthKey.startsWith(prefix)) continue
for (const r of records[monthKey]) {
if (period === 'day') {
if (r.date === exact) { duration += r.duration; sessions++ }
// Use startsWith rather than === to tolerate date strings with
// trailing time/zone info (e.g. "2026-06-11T09:11:24.587Z" if
// some legacy path stored an ISO date) and to be timezone-
// agnostic when client local date differs from server UTC date.
if (r.date.startsWith(exact)) { duration += r.duration; sessions++ }
} else {
if (r.date.startsWith(prefix)) { duration += r.duration; sessions++ }
}
@@ -51,11 +65,19 @@ exports.main = async (event) => {
if (duration > 0) {
const openid = doc._openid || 'unknown'
const entry = userMap.get(openid)
const profile = doc.profile || {}
if (entry) {
entry.duration += duration
entry.sessions += sessions
// Refresh nickname in case the user updated it since last write
if (profile.nickname) entry.nickname = profile.nickname
} else {
userMap.set(openid, { openid, duration, sessions })
userMap.set(openid, {
openid,
duration,
sessions,
nickname: profile.nickname || ''
})
}
}
}
@@ -72,11 +94,18 @@ exports.main = async (event) => {
const myEntry = userMap.get(myOpenid)
const myRank = myEntry ? allSorted.findIndex(e => e.openid === myOpenid) + 1 : 0
// Resolve display name: prefer the user's chosen nickname, fall back to
// a masked openid for users who haven't set one.
const _displayName = (entry) =>
(entry && entry.nickname && entry.nickname.trim()) ||
maskOpenid(entry && entry.openid)
// Top N for the board
const ranked = allSorted.slice(0, MAX_RANK).map((item, i) => ({
const ranked = allSorted.slice(0, limit).map((item, i) => ({
rank: i + 1,
openid: item.openid,
name: maskOpenid(item.openid),
nickname: item.nickname || '',
name: _displayName(item),
duration: item.duration,
sessions: item.sessions
}))
@@ -88,7 +117,8 @@ exports.main = async (event) => {
myEntry: myEntry ? {
rank: myRank,
openid: myEntry.openid,
name: maskOpenid(myEntry.openid),
nickname: myEntry.nickname || '',
name: _displayName(myEntry),
duration: myEntry.duration,
sessions: myEntry.sessions
} : null,
@@ -100,4 +130,4 @@ function maskOpenid(openid) {
if (!openid || openid === 'unknown') return '未知用户'
if (openid.length <= 4) return '****' + openid
return '****' + openid.slice(-4)
}
}
+23 -15
View File
@@ -1,18 +1,11 @@
// Cloud function: synthesize the 4 fixed training-voice prompts.
//
// We do NOT cache on the server. Two reasons:
// 1. Caching via cloud storage requires self-constructing a fileID of
// the form `cloud://<full-env-id>/...`, but `getWXContext().ENV`
// only returns the simple env id. Mismatched → unopenable fileID.
// 2. Caching via the cloud database requires `cloud.database()` at
// top level, which on this WeChat cloud runtime crashes the SCF
// framework's `writeRuntimeFile` step (it can't toString() the
// error object the SDK throws).
//
// The client (`utils/voice.js`) caches audioUrls in memory per session,
// so each of the 4 prompts is synthesized at most once per app launch.
// Re-synthesizing on app restart is fine — 800万 chars/month free tier
// covers 4 × ~30 chars × thousands of restarts per month.
// Caching strategy: the client stores the permanent `fileID` returned by
// the first successful synthesis in local storage. On subsequent calls it
// passes `fileID` back to us; we call `getTempFileURL` with it to get a
// fresh temporary URL instantly (no synthesis, no upload). If the cached
// fileID is stale (file deleted from cloud storage), we fall through and
// re-synthesize, returning the new fileID so the client can update its cache.
//
// Configuration (云开发控制台 → 云函数 → tts → 函数配置 → 环境变量):
// TTS_SECRET_ID — Tencent Cloud API key id
@@ -76,12 +69,27 @@ const _upload = async (key, buffer) => {
}
exports.main = async (event) => {
const { promptKey } = event || {}
const { promptKey, fileID } = event || {}
const text = PROMPTS[promptKey]
if (!text) {
return { success: false, error: `Unknown prompt key: ${promptKey}` }
}
// If the client passed a cached fileID, try to get a fresh temp URL
// from it — this is the fast path (no synthesis, no upload).
if (fileID) {
try {
const urlRes = await cloud.getTempFileURL({ fileList: [fileID] })
const url = urlRes.fileList[0].tempFileURL
if (url) {
return { success: true, audioUrl: url, fileID, text, cached: true }
}
} catch (e) {
// fileID stale or file deleted — fall through to re-synthesize
}
}
// Slow path: synthesize + upload + return fresh fileID for caching
try {
const buffer = await _synthesize(text, promptKey)
const newFileID = await _upload(promptKey, buffer)
@@ -90,7 +98,7 @@ exports.main = async (event) => {
if (!url) {
return { success: false, error: 'getTempFileURL returned empty URL', text }
}
return { success: true, audioUrl: url, text }
return { success: true, audioUrl: url, fileID: newFileID, text, cached: false }
} catch (e) {
return { success: false, error: e.message, text }
}