From 1d55df72b35fe1c4d3590141524921aab7ce2e16 Mon Sep 17 00:00:00 2001 From: cnliucheng Date: Wed, 10 Jun 2026 17:23:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v1.5=20=E2=80=94=20plan=20editor,=20voi?= =?UTF-8?q?ce=20prompts,=20UI=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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--- 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) --- .codegraph/daemon.pid | 6 - .gitignore | 4 + app.js | 27 +- app.json | 2 + app.wxss | 133 ++------ cloudfunctions/leaderboard/config.json | 1 + cloudfunctions/leaderboard/index.js | 103 ++++++ cloudfunctions/leaderboard/package.json | 8 + cloudfunctions/tts/index.js | 97 ++++++ cloudfunctions/tts/package.json | 9 + .../calendar-heatmap/calendar-heatmap.js | 61 ++-- .../calendar-heatmap/calendar-heatmap.wxml | 8 +- components/ui-btn/ui-btn.js | 16 + components/ui-btn/ui-btn.json | 4 + components/ui-btn/ui-btn.wxml | 15 + components/ui-btn/ui-btn.wxss | 92 ++++++ components/ui-card/ui-card.js | 8 + components/ui-card/ui-card.json | 4 + components/ui-card/ui-card.wxml | 3 + components/ui-card/ui-card.wxss | 34 ++ components/ui-modal/ui-modal.js | 17 + components/ui-modal/ui-modal.json | 4 + components/ui-modal/ui-modal.wxml | 7 + components/ui-modal/ui-modal.wxss | 61 ++++ custom-tab-bar/index.js | 59 +++- custom-tab-bar/index.wxml | 31 +- custom-tab-bar/index.wxss | 132 ++------ pages/index/index.js | 35 +- pages/index/index.json | 6 +- pages/index/index.wxml | 184 ++++++----- pages/index/index.wxss | 133 +++----- pages/leaderboard/leaderboard.js | 113 +++++++ pages/leaderboard/leaderboard.json | 6 + pages/leaderboard/leaderboard.wxml | 57 ++++ pages/leaderboard/leaderboard.wxss | 176 ++++++++++ pages/records/records.js | 56 +++- pages/records/records.json | 4 +- pages/records/records.wxml | 164 +++++----- pages/records/records.wxss | 128 +++++--- pages/settings/settings.js | 301 +++++++++++++++--- pages/settings/settings.json | 5 +- pages/settings/settings.wxml | 174 +++++++--- pages/settings/settings.wxss | 288 +++++++++++++++-- pages/timer/timer.js | 104 +++++- pages/timer/timer.json | 3 +- pages/timer/timer.wxml | 60 ++-- pages/timer/timer.wxss | 144 ++------- project.config.json | 3 +- utils/cloud.js | 71 +++-- utils/icons.js | 136 ++++++++ utils/plan.js | 102 ++++-- utils/storage.js | 131 +++++++- utils/theme.js | 20 +- utils/voice.js | 94 ++++++ 54 files changed, 2710 insertions(+), 934 deletions(-) delete mode 100644 .codegraph/daemon.pid create mode 100644 .gitignore create mode 100644 cloudfunctions/leaderboard/config.json create mode 100644 cloudfunctions/leaderboard/index.js create mode 100644 cloudfunctions/leaderboard/package.json create mode 100644 cloudfunctions/tts/index.js create mode 100644 cloudfunctions/tts/package.json create mode 100644 components/ui-btn/ui-btn.js create mode 100644 components/ui-btn/ui-btn.json create mode 100644 components/ui-btn/ui-btn.wxml create mode 100644 components/ui-btn/ui-btn.wxss create mode 100644 components/ui-card/ui-card.js create mode 100644 components/ui-card/ui-card.json create mode 100644 components/ui-card/ui-card.wxml create mode 100644 components/ui-card/ui-card.wxss create mode 100644 components/ui-modal/ui-modal.js create mode 100644 components/ui-modal/ui-modal.json create mode 100644 components/ui-modal/ui-modal.wxml create mode 100644 components/ui-modal/ui-modal.wxss create mode 100644 pages/leaderboard/leaderboard.js create mode 100644 pages/leaderboard/leaderboard.json create mode 100644 pages/leaderboard/leaderboard.wxml create mode 100644 pages/leaderboard/leaderboard.wxss create mode 100644 utils/icons.js create mode 100644 utils/voice.js diff --git a/.codegraph/daemon.pid b/.codegraph/daemon.pid deleted file mode 100644 index cfc98ed..0000000 --- a/.codegraph/daemon.pid +++ /dev/null @@ -1,6 +0,0 @@ -{ - "pid": 75366, - "version": "0.9.7", - "socketPath": "/Users/liubleed/Documents/wx_pbzc/.codegraph/daemon.sock", - "startedAt": 1780561314477 -} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e18c34 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# Local tool caches / daemons — never project-level +.reasonix/ +reasonix.toml +.codegraph/daemon.pid diff --git a/app.js b/app.js index a8ca155..37322be 100644 --- a/app.js +++ b/app.js @@ -1,5 +1,6 @@ const themeMod = require('./utils/theme') const cloud = require('./utils/cloud') +const storage = require('./utils/storage') App({ onLaunch() { @@ -10,11 +11,14 @@ App({ if (!settings) { wx.setStorageSync('user_settings', { planId: 'beginner', - dailyReminder: true, - reminderTime: '08:00', voiceGuide: true, - vibrate: true + vibrate: true, + planStartDate: storage.getToday() }) + } else if (!settings.planStartDate) { + // Migrate: add planStartDate for existing users + settings.planStartDate = storage.getToday() + wx.setStorageSync('user_settings', settings) } const streak = wx.getStorageSync('current_streak') @@ -24,22 +28,14 @@ App({ this.globalData.theme = themeMod.getCurrentTheme() - // Sync with cloud: pull on empty local, push on empty cloud + // Sync with cloud: pull on empty local, push on non-empty local const records = wx.getStorageSync('training_records') if (!records || Object.keys(records).length === 0) { // Case 1: Fresh install — restore from cloud cloud.pullAll().then(cloudData => this._restoreFromCloud(cloudData)) } else { - // Case 2: Has local data — check if cloud needs seeding - cloud.pullAll().then(cloudData => { - if (!cloudData) { - // Cloud empty: push existing local data up - cloud.pushAll() - } else { - // Both have data — trust local (latest) and re-push - cloud.pushAll() - } - }) + // Case 2: Has local data — push to cloud (local is authoritative) + cloud.pushAll() } }, @@ -55,6 +51,9 @@ App({ if (cloudData.streak) { wx.setStorageSync('current_streak', cloudData.streak) } + if (cloudData.customPlans && typeof cloudData.customPlans === 'object') { + wx.setStorageSync('custom_plans', cloudData.customPlans) + } if (cloudData.themeId) { themeMod.setTheme(cloudData.themeId) } diff --git a/app.json b/app.json index 2dc0ac8..c28af1c 100644 --- a/app.json +++ b/app.json @@ -3,6 +3,7 @@ "pages/index/index", "pages/timer/timer", "pages/records/records", + "pages/leaderboard/leaderboard", "pages/settings/settings" ], "window": { @@ -20,6 +21,7 @@ "list": [ { "pagePath": "pages/index/index", "text": "训练" }, { "pagePath": "pages/records/records", "text": "记录" }, + { "pagePath": "pages/leaderboard/leaderboard", "text": "排行" }, { "pagePath": "pages/settings/settings", "text": "设置" } ] }, diff --git a/app.wxss b/app.wxss index 469d6be..84a2f35 100644 --- a/app.wxss +++ b/app.wxss @@ -10,7 +10,7 @@ page { 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; font-size: 28rpx; color: var(--text); - background-color: var(--bg); + background-color: var(--bg) !important; } .container { @@ -20,131 +20,66 @@ page { box-sizing: border-box; } -.card { - background: var(--card-bg); - border-radius: 24rpx; - padding: 32rpx; - margin-bottom: 24rpx; - box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.06); - animation: cardIn 0.45s ease both; +/* Defensive sizing for all SVG icons rendered as . + * WeChat ignores CSS width/height on when src is a data URI + * unless the source SVG has explicit width/height (which it does now, + * via utils/icons.js). This extra safety belt ensures even unstyled + * icons can't blow out their containers. */ +image { + max-width: 100%; + flex-shrink: 0; } -.card:nth-child(1) { animation-delay: 0.05s; } -.card:nth-child(2) { animation-delay: 0.15s; } -.card:nth-child(3) { animation-delay: 0.25s; } -.card:nth-child(4) { animation-delay: 0.35s; } +/* ---- shared animations (used across pages) ---- */ @keyframes cardIn { - from { - opacity: 0; - transform: translateY(40rpx); - } - to { - opacity: 1; - transform: translateY(0); - } + from { opacity: 0; transform: translateY(40rpx); } + to { opacity: 1; transform: translateY(0); } } -.btn-primary { - background: linear-gradient(135deg, var(--primary), var(--primary-light)); - color: #fff; - border: none; - border-radius: 48rpx; - font-size: 34rpx; - font-weight: 600; - padding: 24rpx 0; - text-align: center; - transition: transform 0.15s ease, box-shadow 0.15s ease; - box-shadow: 0 8rpx 24rpx rgba(var(--primary-rgb), 0.3); -} - -.btn-primary:active { - transform: scale(0.96); - box-shadow: 0 4rpx 12rpx rgba(var(--primary-rgb), 0.2); -} - -/* ---- pulse ring (used on timer page) ---- */ @keyframes breathePulse { - 0%, 100% { - transform: scale(0.92); - opacity: 0.6; - } - 50% { - transform: scale(1.08); - opacity: 0.15; - } + 0%, 100% { transform: scale(0.92); opacity: 0.6; } + 50% { transform: scale(1.08); opacity: 0.15; } } @keyframes breathePulseFast { - 0%, 100% { - transform: scale(0.94); - opacity: 0.7; - } - 50% { - transform: scale(1.06); - opacity: 0.2; - } + 0%, 100% { transform: scale(0.94); opacity: 0.7; } + 50% { transform: scale(1.06); opacity: 0.2; } } -/* ---- float ---- */ @keyframes float { 0%, 100% { transform: translateY(0); } - 50% { transform: translateY(-12rpx); } + 50% { transform: translateY(-12rpx); } } -/* ---- pop / bounce ---- */ @keyframes popIn { - 0% { transform: scale(0); opacity: 0; } - 60% { transform: scale(1.15); } + 0% { transform: scale(0); opacity: 0; } + 60% { transform: scale(1.15); } 100% { transform: scale(1); opacity: 1; } } @keyframes bounceSoft { 0%, 100% { transform: translateY(0); } - 30% { transform: translateY(-16rpx); } - 50% { transform: translateY(0); } - 70% { transform: translateY(-6rpx); } + 30% { transform: translateY(-16rpx); } + 50% { transform: translateY(0); } + 70% { transform: translateY(-6rpx); } } +@keyframes slideUp { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} + +@keyframes sparkBurst { + 0% { opacity: 0; transform: translate(0, 0) scale(0.3); } + 40% { opacity: 1; transform: translate(0, -30rpx) scale(1.3); } + 100% { opacity: 0; transform: translate(0, -80rpx) scale(0.4); } +} + +/* ---- shared section title ---- */ .section-title { font-size: 32rpx; font-weight: 600; color: var(--text); margin-bottom: 20rpx; } - -/* ---- shared modal overlay ---- */ -.modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.45); - z-index: 1000; -} - -.modal-overlay-bottom { - display: flex; - align-items: flex-end; - justify-content: center; -} - -.modal-overlay-center { - display: flex; - align-items: center; - justify-content: center; -} - -/* ---- shared danger button ---- */ -.btn-danger { - background: #fff; - color: #E53935; - border: 2rpx solid rgba(229, 57, 53, 0.3); - border-radius: 48rpx; - font-size: 28rpx; - padding: 20rpx 0; - text-align: center; -} - -.btn-danger::after { border: none; } diff --git a/cloudfunctions/leaderboard/config.json b/cloudfunctions/leaderboard/config.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/cloudfunctions/leaderboard/config.json @@ -0,0 +1 @@ +{} diff --git a/cloudfunctions/leaderboard/index.js b/cloudfunctions/leaderboard/index.js new file mode 100644 index 0000000..0b51d6c --- /dev/null +++ b/cloudfunctions/leaderboard/index.js @@ -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) +} diff --git a/cloudfunctions/leaderboard/package.json b/cloudfunctions/leaderboard/package.json new file mode 100644 index 0000000..5190dec --- /dev/null +++ b/cloudfunctions/leaderboard/package.json @@ -0,0 +1,8 @@ +{ + "name": "leaderboard", + "version": "1.0.0", + "main": "index.js", + "dependencies": { + "wx-server-sdk": "latest" + } +} diff --git a/cloudfunctions/tts/index.js b/cloudfunctions/tts/index.js new file mode 100644 index 0000000..f92b0ca --- /dev/null +++ b/cloudfunctions/tts/index.js @@ -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:///...`, 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 } + } +} diff --git a/cloudfunctions/tts/package.json b/cloudfunctions/tts/package.json new file mode 100644 index 0000000..08aed36 --- /dev/null +++ b/cloudfunctions/tts/package.json @@ -0,0 +1,9 @@ +{ + "name": "tts", + "version": "1.0.0", + "main": "index.js", + "dependencies": { + "wx-server-sdk": "latest", + "tencentcloud-sdk-nodejs": "^4.0.0" + } +} diff --git a/components/calendar-heatmap/calendar-heatmap.js b/components/calendar-heatmap/calendar-heatmap.js index e81d55c..9d1540a 100644 --- a/components/calendar-heatmap/calendar-heatmap.js +++ b/components/calendar-heatmap/calendar-heatmap.js @@ -1,10 +1,39 @@ const util = require('../../utils/util') +/** + * Generate 4 heat color shades from a primary color. + * Returns colors from lightest to darkest. + */ +const generateHeatColors = (hex) => { + if (!hex || hex.charAt(0) !== '#') { + return ['#FFE0D0', '#FFB088', '#FF6B35', '#E05520'] + } + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + + return [ + `rgba(${r},${g},${b},0.15)`, // lightest + `rgba(${r},${g},${b},0.35)`, // light + `rgba(${r},${g},${b},0.65)`, // medium + `rgba(${r},${g},${b},0.95)` // darkest + ] +} + +const getHeatColor = (duration, colors) => { + if (!duration || duration <= 0) return 'transparent' + if (duration < 30) return colors[0] + if (duration < 60) return colors[1] + if (duration < 120) return colors[2] + return colors[3] +} + Component({ properties: { year: { type: Number, value: new Date().getFullYear() }, month: { type: Number, value: new Date().getMonth() + 1 }, - records: { type: Array, value: [] } + records: { type: Array, value: [] }, + primaryColor: { type: String, value: '#FF6B35' } }, data: { @@ -12,20 +41,20 @@ Component({ }, observers: { - 'year, month, records'(year, month, records) { - this._compute(year, month, records) + 'year, month, records, primaryColor'(year, month, records, primaryColor) { + this._compute(year, month, records, primaryColor) } }, lifetimes: { ready() { - const { year, month, records } = this.properties - this._compute(year, month, records) + const { year, month, records, primaryColor } = this.properties + this._compute(year, month, records, primaryColor) } }, methods: { - _compute(year, month, records) { + _compute(year, month, records, primaryColor) { const weeks = util.getMonthCalendar(year, month) const recordMap = {} if (records && records.length) { @@ -35,18 +64,13 @@ Component({ }) } + const heatColors = generateHeatColors(primaryColor || '#FF6B35') + const data = weeks.map((week) => week.map((day) => { if (!day) return null const duration = recordMap[day] || 0 - let color = 'transparent' - if (duration > 0) { - if (duration < 30) color = '#FFE0D0' - else if (duration < 60) color = '#FFB088' - else if (duration < 120) color = '#FF6B35' - else color = '#E05520' - } - return { day, duration, color } + return { day, duration, color: getHeatColor(duration, heatColors) } }) ) @@ -64,15 +88,6 @@ Component({ day, duration: cell.duration }) - }, - - getHeatColor(cell) { - if (!cell || cell.duration <= 0) return 'transparent' - const d = cell.duration - if (d < 30) return '#FFE0D0' - if (d < 60) return '#FFB088' - if (d < 120) return '#FF6B35' - return '#E05520' } } }) diff --git a/components/calendar-heatmap/calendar-heatmap.wxml b/components/calendar-heatmap/calendar-heatmap.wxml index 804a5dd..3129eda 100644 --- a/components/calendar-heatmap/calendar-heatmap.wxml +++ b/components/calendar-heatmap/calendar-heatmap.wxml @@ -21,10 +21,10 @@ - - - - + + + + diff --git a/components/ui-btn/ui-btn.js b/components/ui-btn/ui-btn.js new file mode 100644 index 0000000..c37580c --- /dev/null +++ b/components/ui-btn/ui-btn.js @@ -0,0 +1,16 @@ +Component({ + properties: { + text: { type: String, value: '' }, + variant: { type: String, value: 'primary' }, // primary | outline | danger | ghost + size: { type: String, value: 'md' }, // sm | md | lg | xl + block: { type: Boolean, value: false }, + disabled: { type: Boolean, value: false }, + iconSrc: { type: String, value: '' }, + customStyle: { type: String, value: '' } + }, + methods: { + onTap() { + this.triggerEvent('tap') + } + } +}) diff --git a/components/ui-btn/ui-btn.json b/components/ui-btn/ui-btn.json new file mode 100644 index 0000000..a89ef4d --- /dev/null +++ b/components/ui-btn/ui-btn.json @@ -0,0 +1,4 @@ +{ + "component": true, + "usingComponents": {} +} diff --git a/components/ui-btn/ui-btn.wxml b/components/ui-btn/ui-btn.wxml new file mode 100644 index 0000000..d15d430 --- /dev/null +++ b/components/ui-btn/ui-btn.wxml @@ -0,0 +1,15 @@ + diff --git a/components/ui-btn/ui-btn.wxss b/components/ui-btn/ui-btn.wxss new file mode 100644 index 0000000..e1420ca --- /dev/null +++ b/components/ui-btn/ui-btn.wxss @@ -0,0 +1,92 @@ +.ui-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8rpx; + border: none; + padding: 0 32rpx; + font-weight: 600; + border-radius: 48rpx; + line-height: 1; + box-sizing: border-box; + transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease; +} + +.ui-btn::after { border: none; } + +.ui-btn:active:not(.is-disabled) { + transform: scale(0.96); +} + +.ui-btn--block { + width: 100%; + display: flex; +} + +/* Sizes */ +.ui-btn--sm { height: 64rpx; font-size: 26rpx; padding: 0 24rpx; } +.ui-btn--md { height: 80rpx; font-size: 30rpx; } +.ui-btn--lg { height: 96rpx; font-size: 34rpx; padding: 0 40rpx; } +.ui-btn--xl { height: 108rpx; font-size: 36rpx; padding: 0 48rpx; } + +/* Variants */ +.ui-btn--primary { + background: linear-gradient(135deg, var(--primary), var(--primary-light)); + color: #FFFFFF; + box-shadow: 0 8rpx 24rpx rgba(var(--primary-rgb), 0.3); +} + +.ui-btn--primary:active:not(.is-disabled) { + box-shadow: 0 4rpx 12rpx rgba(var(--primary-rgb), 0.2); +} + +.ui-btn--outline { + background: transparent; + color: var(--text-secondary); + border: 2rpx solid rgba(var(--primary-rgb), 0.3); +} + +.ui-btn--outline:active:not(.is-disabled) { + background: var(--primary-bg); + border-color: var(--primary); + color: var(--primary); +} + +.ui-btn--danger { + background: #FFFFFF; + color: #E53935; + border: 2rpx solid rgba(229, 57, 53, 0.3); +} + +.ui-btn--danger:active:not(.is-disabled) { + background: rgba(229, 57, 53, 0.05); + border-color: #E53935; +} + +.ui-btn--ghost { + /* Use rgba on the theme's RGB triplet instead of --primary-bg (~5% alpha) + so the soft secondary reads as colored rather than near-white. */ + background: rgba(var(--primary-rgb), 0.14); + color: var(--primary); + border: 2rpx solid rgba(var(--primary-rgb), 0.22); +} + +.ui-btn--ghost:active:not(.is-disabled) { + background: rgba(var(--primary-rgb), 0.22); + border-color: rgba(var(--primary-rgb), 0.35); +} + +.ui-btn.is-disabled { + opacity: 0.5; + pointer-events: none; +} + +.ui-btn__icon { + width: 1em; + height: 1em; + flex-shrink: 0; +} + +.ui-btn__text { + white-space: nowrap; +} diff --git a/components/ui-card/ui-card.js b/components/ui-card/ui-card.js new file mode 100644 index 0000000..76f51e3 --- /dev/null +++ b/components/ui-card/ui-card.js @@ -0,0 +1,8 @@ +Component({ + options: { multipleSlots: true }, + properties: { + variant: { type: String, value: '' }, // '' | 'soft' | 'in' + padded: { type: Boolean, value: true }, + customStyle: { type: String, value: '' } + } +}) diff --git a/components/ui-card/ui-card.json b/components/ui-card/ui-card.json new file mode 100644 index 0000000..a89ef4d --- /dev/null +++ b/components/ui-card/ui-card.json @@ -0,0 +1,4 @@ +{ + "component": true, + "usingComponents": {} +} diff --git a/components/ui-card/ui-card.wxml b/components/ui-card/ui-card.wxml new file mode 100644 index 0000000..84c9797 --- /dev/null +++ b/components/ui-card/ui-card.wxml @@ -0,0 +1,3 @@ + + + diff --git a/components/ui-card/ui-card.wxss b/components/ui-card/ui-card.wxss new file mode 100644 index 0000000..f2abfbf --- /dev/null +++ b/components/ui-card/ui-card.wxss @@ -0,0 +1,34 @@ +.ui-card { + background: var(--card-bg, #FFFFFF); + border-radius: 24rpx; + padding: 32rpx; + margin-bottom: 24rpx; + box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.06); + box-sizing: border-box; +} + +.ui-card--flush { + padding: 0; +} + +/* Soft variant: subtle background tint, no shadow */ +.ui-card.soft { + background: rgba(var(--primary-rgb, 255, 107, 53), 0.04); + box-shadow: none; + border-radius: 20rpx; +} + +/* Staggered entrance animation */ +.ui-card.in { + animation: uiCardIn 0.45s ease both; +} + +.ui-card.in:nth-child(1) { animation-delay: 0.05s; } +.ui-card.in:nth-child(2) { animation-delay: 0.15s; } +.ui-card.in:nth-child(3) { animation-delay: 0.25s; } +.ui-card.in:nth-child(4) { animation-delay: 0.35s; } + +@keyframes uiCardIn { + from { opacity: 0; transform: translateY(40rpx); } + to { opacity: 1; transform: translateY(0); } +} diff --git a/components/ui-modal/ui-modal.js b/components/ui-modal/ui-modal.js new file mode 100644 index 0000000..aa7d4af --- /dev/null +++ b/components/ui-modal/ui-modal.js @@ -0,0 +1,17 @@ +Component({ + options: { multipleSlots: true }, + properties: { + visible: { type: Boolean, value: false }, + position: { type: String, value: 'bottom' }, // 'bottom' | 'center' + showHandle: { type: Boolean, value: true }, + closeOnOverlay: { type: Boolean, value: true }, + customStyle: { type: String, value: '' } + }, + methods: { + onClose() { + if (!this.data.closeOnOverlay) return + this.triggerEvent('close') + }, + onNoop() { /* swallow bubble for inner touches */ } + } +}) diff --git a/components/ui-modal/ui-modal.json b/components/ui-modal/ui-modal.json new file mode 100644 index 0000000..a89ef4d --- /dev/null +++ b/components/ui-modal/ui-modal.json @@ -0,0 +1,4 @@ +{ + "component": true, + "usingComponents": {} +} diff --git a/components/ui-modal/ui-modal.wxml b/components/ui-modal/ui-modal.wxml new file mode 100644 index 0000000..19c23f8 --- /dev/null +++ b/components/ui-modal/ui-modal.wxml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/components/ui-modal/ui-modal.wxss b/components/ui-modal/ui-modal.wxss new file mode 100644 index 0000000..e33fe89 --- /dev/null +++ b/components/ui-modal/ui-modal.wxss @@ -0,0 +1,61 @@ +.ui-modal { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + z-index: 1000; +} + +.ui-modal--bottom { display: flex; align-items: flex-end; justify-content: center; } +.ui-modal--center { display: flex; align-items: center; justify-content: center; } + +.ui-modal__overlay { + position: absolute; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0, 0, 0, 0.45); + animation: uiModalFade 0.25s ease; +} + +.ui-modal__body { + position: relative; + background: #FFFFFF; + box-sizing: border-box; +} + +.ui-modal--bottom .ui-modal__body { + width: 100%; + border-radius: 32rpx 32rpx 0 0; + padding: 28rpx 32rpx 40rpx; + padding-bottom: calc(40rpx + env(safe-area-inset-bottom)); + animation: uiModalSlideUp 0.3s cubic-bezier(0.32, 0.72, 0, 1); +} + +.ui-modal--center .ui-modal__body { + width: 560rpx; + border-radius: 28rpx; + padding: 48rpx 40rpx 36rpx; + animation: uiModalPop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); + text-align: center; +} + +.ui-modal__handle { + width: 64rpx; + height: 8rpx; + background: #DDD; + border-radius: 4rpx; + margin: 0 auto 24rpx; +} + +@keyframes uiModalFade { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes uiModalSlideUp { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} + +@keyframes uiModalPop { + from { transform: scale(0); opacity: 0; } + 60% { transform: scale(1.05); } + to { transform: scale(1); opacity: 1; } +} diff --git a/custom-tab-bar/index.js b/custom-tab-bar/index.js index b04aba2..2201c48 100644 --- a/custom-tab-bar/index.js +++ b/custom-tab-bar/index.js @@ -1,22 +1,70 @@ const themeMod = require('../utils/theme') +// Resolve the active theme synchronously at module load so the first paint +// already has the correct CSS variables — avoids a flash of the default theme. +const _initialTheme = themeMod.getCurrentTheme() +const _initialThemeStyle = themeMod.getThemeStyle(_initialTheme) + +// SVG icons as data URIs. We use two variants per icon: +// - *Fill*: solid filled (active state, theme color baked in at module load) +// - *Line*: outlined (inactive state, neutral gray) +// Themes are 5 fixed colors so we can bake the active color in at module init. +// If the user changes theme, the component re-renders via pageLifetimes.show. + +// Solid (active) color from current theme +const _activeColor = _initialTheme.primary +// Inactive color +const _inactiveColor = '#999999' + +const _svg = (path, fill) => + 'data:image/svg+xml,' + encodeURIComponent( + '' + + path + '' + ) + +// 24x24 Material-style paths +const PATH_HOME = '' +const PATH_HOME_LINE = '' +const PATH_CALENDAR = '' +const PATH_CALENDAR_LINE = '' +const PATH_RANK = '' +const PATH_RANK_LINE = '' +const PATH_SETTINGS = '' +const PATH_SETTINGS_LINE = '' + +const _buildIcons = (activeHex) => { + const a = activeHex + return { + home: _svg(PATH_HOME_LINE, _inactiveColor), + homeFill: _svg(PATH_HOME, a), + calendar: _svg(PATH_CALENDAR_LINE, _inactiveColor), + calendarFill: _svg(PATH_CALENDAR, a), + rank: _svg(PATH_RANK_LINE, _inactiveColor), + rankFill: _svg(PATH_RANK, a), + settings: _svg(PATH_SETTINGS_LINE, _inactiveColor), + settingsFill: _svg(PATH_SETTINGS, a) + } +} + Component({ data: { selected: 0, - themeStyle: themeMod.BASE_VARS + theme: _initialTheme, + themeStyle: _initialThemeStyle, + svgIcons: _buildIcons(_initialTheme.primary) }, lifetimes: { attached() { - const theme = themeMod.getCurrentTheme() - this.setData({ themeStyle: themeMod.getThemeStyle(theme) }) + themeMod.applyThemeToPage(this) } }, pageLifetimes: { show() { + themeMod.applyThemeToPage(this) const theme = themeMod.getCurrentTheme() - this.setData({ themeStyle: themeMod.getThemeStyle(theme) }) + this.setData({ svgIcons: _buildIcons(theme.primary) }) } }, @@ -26,11 +74,10 @@ Component({ const pages = [ '/pages/index/index', '/pages/records/records', + '/pages/leaderboard/leaderboard', '/pages/settings/settings' ] - if (index === this.data.selected) return - wx.switchTab({ url: pages[index] }) } } diff --git a/custom-tab-bar/index.wxml b/custom-tab-bar/index.wxml index a727751..b35c009 100644 --- a/custom-tab-bar/index.wxml +++ b/custom-tab-bar/index.wxml @@ -4,12 +4,7 @@ data-index="0" bindtap="switchTab" > - - - - - - + 训练 @@ -18,13 +13,7 @@ data-index="1" bindtap="switchTab" > - - - - - - - + 记录 @@ -33,12 +22,16 @@ data-index="2" bindtap="switchTab" > - - - - - - + + 排行 + + + + 设置 diff --git a/custom-tab-bar/index.wxss b/custom-tab-bar/index.wxss index f0e1914..d84ac70 100644 --- a/custom-tab-bar/index.wxss +++ b/custom-tab-bar/index.wxss @@ -3,7 +3,7 @@ bottom: 0; left: 0; right: 0; - height: 110rpx; + height: calc(110rpx + env(safe-area-inset-bottom)); background: #FFFFFF; display: flex; align-items: center; @@ -11,6 +11,8 @@ border-top: 1rpx solid rgba(0, 0, 0, 0.06); padding-bottom: env(safe-area-inset-bottom); z-index: 999; + box-sizing: border-box; + overflow: hidden; } .tab-item { @@ -18,129 +20,35 @@ flex-direction: column; align-items: center; justify-content: center; - padding: 6rpx 0; + padding: 4rpx 0; flex: 1; + min-width: 0; + height: 100%; + box-sizing: border-box; } .tab-item:active { - transform: scale(0.9); + transform: scale(0.92); +} + +.tab-icon { + width: 44rpx; + height: 44rpx; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + margin-bottom: 4rpx; + flex-shrink: 0; } .tab-text { - font-size: 24rpx; + font-size: 22rpx; color: var(--text-secondary); - margin-top: 8rpx; line-height: 1; + white-space: nowrap; } .tab-item.active .tab-text { color: var(--primary); font-weight: 600; } - -/* ---- icon: home ---- */ -.icon-home { - position: relative; - width: 44rpx; - height: 40rpx; -} - -.tab-item.active .icon-home { - animation: bounceSoft 0.5s ease; -} - -.home-roof { - width: 0; - height: 0; - border-left: 22rpx solid transparent; - border-right: 22rpx solid transparent; - border-bottom: 18rpx solid var(--text-secondary); - position: absolute; - top: 0; - left: 0; -} - -.tab-item.active .home-roof { - border-bottom-color: var(--primary); -} - -.home-body { - width: 22rpx; - height: 20rpx; - background: var(--text-secondary); - position: absolute; - bottom: 0; - left: 11rpx; - border-radius: 2rpx 2rpx 0 0; -} - -.tab-item.active .home-body { - background: var(--primary); -} - -/* ---- icon: records (3 bars) ---- */ -.icon-records { - width: 40rpx; - height: 34rpx; - display: flex; - flex-direction: column; - justify-content: flex-end; - gap: 6rpx; -} - -.tab-item.active .icon-records { - animation: bounceSoft 0.5s ease; -} - -.bar { - height: 6rpx; - background: var(--text-secondary); - border-radius: 3rpx; -} - -.bar1 { width: 100%; } -.bar2 { width: 65%; } -.bar3 { width: 80%; } - -.tab-item.active .bar { - background: var(--primary); -} - -/* ---- icon: settings (gear) ---- */ -.icon-settings { - position: relative; - width: 42rpx; - height: 42rpx; -} - -.tab-item.active .icon-settings { - animation: bounceSoft 0.5s ease; -} - -.gear-outer { - width: 36rpx; - height: 36rpx; - border: 5rpx solid var(--text-secondary); - border-radius: 50%; - position: absolute; - top: 3rpx; - left: 3rpx; -} - -.tab-item.active .gear-outer { - border-color: var(--primary); -} - -.gear-center { - width: 12rpx; - height: 12rpx; - background: var(--text-secondary); - border-radius: 50%; - position: absolute; - top: 15rpx; - left: 15rpx; -} - -.tab-item.active .gear-center { - background: var(--primary); -} diff --git a/pages/index/index.js b/pages/index/index.js index 2f99235..de070a0 100644 --- a/pages/index/index.js +++ b/pages/index/index.js @@ -2,6 +2,9 @@ const storage = require('../../utils/storage') const planMod = require('../../utils/plan') const util = require('../../utils/util') const themeMod = require('../../utils/theme') +const iconsMod = require('../../utils/icons') + +const MAX_CUSTOM_DURATION = 3600 // 1 hour max Page({ data: { @@ -19,12 +22,13 @@ Page({ showPicker: false, presets: [30, 60, 90, 120, 180, 300], customDuration: 60, - customInput: '' + customInput: '', + icons: iconsMod.build() }, onLoad() { themeMod.applyThemeToPage(this) - this.refresh() + this._firstShow = true }, onShow() { @@ -33,6 +37,12 @@ Page({ const tb = this.getTabBar() if (tb) tb.setData({ selected: 0 }) } catch (e) {} + // Skip the first onShow to avoid double-refresh; subsequent tab switches still refresh. + if (this._firstShow) { + this._firstShow = false + this.refresh() + return + } this.refresh() }, @@ -43,15 +53,19 @@ Page({ refresh() { const settings = storage.getSettings() - const streak = storage.getStreak() + const streak = storage.validateStreak() const todayRecord = storage.getTodayRecord() const allRecords = Object.values(storage.getRecords()).flat() - const plan = planMod.getPlan(settings.planId) - const planDay = planMod.getPlanDay(settings.planId, allRecords) + const customPlans = storage.getCustomPlans() + const plan = planMod.getPlan(settings.planId, customPlans) + const planDay = planMod.getPlanDay(settings.planId, allRecords, settings.planStartDate) const target = planMod.getTodayTarget(settings.planId, Math.min(planDay, plan.totalDays)) + const theme = themeMod.getCurrentTheme() this.setData({ + theme, + icons: iconsMod.build(theme), todayTarget: target, todayDone: todayRecord && todayRecord.duration >= target, todayDuration: todayRecord ? todayRecord.duration : 0, @@ -82,7 +96,8 @@ Page({ }, onCustomInput(e) { - const val = parseInt(e.detail.value) || 0 + let val = parseInt(e.detail.value) || 0 + if (val > MAX_CUSTOM_DURATION) val = MAX_CUSTOM_DURATION this.setData({ customInput: e.detail.value, customDuration: val }) }, @@ -92,9 +107,11 @@ Page({ wx.showToast({ title: '请输入有效时长', icon: 'none' }) return } + if (dur > MAX_CUSTOM_DURATION) { + wx.showToast({ title: '最大时长为1小时', icon: 'none' }) + return + } this.setData({ showPicker: false }) wx.navigateTo({ url: `/pages/timer/timer?free=${dur}` }) - }, - - noop() {} + } }) diff --git a/pages/index/index.json b/pages/index/index.json index 2d8aa85..6d96456 100644 --- a/pages/index/index.json +++ b/pages/index/index.json @@ -1,4 +1,8 @@ { - "usingComponents": {}, + "usingComponents": { + "ui-card": "/components/ui-card/ui-card", + "ui-btn": "/components/ui-btn/ui-btn", + "ui-modal": "/components/ui-modal/ui-modal" + }, "navigationBarTitleText": "平板支撑训练" } diff --git a/pages/index/index.wxml b/pages/index/index.wxml index 806330a..f92fbd1 100644 --- a/pages/index/index.wxml +++ b/pages/index/index.wxml @@ -3,112 +3,132 @@ - {{todayDone ? '💪' : '👋'}} + {{todayDone ? '太棒了!' : '准备好了吗?'}} {{todayDone ? '继续保持这个势头' : '今天的平板支撑等着你'}} - - - 🔥 - {{streakCount}} - 连续打卡 - - - - - 📋 - {{planName}} + + + + + {{streakCount}} + 连续打卡 - - - - - + + + + + {{planName}} + + + + + + + + 第 {{planDay}} / {{planTotal}} 天 - 第 {{planDay}} / {{planTotal}} 天 - + - - - 🎯 - 今日目标 - - - - {{todayTargetText}} + + + + + 今日目标 - 平板支撑 - - - - - - 今日已完成 {{todayDuration}} 秒 + + + {{todayTargetText}} - - - ⏳ 还未开始训练 - + 平板支撑 + + + + + + 今日已完成 {{todayDuration}} 秒 + + + + + 还未开始训练 + + - + - + - + - - 💡 - 保持身体成一条直线,核心收紧,均匀呼吸 - + + + + 保持身体成一条直线,核心收紧,均匀呼吸 + + - - - - ⏱ 选择训练时长 - - - {{item}} - - + + 选择训练时长 + + + {{item}} + - - 自定义: - - - - - 取消 - + + 自定义: + + + + + 取消 + diff --git a/pages/index/index.wxss b/pages/index/index.wxss index 74c1b4f..937471a 100644 --- a/pages/index/index.wxss +++ b/pages/index/index.wxss @@ -10,10 +10,10 @@ margin-bottom: 8rpx; } -.greeting-emoji { - font-size: 40rpx; +.greeting-icon { + width: 44rpx; + height: 44rpx; margin-right: 12rpx; - line-height: 1; } .greeting-title { @@ -26,7 +26,7 @@ .greeting-sub { font-size: 26rpx; color: var(--text-secondary); - margin-left: 56rpx; + margin-left: 60rpx; line-height: 1; } @@ -34,7 +34,7 @@ .header-card { display: flex; align-items: center; - padding: 36rpx 32rpx; + padding: 4rpx 0; } .streak-section { @@ -45,9 +45,9 @@ } .streak-icon { - font-size: 40rpx; + width: 44rpx; + height: 44rpx; margin-bottom: 4rpx; - line-height: 1; animation: float 2.5s ease-in-out infinite; } @@ -85,9 +85,10 @@ } .plan-badge-icon { - font-size: 28rpx; + width: 28rpx; + height: 28rpx; + color: var(--primary); margin-right: 8rpx; - line-height: 1; } .plan-badge-text { @@ -139,7 +140,6 @@ /* ---- target card ---- */ .target-section { text-align: center; - padding: 36rpx 32rpx; } .target-header { @@ -153,10 +153,10 @@ margin-bottom: 0; } -.target-icon { - font-size: 28rpx; +.section-icon { + width: 32rpx; + height: 32rpx; margin-right: 8rpx; - line-height: 1; } .target-display { @@ -193,6 +193,10 @@ .today-status { margin-top: 16rpx; + display: flex; + align-items: center; + justify-content: center; + gap: 8rpx; } .done-badge { @@ -203,10 +207,10 @@ border-radius: 24rpx; } -.done-emoji { - font-size: 28rpx; +.done-icon { + width: 28rpx; + height: 28rpx; margin-right: 6rpx; - line-height: 1; } .done-text { @@ -215,6 +219,12 @@ line-height: 1; } +.pending-icon { + width: 28rpx; + height: 28rpx; + margin-right: 6rpx; +} + .pending-text { font-size: 26rpx; color: #CCC; @@ -223,51 +233,10 @@ /* ---- action buttons ---- */ .action-buttons { - margin-top: 16rpx; -} - -.start-btn { - width: 100%; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 10rpx; - padding: 24rpx 0; -} - -.btn-icon { - font-size: 28rpx; - line-height: 1; -} - -.free-btn { - width: 100%; - margin-top: 20rpx; - background: transparent; - border: 2rpx solid rgba(var(--primary-rgb), 0.3); - color: var(--text-secondary); - border-radius: 48rpx; - font-size: 28rpx; - padding: 20rpx 0; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8rpx; -} - -.free-btn::after { - border: none; -} - -.free-btn:active { - background: var(--primary-bg); - border-color: var(--primary); - color: var(--primary); -} - -.free-icon { - font-size: 28rpx; - line-height: 1; + display: flex; + flex-direction: column; + gap: 24rpx; + margin: 8rpx 0 8rpx; } /* ---- tip card ---- */ @@ -275,17 +244,16 @@ display: flex; align-items: flex-start; padding: 24rpx 32rpx; - margin-top: 32rpx; - background: rgba(var(--primary-rgb), 0.04); + background: transparent; border-radius: 20rpx; - animation: cardIn 0.45s 0.4s ease both; } .tip-icon { - font-size: 28rpx; - margin-right: 10rpx; + width: 32rpx; + height: 32rpx; + margin-right: 12rpx; flex-shrink: 0; - line-height: 1.6; + align-self: center; } .tip-text { @@ -294,29 +262,7 @@ line-height: 1.6; } -/* ---- picker modal ---- */ -.picker-modal { - width: 100%; - background: #fff; - border-radius: 32rpx 32rpx 0 0; - padding: 28rpx 32rpx 40rpx; - padding-bottom: calc(40rpx + env(safe-area-inset-bottom)); - animation: slideUp 0.3s cubic-bezier(0.32, 0.72, 0, 1); -} - -.picker-handle { - width: 64rpx; - height: 8rpx; - background: #DDD; - border-radius: 4rpx; - margin: 0 auto 24rpx; -} - -@keyframes slideUp { - from { transform: translateY(100%); } - to { transform: translateY(0); } -} - +/* ---- picker ---- */ .picker-title { font-size: 34rpx; font-weight: 600; @@ -341,6 +287,7 @@ text-align: center; display: flex; flex-direction: column; + transition: all 0.2s ease; } .preset-item:active { @@ -395,15 +342,11 @@ margin: 0 16rpx; } -.picker-confirm { - width: 100%; - margin-bottom: 16rpx; -} - .picker-cancel { display: block; text-align: center; font-size: 28rpx; color: var(--text-secondary); padding: 12rpx 0; + margin-top: 8rpx; } diff --git a/pages/leaderboard/leaderboard.js b/pages/leaderboard/leaderboard.js new file mode 100644 index 0000000..fd89db8 --- /dev/null +++ b/pages/leaderboard/leaderboard.js @@ -0,0 +1,113 @@ +const themeMod = require('../../utils/theme') +const iconsMod = require('../../utils/icons') +const util = require('../../utils/util') +const storage = require('../../utils/storage') + +Page({ + data: { + theme: { primary: '#FF6B35', primaryLight: '#FF8C5A', primaryBg: '#FFF3ED', primaryRgb: '255,107,53' }, + themeStyle: themeMod.BASE_VARS, + periods: [ + { key: 'day', label: '日榜' }, + { key: 'month', label: '月榜' }, + { key: 'year', label: '年榜' } + ], + activePeriod: 'day', + rankedList: [], + myEntry: null, + loading: false, + empty: false, + icons: iconsMod.build() + }, + + onLoad() { + themeMod.applyThemeToPage(this) + this._firstShow = true + }, + + onShow() { + themeMod.applyThemeToPage(this) + const theme = themeMod.getCurrentTheme() + this.setData({ theme, icons: iconsMod.build(theme) }) + try { + const tb = this.getTabBar() + if (tb) tb.setData({ selected: 2 }) + } catch (e) {} + if (this._firstShow) { + this._firstShow = false + this.fetchRank() + return + } + this.fetchRank() + }, + + onPullDownRefresh() { + this.fetchRank(() => wx.stopPullDownRefresh()) + }, + + switchPeriod(e) { + const period = e.currentTarget.dataset.period + if (period === this.data.activePeriod) return + this.setData({ activePeriod: period, rankedList: [], myEntry: null }) + this.fetchRank() + }, + + fetchRank(cb) { + this.setData({ loading: true, empty: false }) + wx.cloud.callFunction({ + name: 'leaderboard', + data: { period: this.data.activePeriod } + }).then(res => { + this._applyResult(res.result || {}) + if (cb) cb() + }).catch(() => { + // Cloud function not deployed — fall back to local data + this._applyLocal() + if (cb) cb() + }) + }, + + _applyResult(result) { + const list = (result.ranked || []).map(item => ({ + ...item, + durationText: util.formatDuration(item.duration) + })) + const myEntry = result.myEntry ? { + ...result.myEntry, + durationText: util.formatDuration(result.myEntry.duration), + isMe: true + } : null + this.setData({ + rankedList: list, + myEntry, + loading: false, + empty: list.length === 0 && !myEntry + }) + }, + + /** Local fallback: use local storage when cloud function isn't deployed */ + _applyLocal() { + const period = this.data.activePeriod + const allRecords = Object.values(storage.getRecords()).flat() + const today = storage.getToday() + const thisMonth = today.substring(0, 7) + const thisYear = String(new Date().getFullYear()) + + let duration = 0 + let sessions = 0 + allRecords.forEach(r => { + if (period === 'day' && r.date === today) { duration += r.duration; sessions++ } + if (period === 'month' && r.date.startsWith(thisMonth)) { duration += r.duration; sessions++ } + if (period === 'year' && r.date.startsWith(thisYear)) { duration += r.duration; sessions++ } + }) + + if (duration > 0) { + const entry = { + rank: 1, openid: 'local', name: '我', duration, durationText: util.formatDuration(duration), sessions + } + this.setData({ rankedList: [entry], myEntry: { ...entry, isMe: true }, loading: false, empty: false }) + } else { + this.setData({ loading: false, empty: true }) + } + } +}) diff --git a/pages/leaderboard/leaderboard.json b/pages/leaderboard/leaderboard.json new file mode 100644 index 0000000..cc24158 --- /dev/null +++ b/pages/leaderboard/leaderboard.json @@ -0,0 +1,6 @@ +{ + "navigationBarTitleText": "排行榜", + "enablePullDownRefresh": true, + "backgroundColor": "#F5F5F5", + "usingComponents": {} +} diff --git a/pages/leaderboard/leaderboard.wxml b/pages/leaderboard/leaderboard.wxml new file mode 100644 index 0000000..47c55ae --- /dev/null +++ b/pages/leaderboard/leaderboard.wxml @@ -0,0 +1,57 @@ + + + + {{item.label}} + + + + + 加载中... + + + + + + 暂无排行数据 + 快去训练吧! + + + + + + + 🥇 + 🥈 + 🥉 + {{item.rank}} + + + + {{item.name}} + {{item.sessions}}次 + + {{item.durationText}} + + + + + + {{myEntry.rank}} + + + 我 ({{myEntry.name}}) + {{myEntry.sessions}}次 + + {{myEntry.durationText}} + + diff --git a/pages/leaderboard/leaderboard.wxss b/pages/leaderboard/leaderboard.wxss new file mode 100644 index 0000000..60c3a4f --- /dev/null +++ b/pages/leaderboard/leaderboard.wxss @@ -0,0 +1,176 @@ +.container { + padding: 0; + padding-bottom: calc(134rpx + env(safe-area-inset-bottom)); + min-height: 100vh; + box-sizing: border-box; +} + +/* Period tabs */ +.period-tabs { + display: flex; + background: #FFFFFF; + padding: 0 32rpx; + border-bottom: 1rpx solid var(--border); + position: sticky; + top: 0; + z-index: 10; +} + +.period-tab { + flex: 1; + text-align: center; + padding: 24rpx 0 20rpx; + font-size: 30rpx; + color: var(--text-secondary); + position: relative; + transition: color 0.2s; +} + +.period-tab.active { + color: var(--primary); + font-weight: 600; +} + +.period-tab.active::after { + content: ''; + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 40rpx; + height: 6rpx; + border-radius: 3rpx; + background: var(--primary); +} + +/* Status */ +.status-wrap { + display: flex; + flex-direction: column; + align-items: center; + padding-top: 120rpx; +} + +.status-text { + font-size: 28rpx; + color: var(--text-secondary); +} + +.empty-icon { + width: 120rpx; + height: 120rpx; + opacity: 0.3; + margin-bottom: 24rpx; +} + +.empty-text { + font-size: 30rpx; + color: var(--text-secondary); + margin-bottom: 8rpx; +} + +.empty-sub { + font-size: 26rpx; + color: var(--text-secondary); + opacity: 0.6; +} + +/* Rank list */ +.rank-list { + padding: 16rpx 24rpx; +} + +.rank-item { + display: flex; + align-items: center; + background: var(--card-bg); + border-radius: 16rpx; + padding: 20rpx 24rpx; + margin-bottom: 12rpx; + box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04); +} + +.rank-item.is-me { + background: var(--primary-bg); + border: 2rpx solid var(--primary); +} + +/* Rank number */ +.rank-num { + width: 56rpx; + height: 56rpx; + display: flex; + align-items: center; + justify-content: center; + font-size: 28rpx; + font-weight: 700; + color: var(--text-secondary); + border-radius: 50%; + background: #F5F5F5; + flex-shrink: 0; + margin-right: 16rpx; +} + +.rank-num.gold { background: #FFF7E6; color: #FA8C16; font-size: 36rpx; } +.rank-num.silver { background: #F0F0F0; color: #8C8C8C; font-size: 36rpx; } +.rank-num.bronze { background: #FFF1E6; color: #D46B08; font-size: 36rpx; } + +.rank-avatar { + width: 64rpx; + height: 64rpx; + border-radius: 50%; + background: #F0F0F0; + flex-shrink: 0; + margin-right: 16rpx; + opacity: 0.6; +} + +.rank-info { + flex: 1; + min-width: 0; +} + +.rank-name { + font-size: 28rpx; + font-weight: 500; + color: var(--text); + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rank-sessions { + font-size: 22rpx; + color: var(--text-secondary); + margin-top: 4rpx; +} + +.rank-dur { + font-size: 30rpx; + font-weight: 700; + color: var(--primary); + flex-shrink: 0; + margin-left: 16rpx; +} + +/* My rank bar (pinned) */ +.my-bar { + position: fixed; + bottom: calc(110rpx + env(safe-area-inset-bottom)); + left: 24rpx; + right: 24rpx; + display: flex; + align-items: center; + background: var(--primary-bg); + border: 2rpx solid var(--primary); + border-radius: 16rpx; + padding: 16rpx 24rpx; + z-index: 10; + box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.08); +} + +.my-bar .rank-name { + color: var(--primary); + font-weight: 600; +} diff --git a/pages/records/records.js b/pages/records/records.js index 51d69fa..b1b11f7 100644 --- a/pages/records/records.js +++ b/pages/records/records.js @@ -1,6 +1,7 @@ const storage = require('../../utils/storage') const util = require('../../utils/util') const themeMod = require('../../utils/theme') +const iconsMod = require('../../utils/icons') Page({ data: { @@ -16,12 +17,13 @@ Page({ monthRecords: [], historyList: [], showDayDetail: false, - dayDetail: {} + dayDetail: {}, + icons: iconsMod.build() }, onLoad() { themeMod.applyThemeToPage(this) - this.refresh() + this._firstShow = true }, onShow() { @@ -30,6 +32,11 @@ Page({ const tb = this.getTabBar() if (tb) tb.setData({ selected: 1 }) } catch (e) {} + if (this._firstShow) { + this._firstShow = false + this.refresh() + return + } this.refresh() }, @@ -39,21 +46,35 @@ Page({ }, refresh() { + storage.validateStreak() const stats = storage.getTotalStats() const monthKey = `${this.data.currentYear}-${String(this.data.currentMonth).padStart(2, '0')}` const records = storage.getRecordsByMonth(monthKey) - const allRecords = Object.values(storage.getRecords()).flat() - allRecords.sort((a, b) => b.date.localeCompare(a.date)) + // Each month is already sorted newest-first in storage. To get top 30 most recent, + // walk months in reverse chrono order and concat — O(months*30) instead of O(n log n). + const byMonth = storage.getRecords() + const monthKeys = Object.keys(byMonth).sort().reverse() + const historyList = [] + for (const mk of monthKeys) { + for (const r of byMonth[mk]) { + historyList.push(r) + if (historyList.length >= 30) break + } + if (historyList.length >= 30) break + } + const theme = themeMod.getCurrentTheme() this.setData({ + theme, + icons: iconsMod.build(theme), totalDuration: stats.totalDuration, totalDurationText: util.formatDuration(stats.totalDuration), totalSessions: stats.totalSessions, maxDuration: stats.maxDuration, maxDurationText: util.formatDuration(stats.maxDuration), monthRecords: records, - historyList: allRecords.slice(0, 30) + historyList }) }, @@ -84,12 +105,10 @@ Page({ onDayTap(e) { const { year, month, day } = e.detail const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` - const allRecords = Object.values(storage.getRecords()).flat() const sessions = allRecords.filter(r => r.date === dateStr) const totalDur = sessions.reduce((sum, r) => sum + r.duration, 0) const calories = Math.round(totalDur * 0.068) - this.setData({ showDayDetail: true, dayDetail: { @@ -106,10 +125,31 @@ Page({ this.setData({ showDayDetail: false }) }, - noop() {}, + onLongPressDelete(e) { + const id = e.currentTarget.dataset.id + if (!id && id !== 0) { + wx.showToast({ title: '记录数据异常', icon: 'none', duration: 1200 }) + return + } + wx.showModal({ + title: '删除记录', + content: '确定要删除这条训练记录吗?', + success: (res) => { + if (res.confirm) { + storage.deleteRecord(id) + this.refresh() + wx.showToast({ title: '已删除', icon: 'none', duration: 1200 }) + } + } + }) + }, onDeleteRecord(e) { const id = e.currentTarget.dataset.id + if (!id && id !== 0) { + wx.showToast({ title: '记录数据异常', icon: 'none', duration: 1200 }) + return + } wx.showModal({ title: '删除记录', content: '确定要删除这条训练记录吗?', diff --git a/pages/records/records.json b/pages/records/records.json index 6bfb9b6..6de9699 100644 --- a/pages/records/records.json +++ b/pages/records/records.json @@ -1,6 +1,8 @@ { "usingComponents": { - "calendar-heatmap": "../../components/calendar-heatmap/calendar-heatmap" + "calendar-heatmap": "/components/calendar-heatmap/calendar-heatmap", + "ui-card": "/components/ui-card/ui-card", + "ui-modal": "/components/ui-modal/ui-modal" }, "navigationBarTitleText": "训练记录" } diff --git a/pages/records/records.wxml b/pages/records/records.wxml index 65c8264..dea650a 100644 --- a/pages/records/records.wxml +++ b/pages/records/records.wxml @@ -1,92 +1,112 @@ - - - 📊 - {{totalSessions}}次 - 训练次数 + + + + + {{totalSessions}}次 + 训练次数 + + + + {{totalDurationText}} + 累计时长 + + + + {{maxDurationText}} + 最长记录 + - - - {{totalDurationText}} - 累计时长 - - - 🏆 - {{maxDurationText}} - 最长记录 - - + - - - - - 🗓 - {{currentYear}}年 {{currentMonth}}月 + + + + + + + + + {{currentYear}}年 {{currentMonth}}月 + + + + - + - - + - - - 📋 最近记录 - 长按可删除 - - - 🏃 - 还没有训练记录 - 开始你的第一次平板支撑吧 - - - - - {{item.duration >= 120 ? '🔥' : item.duration >= 60 ? '💪' : '✅'}} - - - {{item.date}} - 第 {{item.day}} 天 + + + + + + 最近记录 - - {{item.duration}}s + 长按可删除 + + + + 还没有训练记录 + 开始你的第一次平板支撑吧 + + + + + + {{item.date}} + {{item.day === 0 ? '自由训练' : '第 ' + item.day + ' 天'}} + + + {{item.duration}}s + + + 删除 + + - + - - - - 📅 - {{dayDetail.date}} + + + + {{dayDetail.date}} + + + + + {{dayDetail.sessions}}次 + 训练次数 - - - {{dayDetail.sessions}}次 - 训练次数 - - - {{dayDetail.durationText}} - 总时长 - - - {{dayDetail.calories}} - 约消耗 (千卡) - + + + {{dayDetail.durationText}} + 总时长 - - 关闭 + + + {{dayDetail.calories}} + 约消耗 (千卡) - + + 关闭 + + diff --git a/pages/records/records.wxss b/pages/records/records.wxss index 09f5e59..4c206d8 100644 --- a/pages/records/records.wxss +++ b/pages/records/records.wxss @@ -2,7 +2,7 @@ display: flex; justify-content: space-around; text-align: center; - padding: 32rpx 16rpx; + padding: 8rpx 0; } .stat-item { @@ -12,13 +12,10 @@ flex: 1; } -.stat-emoji { - font-size: 28rpx; - width: 56rpx; +.stat-icon { + width: 40rpx; height: 40rpx; - line-height: 40rpx; - text-align: center; - margin-bottom: 4rpx; + margin-bottom: 8rpx; } .stat-num { @@ -38,7 +35,7 @@ /* ---- calendar ---- */ .calendar-section { - padding: 24rpx 24rpx 28rpx; + padding: 0; } .month-picker { @@ -54,8 +51,9 @@ gap: 10rpx; } -.month-emoji { - font-size: 28rpx; +.month-icon { + width: 32rpx; + height: 32rpx; } .month-title { @@ -65,18 +63,27 @@ } .month-arrow { - font-size: 44rpx; - color: var(--primary); - padding: 0 20rpx; - font-weight: 300; - transition: opacity 0.15s; + padding: 16rpx 20rpx; + display: flex; + align-items: center; + justify-content: center; } .month-arrow:active { opacity: 0.5; } +.month-arrow-img { + width: 32rpx; + height: 32rpx; + color: var(--primary); +} + /* ---- history ---- */ +.history-section { + padding: 0; +} + .history-header { display: flex; justify-content: space-between; @@ -84,6 +91,17 @@ margin-bottom: 8rpx; } +.history-title-wrap { + display: flex; + align-items: center; + gap: 8rpx; +} + +.history-title-icon { + width: 28rpx; + height: 28rpx; +} + .history-header .section-title { margin-bottom: 0; } @@ -100,8 +118,9 @@ padding: 60rpx 0; } -.empty-emoji { - font-size: 64rpx; +.empty-icon { + width: 80rpx; + height: 80rpx; margin-bottom: 16rpx; animation: float 3s ease-in-out infinite; } @@ -129,8 +148,13 @@ animation: cardIn 0.35s ease both; } -.history-item:last-child { - border-bottom: none; +.history-item:last-child { border-bottom: none; } + +.history-item-icon { + width: 36rpx; + height: 36rpx; + margin-right: 16rpx; + flex-shrink: 0; } .history-left { @@ -153,35 +177,62 @@ .history-right { display: flex; align-items: center; + gap: 12rpx; + flex-shrink: 0; } .history-duration { font-size: 32rpx; font-weight: 600; color: var(--primary); + margin-right: 4rpx; +} + +.history-delete-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 6rpx; + padding: 0 18rpx; + height: 56rpx; + background: rgba(229, 57, 53, 0.1); + border: 2rpx solid rgba(229, 57, 53, 0.2); + border-radius: 28rpx; + flex-shrink: 0; + transition: transform 0.15s ease, background 0.15s ease, border-color 0.15s ease; +} + +.history-delete-btn:active { + transform: scale(0.92); + background: rgba(229, 57, 53, 0.2); + border-color: rgba(229, 57, 53, 0.4); +} + +.history-delete-icon { + width: 28rpx; + height: 28rpx; + flex-shrink: 0; +} + +.history-delete-text { + font-size: 24rpx; + color: #E53935; + font-weight: 600; + line-height: 1; } /* ---- day detail popup ---- */ -.day-detail-modal { - width: 560rpx; - background: #fff; - border-radius: 28rpx; - padding: 48rpx 40rpx 36rpx; - animation: popIn 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); - text-align: center; -} - .detail-date-row { display: flex; align-items: center; justify-content: center; margin-bottom: 36rpx; + gap: 10rpx; } -.detail-emoji { - font-size: 32rpx; - margin-right: 10rpx; - line-height: 1; +.detail-icon { + width: 36rpx; + height: 36rpx; } .detail-date { @@ -202,6 +253,12 @@ align-items: center; } +.detail-item-icon { + width: 36rpx; + height: 36rpx; + margin-bottom: 8rpx; +} + .detail-num { font-size: 36rpx; font-weight: 700; @@ -220,10 +277,3 @@ color: var(--text-secondary); border-top: 1rpx solid var(--border); } - -.history-icon { - font-size: 28rpx; - margin-right: 16rpx; - flex-shrink: 0; - line-height: 1; -} diff --git a/pages/settings/settings.js b/pages/settings/settings.js index 14b99ff..d6ddad7 100644 --- a/pages/settings/settings.js +++ b/pages/settings/settings.js @@ -1,5 +1,44 @@ const storage = require('../../utils/storage') const themeMod = require('../../utils/theme') +const planMod = require('../../utils/plan') +const iconsMod = require('../../utils/icons') +const cloud = require('../../utils/cloud') + +/** + * Build the simplified plans list shown in the settings UI by overlaying + * user-customized plans on top of the 3 presets. Each item carries + * everything the editor needs to repopulate its form. + */ +const buildPlansList = (customPlans) => { + const presets = planMod.plans // { beginner, intermediate, advanced } + const custom = customPlans || {} + const order = ['beginner', 'intermediate', 'advanced'] + return order.map((id) => { + const p = custom[id] || presets[id] + return { + id: p.id, + name: p.name, + desc: p.description, + isCustom: !!custom[id], + totalDays: p.totalDays, + startTarget: p.startTarget, + increment: p.increment, + cycleDays: p.cycleDays + } + }) +} + +const MAX_DAYS = 365 +const MIN_DAYS = 1 +const MAX_TARGET = 3600 +const MIN_TARGET = 5 +const MAX_INCREMENT = 300 +const MAX_CYCLE = 30 + +const _toInt = (v, fallback) => { + const n = parseInt(v, 10) + return Number.isFinite(n) ? n : fallback +} Page({ data: { @@ -7,16 +46,17 @@ Page({ themeStyle: themeMod.BASE_VARS, themes: themeMod.THEMES, currentThemeId: 'orange', - plans: [ - { id: 'beginner', name: '初级', desc: '7天计划 · 30秒起步', days: 7 }, - { id: 'intermediate', name: '中级', desc: '14天计划 · 60秒起步', days: 14 }, - { id: 'advanced', name: '高级', desc: '30天计划 · 90秒起步', days: 30 } - ], + plans: [], currentPlanId: 'beginner', - dailyReminder: true, - reminderTime: '08:00', voiceGuide: true, - vibrate: true + vibrate: true, + icons: iconsMod.build(), + // Editor state + showPlanEditor: false, + editingPlanId: '', + editingIsCustom: false, + editingDraft: null, // { name, totalDays, startTarget, increment, cycleDays } + editorPreview: [] // [{ day, target }, ...] }, onLoad() { @@ -25,58 +65,59 @@ Page({ themeMod.applyThemeToPage(this) this.setData({ currentPlanId: s.planId, - dailyReminder: s.dailyReminder, - reminderTime: s.reminderTime, voiceGuide: s.voiceGuide, vibrate: s.vibrate, - currentThemeId: theme.id + currentThemeId: theme.id, + theme, + icons: iconsMod.build(theme), + plans: buildPlansList(storage.getCustomPlans()) }) }, onShow() { try { const tb = this.getTabBar() - if (tb) tb.setData({ selected: 2 }) + if (tb) tb.setData({ selected: 3 }) } catch (e) {} themeMod.applyThemeToPage(this) - // sync highlighted theme in case it was changed elsewhere - this.setData({ currentThemeId: themeMod.getCurrentTheme().id }) + const theme = themeMod.getCurrentTheme() + this.setData({ + currentThemeId: theme.id, + theme, + icons: iconsMod.build(theme), + plans: buildPlansList(storage.getCustomPlans()) + }) }, onSelectTheme(e) { const id = e.currentTarget.dataset.id const theme = themeMod.setTheme(id) this.setData({ currentThemeId: id }) - try { const tb = this.getTabBar() if (tb) tb.setData({ themeStyle: themeMod.getThemeStyle(theme) }) } catch (e) {} - themeMod.applyThemeToPage(this) }, onSelectPlan(e) { const planId = e.currentTarget.dataset.id const s = storage.getSettings() - s.planId = planId - storage.saveSettings(s) - this.setData({ currentPlanId: planId }) - wx.showToast({ title: '计划已切换', icon: 'success', duration: 1200 }) - }, + if (s.planId === planId) return - onToggleReminder(e) { - const s = storage.getSettings() - s.dailyReminder = e.detail.value - storage.saveSettings(s) - this.setData({ dailyReminder: e.detail.value }) - }, - - onReminderTimeChange(e) { - const s = storage.getSettings() - s.reminderTime = e.detail.value - storage.saveSettings(s) - this.setData({ reminderTime: e.detail.value }) + wx.showModal({ + title: '切换训练计划', + content: '切换计划会重置训练进度,原计划的历史记录仍会保留在训练记录中。确定继续吗?', + confirmText: '确定切换', + success: (res) => { + if (!res.confirm) return + s.planId = planId + s.planStartDate = storage.getToday() + storage.saveSettings(s) + this.setData({ currentPlanId: planId }) + wx.showToast({ title: '计划已切换', icon: 'success', duration: 1200 }) + } + }) }, onToggleVoice(e) { @@ -108,14 +149,196 @@ Page({ content: '将删除所有训练记录和连续打卡数据,此操作不可恢复。确定继续吗?', confirmText: '确定清除', confirmColor: '#E53935', + success: async (res) => { + if (!res.confirm) return + wx.removeStorageSync('training_records') + wx.removeStorageSync('current_streak') + wx.removeStorageSync('user_settings') + wx.removeStorageSync('app_theme') + wx.removeStorageSync('custom_plans') + try { await cloud.clearAll() } catch (e) {} + wx.setStorageSync('user_settings', { + planId: 'beginner', + voiceGuide: true, + vibrate: true, + planStartDate: storage.getToday() + }) + 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 -------- + + onTapEditPlan(e) { + // stopPropagation: prevent the row's onSelectPlan from also firing + const id = e.currentTarget.dataset.id + if (!id) return + const plan = planMod.getPlan(id, storage.getCustomPlans()) + const isCustom = !!storage.getCustomPlans()[id] + const draft = { + name: plan.name, + totalDays: plan.totalDays, + startTarget: plan.startTarget, + increment: plan.increment, + cycleDays: plan.cycleDays + } + this.setData({ + showPlanEditor: true, + editingPlanId: id, + editingIsCustom: isCustom, + editingDraft: draft, + editorPreview: plan.days + }) + }, + + onCloseEditor() { + this.setData({ showPlanEditor: false }) + }, + + // Swallow bubble for inner touches (e.g. tapping the sheet body + // shouldn't dismiss the modal — only the backdrop should). + onNoop() { /* no-op */ }, + + _recomputePreview(draft) { + return planMod.generatePlanDays({ + totalDays: draft.totalDays, + startTarget: draft.startTarget, + increment: draft.increment, + cycleDays: draft.cycleDays + }) + }, + + onEditName(e) { + const draft = { ...this.data.editingDraft, name: e.detail.value } + this.setData({ editingDraft: draft }) + }, + + onEditTotalDays(e) { + const draft = { + ...this.data.editingDraft, + totalDays: _toInt(e.detail.value, 0) + } + this.setData({ + editingDraft: draft, + editorPreview: this._recomputePreview(draft) + }) + }, + + onEditStartTarget(e) { + const draft = { + ...this.data.editingDraft, + startTarget: _toInt(e.detail.value, 0) + } + this.setData({ + editingDraft: draft, + editorPreview: this._recomputePreview(draft) + }) + }, + + onEditIncrement(e) { + const draft = { + ...this.data.editingDraft, + increment: _toInt(e.detail.value, 0) + } + this.setData({ + editingDraft: draft, + editorPreview: this._recomputePreview(draft) + }) + }, + + onEditCycleDays(e) { + const draft = { + ...this.data.editingDraft, + cycleDays: _toInt(e.detail.value, 0) + } + this.setData({ + editingDraft: draft, + editorPreview: this._recomputePreview(draft) + }) + }, + + _validateDraft(draft) { + if (!draft.name || !draft.name.trim()) return '请填写计划名称' + if (draft.totalDays < MIN_DAYS || draft.totalDays > MAX_DAYS) { + return `总天数需在 ${MIN_DAYS}-${MAX_DAYS} 之间` + } + if (draft.startTarget < MIN_TARGET || draft.startTarget > MAX_TARGET) { + return `起始时长需在 ${MIN_TARGET}-${MAX_TARGET} 秒之间` + } + if (draft.increment < 0 || draft.increment > MAX_INCREMENT) { + return `每次增加需在 0-${MAX_INCREMENT} 秒之间` + } + if (draft.cycleDays < 1 || draft.cycleDays > MAX_CYCLE) { + return `每几天增加需在 1-${MAX_CYCLE} 之间` + } + return null + }, + + onSavePlan() { + const { editingPlanId, editingDraft } = this.data + if (!editingPlanId || !editingDraft) return + const err = this._validateDraft(editingDraft) + if (err) { + wx.showToast({ title: err, icon: 'none', duration: 1800 }) + return + } + const planData = { + id: editingPlanId, + name: editingDraft.name.trim(), + totalDays: editingDraft.totalDays, + startTarget: editingDraft.startTarget, + increment: editingDraft.increment, + cycleDays: editingDraft.cycleDays, + days: this._recomputePreview(editingDraft), + description: `${editingDraft.totalDays}天计划 · ${editingDraft.startTarget}秒起步` + } + storage.saveCustomPlan(editingPlanId, planData) + this.setData({ + showPlanEditor: false, + plans: buildPlansList(storage.getCustomPlans()) + }) + wx.showToast({ title: '已保存', icon: 'success', duration: 1200 }) + }, + + onResetPlan() { + const { editingPlanId } = this.data + if (!editingPlanId) return + wx.showModal({ + title: '恢复默认', + content: '将恢复此计划的默认设置,自定义内容会丢失。确定继续吗?', + confirmText: '确定恢复', + confirmColor: '#E53935', success: (res) => { - if (res.confirm) { - wx.removeStorageSync('training_records') - wx.removeStorageSync('current_streak') - // also clear cloud data - try { require('../../utils/cloud').clearAll() } catch (e) {} - wx.showToast({ title: '已清除', icon: 'success', duration: 1500 }) + if (!res.confirm) return + storage.resetCustomPlan(editingPlanId) + // Reopen editor pointed at the preset version + const preset = planMod.getPlan(editingPlanId) + const draft = { + name: preset.name, + totalDays: preset.totalDays, + startTarget: preset.startTarget, + increment: preset.increment, + cycleDays: preset.cycleDays } + this.setData({ + editingIsCustom: false, + editingDraft: draft, + editorPreview: preset.days, + plans: buildPlansList(storage.getCustomPlans()) + }) + wx.showToast({ title: '已恢复默认', icon: 'success', duration: 1200 }) } }) } diff --git a/pages/settings/settings.json b/pages/settings/settings.json index d935131..55dade1 100644 --- a/pages/settings/settings.json +++ b/pages/settings/settings.json @@ -1,4 +1,7 @@ { - "usingComponents": {}, + "usingComponents": { + "ui-card": "/components/ui-card/ui-card", + "ui-btn": "/components/ui-btn/ui-btn" + }, "navigationBarTitleText": "设置" } diff --git a/pages/settings/settings.wxml b/pages/settings/settings.wxml index e7fc4c7..18deda7 100644 --- a/pages/settings/settings.wxml +++ b/pages/settings/settings.wxml @@ -1,8 +1,8 @@ - + - 🎨 + 配色方案 @@ -15,15 +15,15 @@ > {{item.name}} - + - + - + - 📋 + 训练计划 @@ -41,82 +41,68 @@ {{item.name}} {{item.desc}} - + + + + + + - - - - - - 🔔 - 提醒 - - - - 📅 - 每日提醒 - - - - - - 🕐 - 提醒时间 - - - {{reminderTime}} - - - + - + - + 训练偏好 - 🔊 + 语音引导 - 📳 + 振动反馈 - + - + - 🗑 + 数据管理 - - + + - + - + 关于 版本 - v1.2.0 + v1.5 更新日期 - 2026-06-04 + 2026-06-10 开发者 @@ -126,5 +112,99 @@ Made with ❤️ for better fitness + + + + + + + + 编辑训练计划 + + + 计划名称 + + + + + 总天数 + + + + + + 起始时长(秒) + + + + 每次增加(秒) + + + + + + 每几天增加 + + + + + + 预览 + 共 {{editorPreview.length}} 天 + + + + 第 {{item.day}} 天 + {{item.target}} 秒 + + + 请填写总天数 + + + + + + + 恢复默认 + + + 保存 + + + 取消 + diff --git a/pages/settings/settings.wxss b/pages/settings/settings.wxss index b5518bd..8880dbf 100644 --- a/pages/settings/settings.wxss +++ b/pages/settings/settings.wxss @@ -4,10 +4,10 @@ margin-bottom: 20rpx; } -.section-emoji { - font-size: 28rpx; +.section-icon { + width: 32rpx; + height: 32rpx; margin-right: 10rpx; - line-height: 1; } .section-header .section-title { @@ -15,9 +15,7 @@ } /* ---- theme picker ---- */ -.theme-list { - margin-top: 0; -} +.theme-list { margin-top: 0; } .theme-item { display: flex; @@ -27,9 +25,7 @@ transition: background 0.2s ease; } -.theme-item:last-child { - border-bottom: none; -} +.theme-item:last-child { border-bottom: none; } .theme-item.active { background: var(--primary-bg); @@ -64,11 +60,14 @@ color: var(--text); } -/* ---- plan list ---- */ -.plan-list { - margin-top: 0; +.theme-check-img { + width: 32rpx; + height: 32rpx; } +/* ---- plan list ---- */ +.plan-list { margin-top: 0; } + .plan-item { display: flex; align-items: center; @@ -76,9 +75,7 @@ border-bottom: 1rpx solid var(--border); } -.plan-item:last-child { - border-bottom: none; -} +.plan-item:last-child { border-bottom: none; } .plan-item.active { background: var(--primary-bg); @@ -132,10 +129,41 @@ margin-top: 4rpx; } -.plan-check { - color: var(--primary); - font-size: 32rpx; - font-weight: 700; +.plan-check-img { + width: 32rpx; + height: 32rpx; +} + +.plan-actions { + display: flex; + align-items: center; + gap: 8rpx; + flex-shrink: 0; +} + +.plan-edit-btn { + width: 56rpx; + height: 56rpx; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: transparent; + transition: background 0.15s ease; +} + +.plan-edit-btn:active { + background: rgba(var(--primary-rgb), 0.12); +} + +.plan-edit-img { + width: 36rpx; + height: 36rpx; + opacity: 0.55; +} + +.plan-item.active .plan-edit-img { + opacity: 0.85; } /* ---- setting rows ---- */ @@ -147,9 +175,7 @@ border-bottom: 1rpx solid var(--border); } -.setting-row:last-child { - border-bottom: none; -} +.setting-row:last-child { border-bottom: none; } .setting-left { display: flex; @@ -157,7 +183,8 @@ } .setting-icon { - font-size: 28rpx; + width: 32rpx; + height: 32rpx; margin-right: 12rpx; } @@ -172,13 +199,7 @@ } /* ---- about ---- */ -.about-card { - margin-top: 8rpx; -} - -.about-list { - margin-top: 12rpx; -} +.about-list { margin-top: 12rpx; } .about-row { display: flex; @@ -188,9 +209,7 @@ border-bottom: 1rpx solid var(--border); } -.about-row:last-child { - border-bottom: none; -} +.about-row:last-child { border-bottom: none; } .about-label { font-size: 26rpx; @@ -218,3 +237,206 @@ font-size: 22rpx; color: #CCCCCC; } + +/* ---- plan editor modal ---- */ +/* 普通 view 实现的底部 sheet,不用 ui-modal 是因为 WeChat 自定义组件根元素 + 上 position: fixed 会被运行时吞掉,导致 body 落到容器末尾(非浮层)。 */ +.plan-editor-mask { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + z-index: 1000; + display: flex; + align-items: flex-end; + justify-content: center; +} + +.plan-editor-mask__bg { + position: absolute; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0, 0, 0, 0.45); + animation: planEditorFade 0.25s ease; +} + +.plan-editor-sheet { + position: relative; + width: 100%; + max-height: 88vh; + overflow-y: auto; + box-sizing: border-box; + background: #FFFFFF; + border-radius: 32rpx 32rpx 0 0; + padding: 12rpx 32rpx 40rpx; + /* Sheet is z-index 1000 above the tab bar (z-index 999), but the tab bar + still visually covers the bottom of the sheet. Add the tab bar's + height (110rpx + safe area) to padding-bottom so "保存/取消" sit + clearly above the tab bar. */ + padding-bottom: calc(150rpx + env(safe-area-inset-bottom)); + animation: planEditorSlideUp 0.3s cubic-bezier(0.32, 0.72, 0, 1); +} + +.plan-editor-handle { + width: 64rpx; + height: 8rpx; + background: #DDD; + border-radius: 4rpx; + margin: 0 auto 16rpx; +} + +@keyframes planEditorFade { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes planEditorSlideUp { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} + +.editor-title { + display: block; + text-align: center; + font-size: 32rpx; + font-weight: 700; + color: var(--text); + margin-bottom: 20rpx; +} + +.editor-field { + display: flex; + flex-direction: column; + margin-bottom: 14rpx; +} + +.editor-row { + display: flex; + gap: 12rpx; +} + +.editor-row .editor-field-half { + flex: 1; +} + +.editor-label { + font-size: 22rpx; + color: var(--text-secondary); + margin-bottom: 6rpx; + line-height: 1; +} + +.editor-input { + height: 68rpx; + background: #F5F5F5; + border-radius: 10rpx; + padding: 0 16rpx; + font-size: 26rpx; + color: var(--text); + box-sizing: border-box; + width: 100%; +} + +.editor-preview { + margin-top: 6rpx; + background: #FAFAFA; + border-radius: 14rpx; + padding: 12rpx 16rpx; +} + +.editor-preview-head { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 8rpx; +} + +.editor-preview-title { + font-size: 22rpx; + font-weight: 600; + color: var(--text); +} + +.editor-preview-meta { + font-size: 20rpx; + color: var(--text-secondary); +} + +.editor-preview-list { + /* No fixed max-height: the modal body scrolls, preview expands inline. */ + min-height: 60rpx; +} + +.editor-preview-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8rpx 0; + border-bottom: 1rpx solid #EEE; + font-size: 24rpx; +} + +.editor-preview-row:last-child { + border-bottom: none; +} + +.editor-preview-day { + color: var(--text); +} + +.editor-preview-target { + color: var(--primary); + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.editor-preview-empty { + text-align: center; + color: var(--text-secondary); + font-size: 22rpx; + padding: 16rpx 0; +} + +.editor-actions { + display: flex; + gap: 12rpx; + margin-top: 20rpx; +} + +.editor-action-reset, +.editor-action-save { + flex: 1; + height: 80rpx; + display: flex; + align-items: center; + justify-content: center; + border-radius: 40rpx; + font-size: 28rpx; + font-weight: 600; + transition: opacity 0.15s ease, background 0.15s ease; +} + +.editor-action-reset { + background: #FFFFFF; + color: var(--text-secondary); + border: 2rpx solid var(--border); +} + +.editor-action-reset:active { + background: #F5F5F5; +} + +.editor-action-save { + background: linear-gradient(135deg, var(--primary), var(--primary-light)); + color: #FFFFFF; + box-shadow: 0 4rpx 12rpx rgba(var(--primary-rgb), 0.25); +} + +.editor-action-save:active { + opacity: 0.9; +} + +.editor-cancel { + display: block; + text-align: center; + font-size: 26rpx; + color: var(--text-secondary); + padding: 12rpx 0 0; +} diff --git a/pages/timer/timer.js b/pages/timer/timer.js index 89b1ab4..b685a1c 100644 --- a/pages/timer/timer.js +++ b/pages/timer/timer.js @@ -2,6 +2,8 @@ const Timer = require('../../utils/timer') const storage = require('../../utils/storage') const planMod = require('../../utils/plan') const themeMod = require('../../utils/theme') +const iconsMod = require('../../utils/icons') +const voice = require('../../utils/voice') Page({ data: { @@ -16,7 +18,8 @@ Page({ overtime: 0, todayTarget: 0, planDay: 1, - isFreeMode: false + isFreeMode: false, + icons: iconsMod.build() }, onLoad(options) { @@ -26,22 +29,25 @@ Page({ onShow() { themeMod.applyThemeToPage(this) + const theme = themeMod.getCurrentTheme() + this.setData({ theme, icons: iconsMod.build(theme) }) }, _init(options) { - let target let planDay = 1 let isFreeMode = false if (options && options.free) { - target = parseInt(options.free) || 60 + const parsed = parseInt(options.free) + target = parsed > 0 ? parsed : 60 isFreeMode = true } else { const settings = storage.getSettings() const allRecords = Object.values(storage.getRecords()).flat() - const plan = planMod.getPlan(settings.planId) - planDay = planMod.getPlanDay(settings.planId, allRecords) + const customPlans = storage.getCustomPlans() + const plan = planMod.getPlan(settings.planId, customPlans) + planDay = planMod.getPlanDay(settings.planId, allRecords, settings.planStartDate) target = planMod.getTodayTarget(settings.planId, Math.min(planDay, plan.totalDays)) } @@ -53,25 +59,101 @@ Page({ isFreeMode }) + if (this._timer) { + this._timer.stop() + this._timer = null + } + + // Reset reminder state + this._halfwaySignaled = false + this._lastMinuteAt = 0 + this._lastCountdown = 0 + this._timer = new Timer({ onTick: (tick) => { this.setData({ remaining: tick.remaining > 0 ? tick.remaining : 0, overtime: tick.remaining <= 0 ? tick.elapsed - this.data.duration : 0 }) + this._remind(tick) }, onComplete: () => { this.setData({ status: 'completed', isCompleted: true }) - if (storage.getSettings().vibrate) { + const s = storage.getSettings() + if (s.vibrate) { try { wx.vibrateLong() } catch (e) {} } + // 4th voice prompt: completion + if (s.voiceGuide !== false) { + voice.play('complete') + } wx.showToast({ title: '目标完成! 太棒了!', icon: 'success', duration: 2000 }) } }) }, + /** + * Per-tick reminders: vibration + voice prompts at 4 fixed points. + * - halfway: once when elapsed reaches half of duration + * - 30s: once when remaining hits 30 + * - 10s: once when remaining hits 10 + * - complete: fired in onComplete above + * + * Vibration and voice are independently gated by `vibrate` / `voiceGuide` + * settings — user can mute haptics but keep voice (e.g. in office) or vice versa. + */ + _remind(tick) { + const s = storage.getSettings() + const { remaining, elapsed, duration } = tick + const vibrate = (n, ms) => { + if (!s.vibrate) return + try { + for (let i = 0; i < n; i++) { + setTimeout(() => wx.vibrateShort(), i * (ms + 30)) + } + } catch (e) {} + } + + // Each prompt is gated on a minimum duration so short free-mode + // trainings don't trigger them at nonsensical times (e.g. firing + // "最后30秒" on the first tick of a 30s session because remaining + // happens to equal 30 at elapsed=0). + // + // Thresholds chosen so prompts fire in the right ORDER for any + // duration: halfway first, then last30, then last10. + + // 1. Halfway: needs >=10s total so the midpoint is at least 5s in + if (duration >= 10 && !this._halfwaySignaled && elapsed >= Math.floor(duration / 2)) { + this._halfwaySignaled = true + if (s.voiceGuide !== false) voice.play('halfway') + vibrate(2, 150) + } + + // 2. Last 30 seconds: only if duration > 60s so halfway has already passed + if (duration > 60 && remaining === 30 && !this._last30Signaled) { + this._last30Signaled = true + if (s.voiceGuide !== false) voice.play('last30') + } + + // 3. Last 10 seconds: only if duration > 20s so we have at least 10s of training + if (duration > 20 && remaining === 10 && !this._last10Signaled) { + this._last10Signaled = true + if (s.voiceGuide !== false) voice.play('last10') + } + + // 4. Countdown buzzes at 5..1: only if duration >= 6s (so we can reach them) + if (duration >= 6 && remaining <= 5 && remaining > 0 && remaining !== this._lastCountdown) { + this._lastCountdown = remaining + vibrate(1, 100) + } + }, + onUnload() { - if (this._timer) this._timer.stop() + voice.stop() + if (this._timer) { + this._timer.stop() + this._timer = null + } }, onStart() { @@ -88,6 +170,7 @@ Page({ onPause() { if (!this.data.isRunning || this.data.isPaused) return this._timer.pause() + voice.stop() this.setData({ isPaused: true, status: 'paused' }) }, @@ -103,6 +186,11 @@ Page({ finishTraining() { const elapsed = this._timer.stop() + if (elapsed < 3) { + wx.showToast({ title: '训练时间太短', icon: 'none', duration: 1500 }) + setTimeout(() => { wx.navigateBack() }, 1500) + return + } const today = storage.getToday() storage.saveRecord({ date: today, @@ -113,6 +201,6 @@ Page({ storage.updateStreak(today) wx.showToast({ title: `已记录 ${elapsed}秒`, icon: 'none', duration: 1500 }) - setTimeout(() => { wx.navigateBack() }, 3000) + setTimeout(() => { wx.navigateBack() }, 1500) } }) diff --git a/pages/timer/timer.json b/pages/timer/timer.json index f5d17c4..2627f3e 100644 --- a/pages/timer/timer.json +++ b/pages/timer/timer.json @@ -1,6 +1,7 @@ { "usingComponents": { - "progress-ring": "../../components/progress-ring/progress-ring" + "progress-ring": "/components/progress-ring/progress-ring", + "ui-btn": "/components/ui-btn/ui-btn" }, "navigationBarTitleText": "训练中" } diff --git a/pages/timer/timer.wxml b/pages/timer/timer.wxml index 0d7a8e3..cc10f7c 100644 --- a/pages/timer/timer.wxml +++ b/pages/timer/timer.wxml @@ -30,7 +30,7 @@ - 🔥 + +{{overtime}}s @@ -38,58 +38,64 @@ - 🧘 + 自由训练 · 目标 {{duration}} 秒 今日目标 {{duration}} 秒 · 第 {{planDay}} 天 - 💪 + 保持姿势,核心收紧! - + 已暂停 - 🎉 + 目标完成! 继续坚持! - + > - - - + > + diff --git a/pages/timer/timer.wxss b/pages/timer/timer.wxss index 97828a1..a4c08e3 100644 --- a/pages/timer/timer.wxss +++ b/pages/timer/timer.wxss @@ -27,46 +27,14 @@ z-index: 0; } -.breathe-ring-1 { - width: 480rpx; - height: 480rpx; - animation-delay: 0s; -} +.breathe-ring-1 { width: 480rpx; height: 480rpx; animation-delay: 0s; } +.breathe-ring-2 { width: 400rpx; height: 400rpx; animation-delay: 0.5s; border-color: rgba(var(--primary-rgb), 0.25); border-width: 4rpx; } +.breathe-ring-3 { width: 320rpx; height: 320rpx; animation-delay: 1s; border-color: rgba(var(--primary-rgb), 0.35); border-width: 5rpx; } -.breathe-ring-2 { - width: 400rpx; - height: 400rpx; - animation-delay: 0.5s; - border-color: rgba(var(--primary-rgb), 0.25); - border-width: 4rpx; -} - -.breathe-ring-3 { - width: 320rpx; - height: 320rpx; - animation-delay: 1s; - border-color: rgba(var(--primary-rgb), 0.35); - border-width: 5rpx; -} - -.breathe-ring.fast { - animation: breathePulseFast 1.4s ease-in-out infinite; -} - -.breathe-ring.fast.breathe-ring-1 { - animation-delay: 0s; - border-color: rgba(var(--primary-rgb), 0.3); -} - -.breathe-ring.fast.breathe-ring-2 { - animation-delay: 0.25s; - border-color: rgba(var(--primary-rgb), 0.45); -} - -.breathe-ring.fast.breathe-ring-3 { - animation-delay: 0.5s; - border-color: rgba(var(--primary-rgb), 0.6); -} +.breathe-ring.fast { animation: breathePulseFast 1.4s ease-in-out infinite; } +.breathe-ring.fast.breathe-ring-1 { animation-delay: 0s; border-color: rgba(var(--primary-rgb), 0.3); } +.breathe-ring.fast.breathe-ring-2 { animation-delay: 0.25s; border-color: rgba(var(--primary-rgb), 0.45); } +.breathe-ring.fast.breathe-ring-3 { animation-delay: 0.5s; border-color: rgba(var(--primary-rgb), 0.6); } /* celebration burst */ .celebrate-burst { @@ -91,18 +59,9 @@ .s6 { top: -20rpx; left: 40rpx; animation-delay: 0.05s; } @keyframes sparkBurst { - 0% { - opacity: 0; - transform: translate(0, 0) scale(0.3); - } - 40% { - opacity: 1; - transform: translate(0, -30rpx) scale(1.3); - } - 100% { - opacity: 0; - transform: translate(0, -80rpx) scale(0.4); - } + 0% { opacity: 0; transform: translate(0, 0) scale(0.3); } + 40% { opacity: 1; transform: translate(0, -30rpx) scale(1.3); } + 100% { opacity: 0; transform: translate(0, -80rpx) scale(0.4); } } /* overtime badge */ @@ -125,8 +84,8 @@ } .overtime-icon { - font-size: 28rpx; - line-height: 1; + width: 28rpx; + height: 28rpx; } /* ---- hint section ---- */ @@ -142,9 +101,9 @@ gap: 10rpx; } -.hint-emoji { - font-size: 28rpx; - line-height: 1; +.hint-icon { + width: 32rpx; + height: 32rpx; } .running-pulse { @@ -161,23 +120,11 @@ line-height: 1.3; } -.hint-text.running { - color: var(--primary); - font-weight: 600; -} +.hint-text.running { color: var(--primary); font-weight: 600; } +.hint-text.paused { color: var(--primary-light); } +.hint-text.completed { color: var(--success); font-weight: 600; } -.hint-text.paused { - color: var(--primary-light); -} - -.hint-text.completed { - color: var(--success); - font-weight: 600; -} - -.completed-hint { - animation: popIn 0.5s ease; -} +.completed-hint { animation: popIn 0.5s ease; } /* ---- controls ---- */ .controls { @@ -187,59 +134,8 @@ justify-content: center; } -.ctrl-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 12rpx; - width: 300rpx; - height: 108rpx; - border-radius: 54rpx; - font-size: 36rpx; - font-weight: 600; - text-align: center; - border: none; - padding: 0; -} - -.ctrl-btn:active { - transform: scale(0.94); -} - -.ctrl-btn::after { - border: none; -} - -.ctrl-icon { - font-size: 32rpx; - line-height: 1; -} - -.start-btn { - background: linear-gradient(135deg, var(--primary), var(--primary-light)); - color: #fff; - box-shadow: 0 8rpx 28rpx rgba(var(--primary-rgb), 0.35); -} - -.pause-btn { - background: var(--primary-bg); - color: var(--primary); - margin-right: 28rpx; -} - -.resume-btn { - background: linear-gradient(135deg, var(--primary), var(--primary-light)); - color: #fff; - margin-right: 28rpx; - box-shadow: 0 8rpx 28rpx rgba(var(--primary-rgb), 0.35); -} - -.stop-btn { - background: #F5F5F5; - color: var(--text-secondary); -} - .ctrl-row { display: flex; justify-content: center; + align-items: center; } diff --git a/project.config.json b/project.config.json index 6ef8d18..bc22419 100644 --- a/project.config.json +++ b/project.config.json @@ -56,5 +56,6 @@ "tabIndent": "insertSpaces", "tabSize": 2 }, - "simulatorPluginLibVersion": {} + "simulatorPluginLibVersion": {}, + "cloudfunctionTemplateRoot": "cloudfunctionTemplate/" } \ No newline at end of file diff --git a/utils/cloud.js b/utils/cloud.js index a56b5b2..64b0aad 100644 --- a/utils/cloud.js +++ b/utils/cloud.js @@ -1,20 +1,22 @@ const DB_COLLECTION = 'plank_data' +const ENV_ID = 'cloudbase-d1g56kl2q8f4f7d8a' let _db = null let _pushTimer = null let _enabled = false +let _docId = null // cache doc id after first successful query const getDb = () => { if (!_enabled) return null if (!_db) { - try { _db = wx.cloud.database() } catch (e) { _enabled = false } + try { _db = wx.cloud.database({ env: ENV_ID }) } catch (e) { _enabled = false } } return _db } const init = () => { try { - wx.cloud.init({ env: 'cloudbase-d1g56kl2q8f4f7d8a', traceUser: true }) + wx.cloud.init({ env: ENV_ID, traceUser: true }) _enabled = true } catch (e) { _enabled = false @@ -25,10 +27,22 @@ const pullAll = async () => { const db = getDb() if (!db) return null try { - const res = await db.collection(DB_COLLECTION) - .where({ _openid: '{openid}' }) - .get() - return (res && res.data && res.data.length > 0) ? res.data[0] : null + // First try cached doc id + if (_docId) { + try { + const res = await db.collection(DB_COLLECTION).doc(_docId).get() + 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 + const res = await db.collection(DB_COLLECTION).limit(1).get() + if (res && res.data && res.data.length > 0) { + _docId = res.data[0]._id + return res.data[0] + } + return null } catch (e) { return null } @@ -51,20 +65,29 @@ const _doPush = async () => { records: storage.getRecords(), settings: storage.getSettings(), streak: storage.getStreak(), + customPlans: storage.getCustomPlans(), themeId: themeMod.getCurrentTheme().id, updatedAt: db.serverDate() } - // Query then upsert: one doc per user - const existing = await db.collection(DB_COLLECTION) - .where({ _openid: '{openid}' }) - .get() + // Use cached doc id if available + if (_docId) { + try { + await db.collection(DB_COLLECTION).doc(_docId).update({ data }) + return + } catch (e) { + _docId = null // doc may have been deleted + } + } + + // Query for existing doc (relies on security rules to scope to current user) + 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) - .update({ data }) + _docId = existing.data[0]._id + await db.collection(DB_COLLECTION).doc(_docId).update({ data }) } else { - await db.collection(DB_COLLECTION).add({ data }) + const res = await db.collection(DB_COLLECTION).add({ data }) + if (res && res._id) _docId = res._id } } catch (e) { // Silently retry on next write @@ -75,14 +98,20 @@ const clearAll = async () => { const db = getDb() if (!db) return try { - const existing = await db.collection(DB_COLLECTION) - .where({ _openid: '{openid}' }) - .get() - if (existing && existing.data && existing.data.length > 0) { - await db.collection(DB_COLLECTION) - .doc(existing.data[0]._id) - .remove() + if (_docId) { + try { + await db.collection(DB_COLLECTION).doc(_docId).remove() + _docId = null + 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 } diff --git a/utils/icons.js b/utils/icons.js new file mode 100644 index 0000000..13c41f2 --- /dev/null +++ b/utils/icons.js @@ -0,0 +1,136 @@ +/** + * Inline SVG icon library for the plank training app. + * + * Each icon is a 24x24 Material-style path. We bake the color directly into + * the SVG data URI, so consumers can switch between "active" and "inactive" + * variants by simply selecting a different key — no font loading, no CSS + * pseudo-element tricks, and exact pixel rendering at any size. + * + * Usage: + * const icons = require('../../utils/icons') + * const map = icons.build({ primary: '#FF6B35', primaryLight: '#FF8C5A', success: '#4CAF50' }) + * // map.playFill => "data:image/svg+xml;..." (orange play) + * // map.play => "data:image/svg+xml;..." (gray play) + * + * Naming convention: + * = outlined / inactive (neutral gray) + * Fill = filled / active (theme color) + */ + +const PATHS = { + // Navigation & UI + home: '', + homeFill: '', + calendar: '', + calendarFill: '', + settings: '', + settingsFill: '', + back: '', + right: '', + close: '', + check: '', + // Status & feedback + hot: '', + hotFill: '', + like: '', + likeFill: '', + // Training specific + play: '', + playFill: '', + stop: '', + stopFill: '', + pause: '', + pauseFill: '', + time: '', + timeFill: '', + // Markers & targets + target: '', + targetFill: '', + mark: '', + markFill: '', + // Stats & charts + rank: '', + rankFill: '', + crown: '', + crownFill: '', + form: '', + formFill: '', + // Notification + notification: '', + notificationFill: '', + notificationForbid: '', + notificationForbidFill: '', + // People & users + people: '', + peopleFill: '', + // Actions + delete: '', + deleteFill: '', + info: '', + infoFill: '', + // Theme & preferences + skin: '', + skinFill: '', + // Lightbulb & tip + light: '', + lightFill: '', + // Voice + voice: '', + voiceFill: '', + // Round check + roundCheck: '', + roundCheckFill: '', + // Emoji / face + emoji: '', + emojiFill: '', + // Edit (pencil) + edit: '', + editFill: '' +} + +// NOTE: must encodeURIComponent the SVG so that the `#` in colors like +// `#FF6B35` doesn't get parsed as a fragment-identifier delimiter — that +// would silently truncate the SVG and the image would fail to render. +const _svg = (path, color) => { + const svg = + '' + path + '' + return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg) +} + +const INACTIVE = '#999999' +const DANGER = '#E53935' + +/** + * Build an icon map keyed by name. Two variants per name: + * = inactive (gray) + * Fill = active (theme primary) + * Plus semantic aliases (delete, etc.) with appropriate colors. + * + * @param {object} theme - { primary, primaryLight, success, danger } + * @returns {object} { : dataUri, ... } + */ +const build = (theme) => { + const t = theme || {} + const p = t.primary || '#FF6B35' + const s = t.success || '#4CAF50' + const d = t.danger || DANGER + const out = {} + Object.keys(PATHS).forEach((name) => { + if (name.endsWith('Fill')) { + const base = name.slice(0, -4) + out[base] = _svg(PATHS[name], INACTIVE) + out[name] = _svg(PATHS[name], p) + } else { + out[name] = _svg(PATHS[name], INACTIVE) + } + }) + // Semantic overrides + out.deleteActive = _svg(PATHS.deleteFill, d) + out.deleteInactive = _svg(PATHS.delete, INACTIVE) + out.successFill = _svg(PATHS.roundCheckFill, s) + out.successLine = _svg(PATHS.roundCheck, INACTIVE) + return out +} + +module.exports = { build, PATHS, INACTIVE } diff --git a/utils/plan.js b/utils/plan.js index bf0e580..caaff8e 100644 --- a/utils/plan.js +++ b/utils/plan.js @@ -1,36 +1,77 @@ const { formatDate } = require('./util') +/** + * Build the per-day target array for a plan from its formula fields. + * + * target(day) = startTarget + floor((day - 1) / cycleDays) * increment + * + * Examples: + * { totalDays: 7, startTarget: 30, increment: 10, cycleDays: 1 } + * → 30, 40, 50, 60, 70, 80, 90 (preset: 初级) + * { totalDays: 14, startTarget: 60, increment: 15, cycleDays: 2 } + * → 60, 60, 75, 75, 90, 90, ... (preset: 中级) + * { totalDays: 30, startTarget: 90, increment: 15, cycleDays: 3 } + * → 90, 90, 90, 105, 105, 105, ... (preset: 高级) + */ +const generatePlanDays = ({ totalDays, startTarget, increment, cycleDays }) => { + const n = Math.max(0, Math.floor(totalDays) || 0) + const start = Math.max(0, Math.floor(startTarget) || 0) + const inc = Math.max(0, Math.floor(increment) || 0) + const cycle = Math.max(1, Math.floor(cycleDays) || 1) + const days = [] + for (let i = 0; i < n; i++) { + const d = i + 1 + days.push({ day: d, target: start + Math.floor((d - 1) / cycle) * inc }) + } + return days +} + +const _describe = (plan) => + `${plan.totalDays}天计划 · ${plan.startTarget}秒起步` + +const _buildPlan = (base) => { + const plan = { ...base } + plan.days = generatePlanDays(plan) + plan.description = _describe(plan) + return plan +} + const plans = { - beginner: { + beginner: _buildPlan({ id: 'beginner', name: '初级 (7天)', totalDays: 7, - description: '适合初学者,从30秒起步', - days: Array.from({ length: 7 }, (_, i) => ({ day: i + 1, target: 30 + i * 10 })) - }, - intermediate: { + startTarget: 30, + increment: 10, + cycleDays: 1 + }), + intermediate: _buildPlan({ id: 'intermediate', name: '中级 (14天)', totalDays: 14, - description: '有一定基础,从60秒起步', - days: Array.from({ length: 14 }, (_, i) => { - const pair = Math.floor(i / 2) - return { day: i + 1, target: 60 + pair * 15 } - }) - }, - advanced: { + startTarget: 60, + increment: 15, + cycleDays: 2 + }), + advanced: _buildPlan({ id: 'advanced', name: '高级 (30天)', totalDays: 30, - description: '挑战自我,从90秒起步', - days: Array.from({ length: 30 }, (_, i) => ({ - day: i + 1, - target: 90 + Math.floor(i / 3) * 15 - })) - } + startTarget: 90, + increment: 15, + cycleDays: 3 + }) } -const getPlan = (planId) => plans[planId] || plans.beginner +/** + * Look up a plan by id. User-customized plans (passed in `customPlans`) + * shadow the preset with the same id, so e.g. editing "beginner" replaces + * the preset beginner for the user without changing the planId. + */ +const getPlan = (planId, customPlans) => { + if (customPlans && customPlans[planId]) return customPlans[planId] + return plans[planId] || plans.beginner +} const getTodayTarget = (planId, currentDay) => { const plan = getPlan(planId) @@ -38,15 +79,24 @@ const getTodayTarget = (planId, currentDay) => { return day ? day.target : plan.days[0].target } -const getPlanDay = (planId, records) => { +/** + * Calculate the current plan day based on training records since plan start. + * Only counts records from the current plan's start date onward. + */ +const getPlanDay = (planId, records, planStartDate) => { const plan = getPlan(planId) const today = formatDate(new Date()) - const trainedDays = new Set( - records - .filter(r => r.date !== today && r.planId === planId) - .map(r => r.date) - ).size + + // Filter records: same plan, after plan start date, and not today + const eligibleRecords = records.filter(r => { + if (r.planId !== planId) return false + if (r.date === today) return false + if (planStartDate && r.date < planStartDate) return false + return true + }) + + const trainedDays = new Set(eligibleRecords.map(r => r.date)).size return Math.min(trainedDays + 1, plan.totalDays) } -module.exports = { plans, getPlan, getTodayTarget, getPlanDay } +module.exports = { plans, generatePlanDays, getPlan, getTodayTarget, getPlanDay } diff --git a/utils/storage.js b/utils/storage.js index bde7aad..bbd8b79 100644 --- a/utils/storage.js +++ b/utils/storage.js @@ -4,11 +4,48 @@ const cloud = require('./cloud') const RECORDS_KEY = 'training_records' const SETTINGS_KEY = 'user_settings' const STREAK_KEY = 'current_streak' +const CUSTOM_PLANS_KEY = 'custom_plans' -const getRecords = () => wx.getStorageSync(RECORDS_KEY) || {} +/** + * Backfill an `id` for any record that doesn't have one. + * Older records (e.g. data restored from cloud or migrated from a build + * that didn't yet write the id field) come in without an id, which makes + * delete-by-id fail with "记录数据异常". Assign a stable, deterministic id + * derived from the record's position + payload so the user can still + * delete them. + * + * Returns true if any record was patched. + */ +const _ensureRecordIds = (records) => { + if (!records || typeof records !== 'object') return false + let changed = false + Object.keys(records).forEach((month) => { + const list = records[month] + if (!Array.isArray(list)) return + list.forEach((r, i) => { + if (r && (r.id == null || r.id === '')) { + // month is YYYY-MM; strip the dash so the id stays a safe token + const m = String(month).replace(/[^0-9]/g, '') + r.id = `legacy-${m}-${i}-${r.duration || 0}` + changed = true + } + }) + }) + return changed +} + +const getRecords = () => { + const records = wx.getStorageSync(RECORDS_KEY) || {} + if (_ensureRecordIds(records)) { + // Persist the backfill so subsequent calls don't re-mutate; the cloud + // will resync on the next saveRecord/deleteRecord. + try { wx.setStorageSync(RECORDS_KEY, records) } catch (e) {} + } + return records +} const saveRecord = (record) => { - record.id = Date.now() + record.id = String(Date.now()) const records = getRecords() const month = record.date.substring(0, 7) if (!records[month]) records[month] = [] @@ -19,12 +56,21 @@ const saveRecord = (record) => { } const deleteRecord = (recordId) => { + if (!recordId && recordId !== 0) return // guard against undefined/null + const id = String(recordId) + if (!id || id === 'undefined' || id === 'null') return const records = getRecords() + let found = false Object.keys(records).forEach((month) => { - records[month] = records[month].filter((r) => r.id !== recordId) + const before = records[month].length + records[month] = records[month].filter((r) => String(r.id) !== id) + if (records[month].length < before) found = true if (records[month].length === 0) delete records[month] }) + if (!found) return // id not found, don't overwrite storage wx.setStorageSync(RECORDS_KEY, records) + // Re-validate streak: deleting the last record for a day should break the streak + validateStreak() cloud.pushAll() } @@ -50,8 +96,6 @@ const getTotalStats = () => { const getSettings = () => wx.getStorageSync(SETTINGS_KEY) || { planId: 'beginner', - dailyReminder: true, - reminderTime: '08:00', voiceGuide: true, vibrate: true } @@ -63,6 +107,75 @@ const saveSettings = (settings) => { const getStreak = () => wx.getStorageSync(STREAK_KEY) || { count: 0, lastDate: '' } +/** + * User-customized training plans, keyed by planId (e.g. 'beginner'). + * Each value is a full plan object built by `utils/plan.generatePlanDays` + * plus the editor's formula fields (startTarget / increment / cycleDays). + * + * Storage shape mirrors `getRecords()`: an object map so partial writes + * don't have to read+modify+write the whole collection. + */ +const getCustomPlans = () => { + const raw = wx.getStorageSync(CUSTOM_PLANS_KEY) + if (raw && typeof raw === 'object' && !Array.isArray(raw)) return raw + return {} +} + +const saveCustomPlan = (planId, planData) => { + if (!planId) return + const plans = getCustomPlans() + plans[planId] = planData + wx.setStorageSync(CUSTOM_PLANS_KEY, plans) + cloud.pushAll() +} + +const resetCustomPlan = (planId) => { + if (!planId) return + const plans = getCustomPlans() + if (!(planId in plans)) return + delete plans[planId] + wx.setStorageSync(CUSTOM_PLANS_KEY, plans) + cloud.pushAll() +} + +/** + * Validate streak against actual training records. + * Call on app launch to correct any inconsistencies + * (e.g. after deleting the only record for a day). + */ +const validateStreak = () => { + const streak = getStreak() + if (!streak.lastDate) return streak + + const today = getToday() + const yesterday = getDateOffset(today, -1) + const records = getRecords() + const allDates = new Set() + Object.values(records).forEach(monthRecs => { + monthRecs.forEach(r => allDates.add(r.date)) + }) + + // If lastDate is today, check today still has records + if (streak.lastDate === today) { + if (!allDates.has(today)) { + // Today's records were deleted + if (allDates.has(yesterday)) { + streak.lastDate = yesterday + streak.count = Math.max(1, streak.count - 1) + } else { + streak.count = 0 + streak.lastDate = '' + } + wx.setStorageSync(STREAK_KEY, streak) + } + return streak + } + + // If lastDate is not today and not yesterday, streak is already broken + // (updateStreak handles reset on next training), no action needed here + return streak +} + const updateStreak = (date) => { const streak = getStreak() const today = date || getToday() @@ -84,7 +197,8 @@ const updateStreak = (date) => { const getToday = () => formatDate(new Date()) const getDateOffset = (dateStr, offset) => { - const d = new Date(dateStr) + // Use YYYY/MM/DD for iOS compatibility (YYYY-MM-DD may fail on some iOS) + const d = new Date(dateStr.replace(/-/g, '/')) d.setDate(d.getDate() + offset) return formatDate(d) } @@ -113,10 +227,13 @@ module.exports = { getTotalStats, getSettings, saveSettings, + getCustomPlans, + saveCustomPlan, + resetCustomPlan, getStreak, + validateStreak, updateStreak, getToday, - formatDate, getDateOffset, getTodayRecord } diff --git a/utils/theme.js b/utils/theme.js index aab9ae5..c7eab33 100644 --- a/utils/theme.js +++ b/utils/theme.js @@ -94,20 +94,22 @@ const setTheme = (id) => { return theme } -const applyThemeToPage = (self) => { - const theme = getCurrentTheme() - self.setData({ - theme, - themeStyle: `${BASE_VARS}--primary:${theme.primary};--primary-light:${theme.primaryLight};--primary-bg:${theme.primaryBg};--primary-rgb:${theme.primaryRgb};` - }) - _setNavBarColor(theme.primary) -} - const getThemeStyle = (theme) => { const t = theme || getCurrentTheme() return `${BASE_VARS}--primary:${t.primary};--primary-light:${t.primaryLight};--primary-bg:${t.primaryBg};--primary-rgb:${t.primaryRgb};` } +const applyThemeToPage = (self) => { + const theme = getCurrentTheme() + const nextStyle = getThemeStyle(theme) + // Skip setData if nothing changed — avoid unnecessary re-renders on every onShow + if (self.data && self.data.themeStyle === nextStyle) { + return + } + self.setData({ theme, themeStyle: nextStyle }) + _setNavBarColor(theme.primary) +} + module.exports = { THEMES, DEFAULT_THEME_ID, diff --git a/utils/voice.js b/utils/voice.js new file mode 100644 index 0000000..fce9e64 --- /dev/null +++ b/utils/voice.js @@ -0,0 +1,94 @@ +/** + * Voice prompt utility for the timer. + * + * 4 fixed, polished prompts live in `cloudfunctions/tts`. We call that + * cloud function with a key, get back an audio URL (cached in cloud + * storage after first synthesis), and play it via createInnerAudioContext. + * + * The in-memory `_urlCache` avoids hitting the cloud function more than + * once per prompt per session. The audio context is a singleton so + * overlapping prompts interrupt each other cleanly. + * + * If the cloud function is unconfigured (no TTS credentials) and returns + * `success: false`, we fall back to a silent no-op — better than spamming + * the user with toast errors mid-plank. + */ + +const PROMPTS = { + halfway: '已完成一半啦,坚持就是胜利!', + last30: '最后30秒,保持呼吸,稳住姿势!', + last10: '最后10秒,再加把劲!', + complete: '太棒了!今天的目标已完成,继续加油!' +} + +let _ctx = null +const _urlCache = {} // { promptKey: audioUrl } + +const _getCtx = () => { + if (!_ctx) { + _ctx = wx.createInnerAudioContext() + // Play even when the device is in silent mode — we want the user to + // actually hear training cues during a session. + _ctx.obeyMuteSwitch = false + } + return _ctx +} + +const _fetchUrl = async (promptKey) => { + if (_urlCache[promptKey]) return _urlCache[promptKey] + try { + const res = await wx.cloud.callFunction({ + name: 'tts', + data: { promptKey }, + // Default WeChat cloud timeout is 3s, which is exactly the TTS + // first-call budget (synth + upload + tempURL). Bump to 30s for + // cold starts; cached calls return in <500ms. + config: { timeout: 30000 } + }) + if (res && res.result && res.result.success && res.result.audioUrl) { + _urlCache[promptKey] = res.result.audioUrl + return res.result.audioUrl + } + } catch (e) { + // cloud function not deployed or other error — silent fallback + } + return null +} + +/** + * Play a prompt. Returns a Promise that resolves when playback starts + * (or immediately if there's no audio available). + */ +const play = async (promptKey) => { + if (!PROMPTS[promptKey]) return + const url = await _fetchUrl(promptKey) + if (!url) return + const ctx = _getCtx() + try { + ctx.stop() + ctx.src = url + ctx.play() + } catch (e) { /* ignore playback errors */ } +} + +/** + * Stop any currently-playing voice prompt. Call this when the user + * pauses/stops/finishes training to avoid stray audio. + */ +const stop = () => { + if (_ctx) { + try { _ctx.stop() } catch (e) { /* ignore */ } + } +} + +/** + * Tear down the audio context. Call on page unload. + */ +const destroy = () => { + if (_ctx) { + try { _ctx.destroy() } catch (e) { /* ignore */ } + _ctx = null + } +} + +module.exports = { play, stop, destroy, PROMPTS }