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:
2026-06-10 17:23:13 +08:00
parent 90e0e64156
commit 1d55df72b3
54 changed files with 2710 additions and 934 deletions
+97
View File
@@ -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 }
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "tts",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"wx-server-sdk": "latest",
"tencentcloud-sdk-nodejs": "^4.0.0"
}
}