feat: v1.5 — plan editor, voice prompts, UI polish
Major features: - Training plan editor: edit preset days/duration per plan (in-place override via custom_plans storage; preset ids preserved so existing records stay valid) - Voice prompts at 4 fixed points during training (halfway / 30s / 10s / done) via Tencent Cloud TTS (cloudfunctions/tts + utils/voice.js) - Free-training button: switched from outline (transparent) to ghost variant (theme-tinted) for visual weight Bug fixes: - 4 functional pages: SVG data URIs failed to render in WeChat because '\#' in colors was parsed as a data-URI fragment delimiter. encode the whole SVG via encodeURIComponent in utils/icons.js build(). - Old records (saved before id field was added) could not be deleted (data-id was empty, triggered defensive guard). Backfill stable legacy-<month>-<index>-<duration> ids in getRecords() and persist. - settings.js plan-row editor button event was bubbling up to the row's bindtap (which also fired onSelectPlan). Wrapped in catch:tap. UI: - Settings page: 3 hardcoded plans replaced with dynamic buildPlansList that overlays custom_plans on top of presets - Plan editor: bottom-sheet modal in settings page (regular view, not ui-modal — WeChat custom component root element drops position:fixed in this runtime) - Free-training button: rounded pill style, full-width, 24rpx gap from primary action; bottom sheet uses max-height: 88vh + internal scroll - Version bumped to v1.5, last updated 2026-06-10 Removed: - Daily reminder section (dailyReminder / reminderTime) — replaced by the 4 voice prompts which cover the same user need without requiring long-term scheduling that WeChat mini-programs can't actually do Misc: - utils/plan.js refactored to formula-driven: presets declare totalDays/startTarget/increment/cycleDays, days[] is generated. Same formula applies to custom plans. - Timer _remind() guards each prompt on minimum duration so short free-mode sessions don't fire 'last30' at the start - Cloud storage cloud_plans field added to data push payload; restored on first install via _restoreFromCloud - .gitignore added for local AI tool caches (.reasonix/, reasonix.toml, .codegraph/daemon.pid)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,103 @@
|
||||
const cloud = require('wx-server-sdk')
|
||||
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
|
||||
const db = cloud.database()
|
||||
const _ = db.command
|
||||
|
||||
const COLLECTION = 'plank_data'
|
||||
const PAGE_SIZE = 100
|
||||
const MAX_RANK = 100
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
|
||||
exports.main = async (event) => {
|
||||
const { period } = event
|
||||
if (!period) return { err: 'missing period' }
|
||||
|
||||
const now = new Date()
|
||||
const today = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
||||
const thisMonth = today.substring(0, 7)
|
||||
const thisYear = String(now.getFullYear())
|
||||
const myOpenid = cloud.getWXContext().OPENID
|
||||
|
||||
let prefix, exact
|
||||
if (period === 'day') { exact = today; prefix = null }
|
||||
else if (period === 'month') { prefix = thisMonth; exact = null }
|
||||
else if (period === 'year') { prefix = thisYear; exact = null }
|
||||
else return { err: 'invalid period' }
|
||||
|
||||
const userMap = new Map()
|
||||
let skip = 0
|
||||
|
||||
while (true) {
|
||||
const res = await db.collection(COLLECTION).skip(skip).limit(PAGE_SIZE).get()
|
||||
if (!res.data || res.data.length === 0) break
|
||||
|
||||
for (const doc of res.data) {
|
||||
const records = doc.records || {}
|
||||
let duration = 0
|
||||
let sessions = 0
|
||||
|
||||
for (const monthKey of Object.keys(records)) {
|
||||
if (prefix && !monthKey.startsWith(prefix)) continue
|
||||
for (const r of records[monthKey]) {
|
||||
if (period === 'day') {
|
||||
if (r.date === exact) { duration += r.duration; sessions++ }
|
||||
} else {
|
||||
if (r.date.startsWith(prefix)) { duration += r.duration; sessions++ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (duration > 0) {
|
||||
const openid = doc._openid || 'unknown'
|
||||
const entry = userMap.get(openid)
|
||||
if (entry) {
|
||||
entry.duration += duration
|
||||
entry.sessions += sessions
|
||||
} else {
|
||||
userMap.set(openid, { openid, duration, sessions })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (res.data.length < PAGE_SIZE) break
|
||||
skip += PAGE_SIZE
|
||||
}
|
||||
|
||||
// Full sorted list (for ranking)
|
||||
const allSorted = Array.from(userMap.values())
|
||||
.sort((a, b) => b.duration - a.duration)
|
||||
|
||||
// My entry always included, even if outside top N
|
||||
const myEntry = userMap.get(myOpenid)
|
||||
const myRank = myEntry ? allSorted.findIndex(e => e.openid === myOpenid) + 1 : 0
|
||||
|
||||
// Top N for the board
|
||||
const ranked = allSorted.slice(0, MAX_RANK).map((item, i) => ({
|
||||
rank: i + 1,
|
||||
openid: item.openid,
|
||||
name: maskOpenid(item.openid),
|
||||
duration: item.duration,
|
||||
sessions: item.sessions
|
||||
}))
|
||||
|
||||
return {
|
||||
period,
|
||||
ranked,
|
||||
myOpenid,
|
||||
myEntry: myEntry ? {
|
||||
rank: myRank,
|
||||
openid: myEntry.openid,
|
||||
name: maskOpenid(myEntry.openid),
|
||||
duration: myEntry.duration,
|
||||
sessions: myEntry.sessions
|
||||
} : null,
|
||||
updatedAt: now.toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
function maskOpenid(openid) {
|
||||
if (!openid || openid === 'unknown') return '未知用户'
|
||||
if (openid.length <= 4) return '****' + openid
|
||||
return '****' + openid.slice(-4)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "leaderboard",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"wx-server-sdk": "latest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// 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.
|
||||
//
|
||||
// Configuration (云开发控制台 → 云函数 → tts → 函数配置 → 环境变量):
|
||||
// TTS_SECRET_ID — Tencent Cloud API key id
|
||||
// TTS_SECRET_KEY — Tencent Cloud API key
|
||||
// TTS_REGION — (optional) defaults to ap-guangzhou
|
||||
//
|
||||
// NOTE: env var names cannot start with SCF_ / QCLOUD_ / TENCENTCLOUD_ —
|
||||
// those prefixes are reserved by Tencent Cloud SCF. Use the TTS_ prefix.
|
||||
|
||||
const cloud = require('wx-server-sdk')
|
||||
cloud.init()
|
||||
|
||||
// Polished, fixed prompts.
|
||||
const PROMPTS = {
|
||||
halfway: '已完成一半啦,坚持就是胜利!',
|
||||
last30: '最后30秒,保持呼吸,稳住姿势!',
|
||||
last10: '最后10秒,再加把劲!',
|
||||
complete: '太棒了!今天的目标已完成,继续加油!'
|
||||
}
|
||||
|
||||
const CACHE_DIR = 'tts-cache'
|
||||
|
||||
const _synthesize = async (text, promptKey) => {
|
||||
let TtsClient
|
||||
try {
|
||||
TtsClient = require('tencentcloud-sdk-nodejs').tts.v20190823.Client
|
||||
} catch (e) {
|
||||
throw new Error('tencentcloud-sdk-nodejs not installed. Run `npm i tencentcloud-sdk-nodejs` in this cloud function directory.')
|
||||
}
|
||||
|
||||
const secretId = process.env.TTS_SECRET_ID
|
||||
const secretKey = process.env.TTS_SECRET_KEY
|
||||
if (!secretId || !secretKey) {
|
||||
throw new Error('TTS_SECRET_ID / TTS_SECRET_KEY not configured.')
|
||||
}
|
||||
|
||||
const client = new TtsClient({
|
||||
credential: { secretId, secretKey },
|
||||
region: process.env.TTS_REGION || 'ap-guangzhou'
|
||||
})
|
||||
|
||||
const res = await client.TextToVoice({
|
||||
Text: text,
|
||||
SessionId: `${promptKey}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
||||
VoiceType: 101001, // 智瑜,温柔女声
|
||||
Codec: 'mp3',
|
||||
SampleRate: 16000,
|
||||
Speed: 0,
|
||||
Volume: 5
|
||||
})
|
||||
return Buffer.from(res.Audio, 'base64')
|
||||
}
|
||||
|
||||
const _upload = async (key, buffer) => {
|
||||
const cloudPath = `${CACHE_DIR}/${key}.mp3`
|
||||
// uploadFile returns the canonical fileID (e.g. "cloud://env.xxx/...").
|
||||
// We must use THAT — not the relative cloudPath we passed in — when
|
||||
// calling getTempFileURL. A self-constructed fileID is invalid.
|
||||
const res = await cloud.uploadFile({ cloudPath, fileContent: buffer })
|
||||
return res.fileID
|
||||
}
|
||||
|
||||
exports.main = async (event) => {
|
||||
const { promptKey } = event || {}
|
||||
const text = PROMPTS[promptKey]
|
||||
if (!text) {
|
||||
return { success: false, error: `Unknown prompt key: ${promptKey}` }
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await _synthesize(text, promptKey)
|
||||
const newFileID = await _upload(promptKey, buffer)
|
||||
const urlRes = await cloud.getTempFileURL({ fileList: [newFileID] })
|
||||
const url = urlRes.fileList[0].tempFileURL
|
||||
if (!url) {
|
||||
return { success: false, error: 'getTempFileURL returned empty URL', text }
|
||||
}
|
||||
return { success: true, audioUrl: url, text }
|
||||
} catch (e) {
|
||||
return { success: false, error: e.message, text }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "tts",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"wx-server-sdk": "latest",
|
||||
"tencentcloud-sdk-nodejs": "^4.0.0"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user