feat: v1.5 — plan editor, voice prompts, UI polish

Major features:
- Training plan editor: edit preset days/duration per plan (in-place
  override via custom_plans storage; preset ids preserved so existing
  records stay valid)
- Voice prompts at 4 fixed points during training (halfway / 30s /
  10s / done) via Tencent Cloud TTS (cloudfunctions/tts + utils/voice.js)
- Free-training button: switched from outline (transparent) to ghost
  variant (theme-tinted) for visual weight

Bug fixes:
- 4 functional pages: SVG data URIs failed to render in WeChat because
  '\#' in colors was parsed as a data-URI fragment delimiter. encode the
  whole SVG via encodeURIComponent in utils/icons.js build().
- Old records (saved before id field was added) could not be deleted
  (data-id was empty, triggered defensive guard). Backfill stable
  legacy-<month>-<index>-<duration> ids in getRecords() and persist.
- settings.js plan-row editor button event was bubbling up to the row's
  bindtap (which also fired onSelectPlan). Wrapped in catch:tap.

UI:
- Settings page: 3 hardcoded plans replaced with dynamic buildPlansList
  that overlays custom_plans on top of presets
- Plan editor: bottom-sheet modal in settings page (regular view, not
  ui-modal — WeChat custom component root element drops position:fixed
  in this runtime)
- Free-training button: rounded pill style, full-width, 24rpx gap from
  primary action; bottom sheet uses max-height: 88vh + internal scroll
- Version bumped to v1.5, last updated 2026-06-10

Removed:
- Daily reminder section (dailyReminder / reminderTime) — replaced by
  the 4 voice prompts which cover the same user need without requiring
  long-term scheduling that WeChat mini-programs can't actually do

Misc:
- utils/plan.js refactored to formula-driven: presets declare
  totalDays/startTarget/increment/cycleDays, days[] is generated. Same
  formula applies to custom plans.
- Timer _remind() guards each prompt on minimum duration so short
  free-mode sessions don't fire 'last30' at the start
- Cloud storage cloud_plans field added to data push payload; restored
  on first install via _restoreFromCloud
- .gitignore added for local AI tool caches (.reasonix/, reasonix.toml,
  .codegraph/daemon.pid)
This commit is contained in:
2026-06-10 17:23:13 +08:00
parent 90e0e64156
commit 1d55df72b3
54 changed files with 2710 additions and 934 deletions
-6
View File
@@ -1,6 +0,0 @@
{
"pid": 75366,
"version": "0.9.7",
"socketPath": "/Users/liubleed/Documents/wx_pbzc/.codegraph/daemon.sock",
"startedAt": 1780561314477
}
+4
View File
@@ -0,0 +1,4 @@
# Local tool caches / daemons — never project-level
.reasonix/
reasonix.toml
.codegraph/daemon.pid
+13 -14
View File
@@ -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)
}
+2
View File
@@ -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": "设置" }
]
},
+34 -99
View File
@@ -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 <image>.
* WeChat ignores CSS width/height on <image> 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; }
+1
View File
@@ -0,0 +1 @@
{}
+103
View File
@@ -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)
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "leaderboard",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"wx-server-sdk": "latest"
}
}
+97
View File
@@ -0,0 +1,97 @@
// Cloud function: synthesize the 4 fixed training-voice prompts.
//
// We do NOT cache on the server. Two reasons:
// 1. Caching via cloud storage requires self-constructing a fileID of
// the form `cloud://<full-env-id>/...`, but `getWXContext().ENV`
// only returns the simple env id. Mismatched → unopenable fileID.
// 2. Caching via the cloud database requires `cloud.database()` at
// top level, which on this WeChat cloud runtime crashes the SCF
// framework's `writeRuntimeFile` step (it can't toString() the
// error object the SDK throws).
//
// The client (`utils/voice.js`) caches audioUrls in memory per session,
// so each of the 4 prompts is synthesized at most once per app launch.
// Re-synthesizing on app restart is fine — 800万 chars/month free tier
// covers 4 × ~30 chars × thousands of restarts per month.
//
// Configuration (云开发控制台 → 云函数 → tts → 函数配置 → 环境变量):
// TTS_SECRET_ID — Tencent Cloud API key id
// TTS_SECRET_KEY — Tencent Cloud API key
// TTS_REGION — (optional) defaults to ap-guangzhou
//
// NOTE: env var names cannot start with SCF_ / QCLOUD_ / TENCENTCLOUD_ —
// those prefixes are reserved by Tencent Cloud SCF. Use the TTS_ prefix.
const cloud = require('wx-server-sdk')
cloud.init()
// Polished, fixed prompts.
const PROMPTS = {
halfway: '已完成一半啦,坚持就是胜利!',
last30: '最后30秒,保持呼吸,稳住姿势!',
last10: '最后10秒,再加把劲!',
complete: '太棒了!今天的目标已完成,继续加油!'
}
const CACHE_DIR = 'tts-cache'
const _synthesize = async (text, promptKey) => {
let TtsClient
try {
TtsClient = require('tencentcloud-sdk-nodejs').tts.v20190823.Client
} catch (e) {
throw new Error('tencentcloud-sdk-nodejs not installed. Run `npm i tencentcloud-sdk-nodejs` in this cloud function directory.')
}
const secretId = process.env.TTS_SECRET_ID
const secretKey = process.env.TTS_SECRET_KEY
if (!secretId || !secretKey) {
throw new Error('TTS_SECRET_ID / TTS_SECRET_KEY not configured.')
}
const client = new TtsClient({
credential: { secretId, secretKey },
region: process.env.TTS_REGION || 'ap-guangzhou'
})
const res = await client.TextToVoice({
Text: text,
SessionId: `${promptKey}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
VoiceType: 101001, // 智瑜,温柔女声
Codec: 'mp3',
SampleRate: 16000,
Speed: 0,
Volume: 5
})
return Buffer.from(res.Audio, 'base64')
}
const _upload = async (key, buffer) => {
const cloudPath = `${CACHE_DIR}/${key}.mp3`
// uploadFile returns the canonical fileID (e.g. "cloud://env.xxx/...").
// We must use THAT — not the relative cloudPath we passed in — when
// calling getTempFileURL. A self-constructed fileID is invalid.
const res = await cloud.uploadFile({ cloudPath, fileContent: buffer })
return res.fileID
}
exports.main = async (event) => {
const { promptKey } = event || {}
const text = PROMPTS[promptKey]
if (!text) {
return { success: false, error: `Unknown prompt key: ${promptKey}` }
}
try {
const buffer = await _synthesize(text, promptKey)
const newFileID = await _upload(promptKey, buffer)
const urlRes = await cloud.getTempFileURL({ fileList: [newFileID] })
const url = urlRes.fileList[0].tempFileURL
if (!url) {
return { success: false, error: 'getTempFileURL returned empty URL', text }
}
return { success: true, audioUrl: url, text }
} catch (e) {
return { success: false, error: e.message, text }
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "tts",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"wx-server-sdk": "latest",
"tencentcloud-sdk-nodejs": "^4.0.0"
}
}
+38 -23
View File
@@ -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'
}
}
})
@@ -21,10 +21,10 @@
</view>
<view class="heat-legend">
<text class="legend-label">少</text>
<view class="legend-block" style="background:#FFE0D0;"></view>
<view class="legend-block" style="background:#FFB088;"></view>
<view class="legend-block" style="background:#FF6B35;"></view>
<view class="legend-block" style="background:#E05520;"></view>
<view class="legend-block" style="background:{{primaryColor}}; opacity: 0.15;"></view>
<view class="legend-block" style="background:{{primaryColor}}; opacity: 0.35;"></view>
<view class="legend-block" style="background:{{primaryColor}}; opacity: 0.65;"></view>
<view class="legend-block" style="background:{{primaryColor}}; opacity: 0.95;"></view>
<text class="legend-label">多</text>
</view>
</view>
+16
View File
@@ -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')
}
}
})
+4
View File
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
+15
View File
@@ -0,0 +1,15 @@
<button
class="ui-btn ui-btn--{{variant}} ui-btn--{{size}} {{block ? 'ui-btn--block' : ''}} {{disabled ? 'is-disabled' : ''}}"
style="{{customStyle}}"
bindtap="onTap"
disabled="{{disabled}}"
hover-class="none"
>
<image
wx:if="{{iconSrc}}"
class="ui-btn__icon"
src="{{iconSrc}}"
mode="aspectFit"
></image>
<text class="ui-btn__text">{{text}}</text>
</button>
+92
View File
@@ -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;
}
+8
View File
@@ -0,0 +1,8 @@
Component({
options: { multipleSlots: true },
properties: {
variant: { type: String, value: '' }, // '' | 'soft' | 'in'
padded: { type: Boolean, value: true },
customStyle: { type: String, value: '' }
}
})
+4
View File
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
+3
View File
@@ -0,0 +1,3 @@
<view class="ui-card {{variant}} {{padded ? '' : 'ui-card--flush'}}" style="{{customStyle}}">
<slot></slot>
</view>
+34
View File
@@ -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); }
}
+17
View File
@@ -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 */ }
}
})
+4
View File
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
+7
View File
@@ -0,0 +1,7 @@
<view wx:if="{{visible}}" class="ui-modal ui-modal--{{position}}" catchtouchmove="onNoop">
<view class="ui-modal__overlay" bindtap="onClose"></view>
<view class="ui-modal__body" catchtap="onNoop" style="{{customStyle}}">
<view wx:if="{{showHandle && position === 'bottom'}}" class="ui-modal__handle"></view>
<slot></slot>
</view>
</view>
+61
View File
@@ -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; }
}
+53 -6
View File
@@ -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(
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="' + fill + '">' +
path + '</svg>'
)
// 24x24 Material-style paths
const PATH_HOME = '<path d="M12 3l9 8h-3v9h-5v-6h-2v6H6v-9H3l9-8z"/>'
const PATH_HOME_LINE = '<path d="M12 5.2L5.5 11H7v8h2v-6h6v6h2v-8h1.5L12 5.2M12 3l9 8h-3v9h-5v-6h-2v6H6v-9H3l9-8z" fill-rule="evenodd"/>'
const PATH_CALENDAR = '<path d="M19 4h-2V2h-2v2H9V2H7v2H5C3.9 4 3 4.9 3 6v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V10h14v10zm0-12H5V6h14v2z"/><path d="M7 12h5v5H7z"/>'
const PATH_CALENDAR_LINE = '<path d="M19 4h-2V2h-2v2H9V2H7v2H5C3.9 4 3 4.9 3 6v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V10h14v10zm0-12H5V6h14v2z" fill-rule="evenodd"/>'
const PATH_RANK = '<path d="M7.5 21H2V9h5.5v12zm7.25-14h-5.5v14h5.5V7zM22 11h-5.5v10H22V11z"/>'
const PATH_RANK_LINE = '<path d="M7.5 21H2V9h5.5v12zm7.25-14h-5.5v14h5.5V7zM22 11h-5.5v10H22V11z" fill-rule="evenodd"/>'
const PATH_SETTINGS = '<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/>'
const PATH_SETTINGS_LINE = '<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" fill-rule="evenodd"/>'
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] })
}
}
+12 -19
View File
@@ -4,12 +4,7 @@
data-index="0"
bindtap="switchTab"
>
<view class="tab-icon">
<view class="icon-home {{selected === 0 ? 'active' : ''}}">
<view class="home-roof"></view>
<view class="home-body"></view>
</view>
</view>
<view class="tab-icon" style="background-image: url({{selected === 0 ? svgIcons.homeFill : svgIcons.home}})"></view>
<text class="tab-text">训练</text>
</view>
@@ -18,13 +13,7 @@
data-index="1"
bindtap="switchTab"
>
<view class="tab-icon">
<view class="icon-records {{selected === 1 ? 'active' : ''}}">
<view class="bar bar1"></view>
<view class="bar bar2"></view>
<view class="bar bar3"></view>
</view>
</view>
<view class="tab-icon" style="background-image: url({{selected === 1 ? svgIcons.calendarFill : svgIcons.calendar}})"></view>
<text class="tab-text">记录</text>
</view>
@@ -33,12 +22,16 @@
data-index="2"
bindtap="switchTab"
>
<view class="tab-icon">
<view class="icon-settings {{selected === 2 ? 'active' : ''}}">
<view class="gear-outer"></view>
<view class="gear-center"></view>
</view>
</view>
<view class="tab-icon" style="background-image: url({{selected === 2 ? svgIcons.rankFill : svgIcons.rank}})"></view>
<text class="tab-text">排行</text>
</view>
<view
class="tab-item {{selected === 3 ? 'active' : ''}}"
data-index="3"
bindtap="switchTab"
>
<view class="tab-icon" style="background-image: url({{selected === 3 ? svgIcons.settingsFill : svgIcons.settings}})"></view>
<text class="tab-text">设置</text>
</view>
</view>
+20 -112
View File
@@ -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);
}
+26 -9
View File
@@ -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() {}
}
})
+5 -1
View File
@@ -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": "平板支撑训练"
}
+102 -82
View File
@@ -3,112 +3,132 @@
<!-- 顶部问候 -->
<view class="greeting-row">
<view class="greeting-text">
<text class="greeting-emoji">{{todayDone ? '💪' : '👋'}}</text>
<image class="greeting-icon" src="{{todayDone ? icons.successFill : icons.emojiFill}}" mode="aspectFit"></image>
<text class="greeting-title">{{todayDone ? '太棒了!' : '准备好了吗?'}}</text>
</view>
<text class="greeting-sub">{{todayDone ? '继续保持这个势头' : '今天的平板支撑等着你'}}</text>
</view>
<!-- 连续打卡卡片 -->
<view class="header-card card">
<view class="streak-section">
<text class="streak-icon">🔥</text>
<text class="streak-num">{{streakCount}}</text>
<text class="streak-label">连续打卡</text>
</view>
<view class="divider"></view>
<view class="plan-info">
<view class="plan-badge">
<text class="plan-badge-icon">📋</text>
<text class="plan-badge-text">{{planName}}</text>
<ui-card variant="in">
<view class="header-card">
<view class="streak-section">
<image class="streak-icon" src="{{icons.hotFill}}" mode="aspectFit"></image>
<text class="streak-num">{{streakCount}}</text>
<text class="streak-label">连续打卡</text>
</view>
<view class="progress-bar-wrap">
<view class="progress-bar">
<view class="progress-fill" style="width: {{planProgress}}%;">
<view class="progress-glow"></view>
</view>
<view class="divider"></view>
<view class="plan-info">
<view class="plan-badge">
<image class="plan-badge-icon" src="{{icons.formFill}}" mode="aspectFit"></image>
<text class="plan-badge-text">{{planName}}</text>
</view>
<view class="progress-bar-wrap">
<view class="progress-bar">
<view class="progress-fill" style="width: {{planProgress}}%;">
<view class="progress-glow"></view>
</view>
</view>
<text class="progress-label">第 {{planDay}} / {{planTotal}} 天</text>
</view>
<text class="progress-label">第 {{planDay}} / {{planTotal}} 天</text>
</view>
</view>
</view>
</ui-card>
<!-- 今日目标卡片 -->
<view class="target-section card">
<view class="target-header">
<text class="target-icon">🎯</text>
<text class="section-title">今日目标</text>
</view>
<view class="target-display">
<view class="target-ring">
<text class="target-time">{{todayTargetText}}</text>
<ui-card variant="in">
<view class="target-section">
<view class="target-header">
<image class="section-icon" src="{{icons.targetFill}}" mode="aspectFit"></image>
<text class="section-title">今日目标</text>
</view>
<text class="target-unit">平板支撑</text>
</view>
<view class="today-status">
<block wx:if="{{todayDone}}">
<view class="done-badge">
<text class="done-emoji">✨</text>
<text class="done-text">今日已完成 {{todayDuration}} 秒</text>
<view class="target-display">
<view class="target-ring">
<text class="target-time">{{todayTargetText}}</text>
</view>
</block>
<block wx:else>
<text class="pending-text">⏳ 还未开始训练</text>
</block>
<text class="target-unit">平板支撑</text>
</view>
<view class="today-status">
<block wx:if="{{todayDone}}">
<view class="done-badge">
<image class="done-icon" src="{{icons.successFill}}" mode="aspectFit"></image>
<text class="done-text">今日已完成 {{todayDuration}} 秒</text>
</view>
</block>
<block wx:else>
<image class="pending-icon" src="{{icons.time}}" mode="aspectFit"></image>
<text class="pending-text">还未开始训练</text>
</block>
</view>
</view>
</view>
</ui-card>
<!-- 操作按钮区 -->
<view class="action-buttons">
<button class="start-btn btn-primary" bindtap="onStartTrain">
<text class="btn-icon">{{todayDone ? '🔄' : '▶'}}</text>
<text>{{todayDone ? '再次训练' : '开始训练'}}</text>
</button>
<ui-btn
variant="primary"
size="xl"
block
text="{{todayDone ? '再次训练' : '开始训练'}}"
icon-src="{{icons.playFill}}"
bindtap="onStartTrain"
></ui-btn>
<button class="free-btn" bindtap="onFreeTrain">
<text class="free-icon">⏱</text>
<text>自由训练 · 自定义时长</text>
</button>
<ui-btn
variant="ghost"
size="md"
block
text="自由训练 · 自定义时长"
icon-src="{{icons.timeFill}}"
bindtap="onFreeTrain"
></ui-btn>
</view>
<!-- 小提示 -->
<view class="tip-card">
<text class="tip-icon">💡</text>
<text class="tip-text">保持身体成一条直线,核心收紧,均匀呼吸</text>
</view>
<ui-card variant="soft" padded="{{false}}">
<view class="tip-card">
<image class="tip-icon" src="{{icons.lightFill}}" mode="aspectFit"></image>
<text class="tip-text">保持身体成一条直线,核心收紧,均匀呼吸</text>
</view>
</ui-card>
<!-- 时间选择弹窗 -->
<view class="modal-overlay modal-overlay-bottom" wx:if="{{showPicker}}" bindtap="onClosePicker">
<view class="picker-modal" catchtap="noop" catchtouchmove="noop">
<view class="picker-handle"></view>
<text class="picker-title">⏱ 选择训练时长</text>
<view class="preset-grid">
<view
class="preset-item {{customDuration === item ? 'selected' : ''}}"
wx:for="{{presets}}"
wx:key="*this"
data-value="{{item}}"
bindtap="onSelectPreset"
>
<text class="preset-num">{{item}}</text>
<text class="preset-unit">秒</text>
</view>
<ui-modal
visible="{{showPicker}}"
position="bottom"
bind:close="onClosePicker"
>
<text class="picker-title">选择训练时长</text>
<view class="preset-grid">
<view
class="preset-item {{customDuration === item ? 'selected' : ''}}"
wx:for="{{presets}}"
wx:key="*this"
data-value="{{item}}"
bindtap="onSelectPreset"
>
<text class="preset-num">{{item}}</text>
<text class="preset-unit">秒</text>
</view>
<view class="custom-input-row">
<text class="custom-label">自定义:</text>
<input
class="custom-input"
type="number"
placeholder="输入秒数"
value="{{customInput}}"
bindinput="onCustomInput"
/>
<text class="custom-label">秒</text>
</view>
<button class="picker-confirm btn-primary" bindtap="onConfirmFree">
开始自由训练
</button>
<text class="picker-cancel" bindtap="onClosePicker">取消</text>
</view>
</view>
<view class="custom-input-row">
<text class="custom-label">自定义:</text>
<input
class="custom-input"
type="number"
placeholder="输入秒数"
value="{{customInput}}"
bindinput="onCustomInput"
/>
<text class="custom-label">秒</text>
</view>
<ui-btn
variant="primary"
size="lg"
block
text="开始自由训练"
bindtap="onConfirmFree"
></ui-btn>
<text class="picker-cancel" bindtap="onClosePicker">取消</text>
</ui-modal>
</view>
+38 -95
View File
@@ -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;
}
+113
View File
@@ -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 })
}
}
})
+6
View File
@@ -0,0 +1,6 @@
{
"navigationBarTitleText": "排行榜",
"enablePullDownRefresh": true,
"backgroundColor": "#F5F5F5",
"usingComponents": {}
}
+57
View File
@@ -0,0 +1,57 @@
<view class="container" style="{{themeStyle}}">
<!-- Period tabs -->
<view class="period-tabs">
<view
wx:for="{{periods}}"
wx:key="key"
class="period-tab {{activePeriod === item.key ? 'active' : ''}}"
data-period="{{item.key}}"
bindtap="switchPeriod"
>{{item.label}}</view>
</view>
<!-- Loading -->
<view class="status-wrap" wx:if="{{loading}}">
<text class="status-text">加载中...</text>
</view>
<!-- Empty -->
<view class="status-wrap" wx:elif="{{empty}}">
<image class="empty-icon" src="{{icons.peopleFill}}" mode="aspectFit"></image>
<text class="empty-text">暂无排行数据</text>
<text class="empty-sub">快去训练吧!</text>
</view>
<!-- Rank list -->
<view class="rank-list" wx:else>
<view
wx:for="{{rankedList}}"
wx:key="openid"
class="rank-item {{item.openid === myEntry.openid ? 'is-me' : ''}}"
>
<view class="rank-num {{item.rank === 1 ? 'gold' : item.rank === 2 ? 'silver' : item.rank === 3 ? 'bronze' : ''}}">
<text wx:if="{{item.rank === 1}}">🥇</text>
<text wx:elif="{{item.rank === 2}}">🥈</text>
<text wx:elif="{{item.rank === 3}}">🥉</text>
<text wx:else>{{item.rank}}</text>
</view>
<image class="rank-avatar" src="{{icons.peopleFill}}" mode="aspectFit"></image>
<view class="rank-info">
<text class="rank-name">{{item.name}}</text>
<text class="rank-sessions">{{item.sessions}}次</text>
</view>
<text class="rank-dur">{{item.durationText}}</text>
</view>
</view>
<!-- My entry pinned at bottom (if not already in list) -->
<view class="my-bar" wx:if="{{myEntry && myEntry.rank > rankedList.length}}">
<view class="rank-num">{{myEntry.rank}}</view>
<image class="rank-avatar" src="{{icons.peopleFill}}" mode="aspectFit"></image>
<view class="rank-info">
<text class="rank-name">我 ({{myEntry.name}})</text>
<text class="rank-sessions">{{myEntry.sessions}}次</text>
</view>
<text class="rank-dur">{{myEntry.durationText}}</text>
</view>
</view>
+176
View File
@@ -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;
}
+48 -8
View File
@@ -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: '确定要删除这条训练记录吗?',
+3 -1
View File
@@ -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": "训练记录"
}
+92 -72
View File
@@ -1,92 +1,112 @@
<view class="container" style="{{themeStyle}}">
<!-- 统计卡片 -->
<view class="stats card">
<view class="stat-item">
<text class="stat-emoji">📊</text>
<text class="stat-num">{{totalSessions}}次</text>
<text class="stat-label">训练次数</text>
<ui-card variant="in">
<view class="stats">
<view class="stat-item">
<image class="stat-icon" src="{{icons.rankFill}}" mode="aspectFit"></image>
<text class="stat-num">{{totalSessions}}次</text>
<text class="stat-label">训练次数</text>
</view>
<view class="stat-item">
<image class="stat-icon" src="{{icons.timeFill}}" mode="aspectFit"></image>
<text class="stat-num">{{totalDurationText}}</text>
<text class="stat-label">累计时长</text>
</view>
<view class="stat-item">
<image class="stat-icon" src="{{icons.crownFill}}" mode="aspectFit"></image>
<text class="stat-num">{{maxDurationText}}</text>
<text class="stat-label">最长记录</text>
</view>
</view>
<view class="stat-item">
<text class="stat-emoji">⏱</text>
<text class="stat-num">{{totalDurationText}}</text>
<text class="stat-label">累计时长</text>
</view>
<view class="stat-item">
<text class="stat-emoji">🏆</text>
<text class="stat-num">{{maxDurationText}}</text>
<text class="stat-label">最长记录</text>
</view>
</view>
</ui-card>
<!-- 日历热力图 -->
<view class="calendar-section card">
<view class="month-picker">
<view class="month-arrow" bindtap="onPrevMonth"></view>
<view class="month-title-wrap">
<text class="month-emoji">🗓</text>
<text class="month-title">{{currentYear}}年 {{currentMonth}}月</text>
<ui-card variant="in">
<view class="calendar-section">
<view class="month-picker">
<view class="month-arrow" bindtap="onPrevMonth">
<image class="month-arrow-img" src="{{icons.back}}" mode="aspectFit"></image>
</view>
<view class="month-title-wrap">
<image class="month-icon" src="{{icons.calendarFill}}" mode="aspectFit"></image>
<text class="month-title">{{currentYear}}年 {{currentMonth}}月</text>
</view>
<view class="month-arrow" bindtap="onNextMonth">
<image class="month-arrow-img" src="{{icons.right}}" mode="aspectFit"></image>
</view>
</view>
<view class="month-arrow" bindtap="onNextMonth"></view>
<calendar-heatmap
year="{{currentYear}}"
month="{{currentMonth}}"
records="{{monthRecords}}"
primaryColor="{{theme.primary}}"
binddaytap="onDayTap"
></calendar-heatmap>
</view>
<calendar-heatmap
year="{{currentYear}}"
month="{{currentMonth}}"
records="{{monthRecords}}"
binddaytap="onDayTap"
></calendar-heatmap>
</view>
</ui-card>
<!-- 历史记录 -->
<view class="history-section card">
<view class="history-header">
<text class="section-title">📋 最近记录</text>
<text class="history-hint" wx:if="{{historyList.length > 0}}">长按可删除</text>
</view>
<view wx:if="{{historyList.length === 0}}" class="empty-state">
<text class="empty-emoji">🏃</text>
<text class="empty-text">还没有训练记录</text>
<text class="empty-sub">开始你的第一次平板支撑吧</text>
</view>
<view class="history-list">
<view class="history-item" wx:for="{{historyList}}" wx:key="id" data-id="{{item.id}}" bindlongpress="onDeleteRecord">
<text class="history-icon">
{{item.duration >= 120 ? '🔥' : item.duration >= 60 ? '💪' : '✅'}}
</text>
<view class="history-left">
<text class="history-date">{{item.date}}</text>
<text class="history-plan">第 {{item.day}} 天</text>
<ui-card variant="in">
<view class="history-section">
<view class="history-header">
<view class="history-title-wrap">
<image class="history-title-icon" src="{{icons.formFill}}" mode="aspectFit"></image>
<text class="section-title">最近记录</text>
</view>
<view class="history-right">
<text class="history-duration">{{item.duration}}s</text>
<text class="history-hint" wx:if="{{historyList.length > 0}}">长按可删除</text>
</view>
<view wx:if="{{historyList.length === 0}}" class="empty-state">
<image class="empty-icon" src="{{icons.peopleFill}}" mode="aspectFit"></image>
<text class="empty-text">还没有训练记录</text>
<text class="empty-sub">开始你的第一次平板支撑吧</text>
</view>
<view class="history-list">
<view class="history-item" wx:for="{{historyList}}" wx:key="id" data-id="{{item.id}}" bindlongpress="onLongPressDelete">
<image class="history-item-icon"
src="{{item.duration >= 120 ? icons.hotFill : item.duration >= 60 ? icons.likeFill : icons.roundCheckFill}}"
mode="aspectFit"
></image>
<view class="history-left">
<text class="history-date">{{item.date}}</text>
<text class="history-plan">{{item.day === 0 ? '自由训练' : '第 ' + item.day + ' 天'}}</text>
</view>
<view class="history-right">
<text class="history-duration">{{item.duration}}s</text>
<view class="history-delete-btn" catch:tap="onDeleteRecord" data-id="{{item.id}}">
<image class="history-delete-icon" src="{{icons.deleteActive}}" mode="aspectFit"></image>
<text class="history-delete-text">删除</text>
</view>
</view>
</view>
</view>
</view>
</view>
</ui-card>
<!-- 日期详情弹窗 -->
<view class="modal-overlay modal-overlay-center" wx:if="{{showDayDetail}}" bindtap="onCloseDayDetail">
<view class="day-detail-modal" catchtap="noop">
<view class="detail-date-row">
<text class="detail-emoji">📅</text>
<text class="detail-date">{{dayDetail.date}}</text>
<ui-modal visible="{{showDayDetail}}" position="center" bind:close="onCloseDayDetail">
<view class="detail-date-row">
<image class="detail-icon" src="{{icons.calendarFill}}" mode="aspectFit"></image>
<text class="detail-date">{{dayDetail.date}}</text>
</view>
<view class="detail-grid">
<view class="detail-item">
<image class="detail-item-icon" src="{{icons.rankFill}}" mode="aspectFit"></image>
<text class="detail-num">{{dayDetail.sessions}}次</text>
<text class="detail-label">训练次数</text>
</view>
<view class="detail-grid">
<view class="detail-item">
<text class="detail-num">{{dayDetail.sessions}}</text>
<text class="detail-label">训练次数</text>
</view>
<view class="detail-item">
<text class="detail-num">{{dayDetail.durationText}}</text>
<text class="detail-label">总时长</text>
</view>
<view class="detail-item">
<text class="detail-num">{{dayDetail.calories}}</text>
<text class="detail-label">约消耗 (千卡)</text>
</view>
<view class="detail-item">
<image class="detail-item-icon" src="{{icons.timeFill}}" mode="aspectFit"></image>
<text class="detail-num">{{dayDetail.durationText}}</text>
<text class="detail-label">总时长</text>
</view>
<view class="detail-close" bindtap="onCloseDayDetail">
<text>关闭</text>
<view class="detail-item">
<image class="detail-item-icon" src="{{icons.hotFill}}" mode="aspectFit"></image>
<text class="detail-num">{{dayDetail.calories}}</text>
<text class="detail-label">约消耗 (千卡)</text>
</view>
</view>
</view>
<view class="detail-close" bindtap="onCloseDayDetail">
<text>关闭</text>
</view>
</ui-modal>
</view>
+89 -39
View File
@@ -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;
}
+262 -39
View File
@@ -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 })
}
})
}
+4 -1
View File
@@ -1,4 +1,7 @@
{
"usingComponents": {},
"usingComponents": {
"ui-card": "/components/ui-card/ui-card",
"ui-btn": "/components/ui-btn/ui-btn"
},
"navigationBarTitleText": "设置"
}
+127 -47
View File
@@ -1,8 +1,8 @@
<view class="container" style="{{themeStyle}}">
<!-- 配色方案 -->
<view class="card">
<ui-card variant="in">
<view class="section-header">
<text class="section-emoji">🎨</text>
<image class="section-icon" src="{{icons.skinFill}}" mode="aspectFit"></image>
<text class="section-title">配色方案</text>
</view>
<view class="theme-list">
@@ -15,15 +15,15 @@
>
<view class="theme-color-dot" style="background: {{item.primary}};"></view>
<text class="theme-name">{{item.name}}</text>
<text class="plan-check" wx:if="{{currentThemeId === item.id}}">✓</text>
<image class="theme-check-img" wx:if="{{currentThemeId === item.id}}" src="{{icons.check}}" mode="aspectFit"></image>
</view>
</view>
</view>
</ui-card>
<!-- 训练计划选择 -->
<view class="card">
<ui-card variant="in">
<view class="section-header">
<text class="section-emoji">📋</text>
<image class="section-icon" src="{{icons.formFill}}" mode="aspectFit"></image>
<text class="section-title">训练计划</text>
</view>
<view class="plan-list">
@@ -41,82 +41,68 @@
<text class="plan-item-name">{{item.name}}</text>
<text class="plan-item-desc">{{item.desc}}</text>
</view>
<text class="plan-check" wx:if="{{currentPlanId === item.id}}">✓</text>
<view class="plan-actions">
<view class="plan-edit-btn" catch:tap="onTapEditPlan" data-id="{{item.id}}">
<image class="plan-edit-img" src="{{icons.edit}}" mode="aspectFit"></image>
</view>
<image class="plan-check-img" wx:if="{{currentPlanId === item.id}}" src="{{icons.check}}" mode="aspectFit"></image>
</view>
</view>
</view>
</view>
<!-- 提醒设置 -->
<view class="card">
<view class="section-header">
<text class="section-emoji">🔔</text>
<text class="section-title">提醒</text>
</view>
<view class="setting-row">
<view class="setting-left">
<text class="setting-icon">📅</text>
<text class="setting-label">每日提醒</text>
</view>
<switch checked="{{dailyReminder}}" bindchange="onToggleReminder" color="{{theme.primary}}"/>
</view>
<view class="setting-row" wx:if="{{dailyReminder}}">
<view class="setting-left">
<text class="setting-icon">🕐</text>
<text class="setting-label">提醒时间</text>
</view>
<picker mode="time" value="{{reminderTime}}" bindchange="onReminderTimeChange">
<text class="setting-value">{{reminderTime}}</text>
</picker>
</view>
</view>
</ui-card>
<!-- 训练偏好 -->
<view class="card">
<ui-card variant="in">
<view class="section-header">
<text class="section-emoji"></text>
<image class="section-icon" src="{{icons.settingsFill}}" mode="aspectFit"></image>
<text class="section-title">训练偏好</text>
</view>
<view class="setting-row">
<view class="setting-left">
<text class="setting-icon">🔊</text>
<image class="setting-icon" src="{{icons.voiceFill}}" mode="aspectFit"></image>
<text class="setting-label">语音引导</text>
</view>
<switch checked="{{voiceGuide}}" bindchange="onToggleVoice" color="{{theme.primary}}"/>
</view>
<view class="setting-row">
<view class="setting-left">
<text class="setting-icon">📳</text>
<image class="setting-icon" src="{{icons.notificationFill}}" mode="aspectFit"></image>
<text class="setting-label">振动反馈</text>
</view>
<switch checked="{{vibrate}}" bindchange="onToggleVibrate" color="{{theme.primary}}"/>
</view>
</view>
</ui-card>
<!-- 数据管理 -->
<view class="card">
<ui-card variant="in">
<view class="section-header">
<text class="section-emoji">🗑</text>
<image class="section-icon" src="{{icons.deleteActive}}" mode="aspectFit"></image>
<text class="section-title">数据管理</text>
</view>
<button class="btn-danger" bindtap="onClearData">
<text>清除所有训练记录</text>
</button>
</view>
<ui-btn
variant="danger"
size="md"
block
text="清除所有训练记录"
icon-src="{{icons.delete}}"
bindtap="onClearData"
></ui-btn>
</ui-card>
<!-- 关于 -->
<view class="card about-card">
<ui-card variant="in">
<view class="section-header">
<text class="section-emoji"></text>
<image class="section-icon" src="{{icons.infoFill}}" mode="aspectFit"></image>
<text class="section-title">关于</text>
</view>
<view class="about-list">
<view class="about-row">
<text class="about-label">版本</text>
<text class="about-value">v1.2.0</text>
<text class="about-value">v1.5</text>
</view>
<view class="about-row">
<text class="about-label">更新日期</text>
<text class="about-value">2026-06-04</text>
<text class="about-value">2026-06-10</text>
</view>
<view class="about-row" bindtap="onCopyWechat">
<text class="about-label">开发者</text>
@@ -126,5 +112,99 @@
<view class="about-footer">
<text class="about-copy">Made with ❤️ for better fitness</text>
</view>
</ui-card>
<!-- 训练计划编辑器:用普通 view 不用 ui-modal,
因为 WeChat 自定义组件根元素上 position: fixed 会被吃,导致 body 被推到容器末尾。
普通 view 的 position: fixed 在小程序里 100% 生效。 -->
<view wx:if="{{showPlanEditor}}" class="plan-editor-mask" catchtouchmove="onNoop">
<view class="plan-editor-mask__bg" bindtap="onCloseEditor"></view>
<view class="plan-editor-sheet" catchtap="onNoop">
<view class="plan-editor-handle"></view>
<view class="editor-title">编辑训练计划</view>
<view class="editor-field">
<text class="editor-label">计划名称</text>
<input
class="editor-input"
type="text"
value="{{editingDraft.name}}"
bindinput="onEditName"
placeholder="例如:初级"
maxlength="20"
/>
</view>
<view class="editor-field">
<text class="editor-label">总天数</text>
<input
class="editor-input"
type="number"
value="{{editingDraft.totalDays}}"
bindinput="onEditTotalDays"
placeholder="1-365"
/>
</view>
<view class="editor-row">
<view class="editor-field-half">
<text class="editor-label">起始时长(秒)</text>
<input
class="editor-input"
type="number"
value="{{editingDraft.startTarget}}"
bindinput="onEditStartTarget"
placeholder="5-3600"
/>
</view>
<view class="editor-field-half">
<text class="editor-label">每次增加(秒)</text>
<input
class="editor-input"
type="number"
value="{{editingDraft.increment}}"
bindinput="onEditIncrement"
placeholder="0-300"
/>
</view>
</view>
<view class="editor-field">
<text class="editor-label">每几天增加</text>
<input
class="editor-input"
type="number"
value="{{editingDraft.cycleDays}}"
bindinput="onEditCycleDays"
placeholder="1-30"
/>
</view>
<view class="editor-preview">
<view class="editor-preview-head">
<text class="editor-preview-title">预览</text>
<text class="editor-preview-meta">共 {{editorPreview.length}} 天</text>
</view>
<view class="editor-preview-list">
<view class="editor-preview-row" wx:for="{{editorPreview}}" wx:key="day">
<text class="editor-preview-day">第 {{item.day}} 天</text>
<text class="editor-preview-target">{{item.target}} 秒</text>
</view>
<view class="editor-preview-empty" wx:if="{{editorPreview.length === 0}}">
<text>请填写总天数</text>
</view>
</view>
</view>
<view class="editor-actions">
<view class="editor-action-reset" wx:if="{{editingIsCustom}}" bindtap="onResetPlan">
<text>恢复默认</text>
</view>
<view class="editor-action-save" bindtap="onSavePlan">
<text>保存</text>
</view>
</view>
<text class="editor-cancel" bindtap="onCloseEditor">取消</text>
</view>
</view>
</view>
+255 -33
View File
@@ -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;
}
+96 -8
View File
@@ -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)
}
})
+2 -1
View File
@@ -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": "训练中"
}
+33 -27
View File
@@ -30,7 +30,7 @@
</view>
<view class="overtime-badge" wx:if="{{isCompleted && overtime > 0}}">
<text class="overtime-icon">🔥</text>
<image class="overtime-icon" src="{{icons.hotFill}}" mode="aspectFit"></image>
<text>+{{overtime}}s</text>
</view>
</view>
@@ -38,58 +38,64 @@
<!-- 状态提示 -->
<view class="hint-section">
<view class="hint-row" wx:if="{{status === 'idle'}}">
<text class="hint-emoji">🧘</text>
<image class="hint-icon" src="{{icons.targetFill}}" mode="aspectFit"></image>
<text class="hint-text">
<block wx:if="{{isFreeMode}}">自由训练 · 目标 {{duration}} 秒</block>
<block wx:else>今日目标 {{duration}} 秒 · 第 {{planDay}} 天</block>
</text>
</view>
<view class="hint-row" wx:elif="{{status === 'running'}}">
<text class="hint-emoji running-pulse">💪</text>
<image class="hint-icon running-pulse" src="{{icons.likeFill}}" mode="aspectFit"></image>
<text class="hint-text running">保持姿势,核心收紧!</text>
</view>
<view class="hint-row" wx:elif="{{status === 'paused'}}">
<text class="hint-emoji"></text>
<image class="hint-icon" src="{{icons.notificationForbidFill}}" mode="aspectFit"></image>
<text class="hint-text paused">已暂停</text>
</view>
<view class="hint-row completed-hint" wx:elif="{{status === 'completed'}}">
<text class="hint-emoji completed-bounce">🎉</text>
<image class="hint-icon completed-bounce" src="{{icons.roundCheckFill}}" mode="aspectFit"></image>
<text class="hint-text completed">目标完成! 继续坚持!</text>
</view>
</view>
<!-- 控制按钮 -->
<view class="controls">
<button
<ui-btn
wx:if="{{!isRunning && !isPaused}}"
class="ctrl-btn start-btn"
variant="primary"
size="xl"
block
text="开始"
icon-src="{{icons.playFill}}"
bindtap="onStart"
>
<text class="ctrl-icon">▶</text>
<text>开始</text>
</button>
></ui-btn>
<view class="ctrl-row" wx:if="{{isRunning || isPaused}}">
<button
<ui-btn
wx:if="{{!isPaused}}"
class="ctrl-btn pause-btn"
variant="ghost"
size="xl"
text="暂停"
icon-src="{{icons.notificationForbidFill}}"
custom-style="margin-right: 28rpx;"
bindtap="onPause"
>
<text class="ctrl-icon">⏸</text>
<text>暂停</text>
</button>
<button
></ui-btn>
<ui-btn
wx:if="{{isPaused}}"
class="ctrl-btn resume-btn"
variant="primary"
size="xl"
text="继续"
icon-src="{{icons.playFill}}"
custom-style="margin-right: 28rpx;"
bindtap="onStart"
>
<text class="ctrl-icon">▶</text>
<text>继续</text>
</button>
<button class="ctrl-btn stop-btn" bindtap="onStop">
<text class="ctrl-icon">⏹</text>
<text>结束</text>
</button>
></ui-btn>
<ui-btn
variant="outline"
size="xl"
text="结束"
icon-src="{{icons.stop}}"
bindtap="onStop"
></ui-btn>
</view>
</view>
</view>
+20 -124
View File
@@ -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;
}
+2 -1
View File
@@ -56,5 +56,6 @@
"tabIndent": "insertSpaces",
"tabSize": 2
},
"simulatorPluginLibVersion": {}
"simulatorPluginLibVersion": {},
"cloudfunctionTemplateRoot": "cloudfunctionTemplate/"
}
+50 -21
View File
@@ -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
}
+136
View File
@@ -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:
* <name> = outlined / inactive (neutral gray)
* <name>Fill = filled / active (theme color)
*/
const PATHS = {
// Navigation & UI
home: '<path d="M12 5.2L5.5 11H7v8h2v-6h6v6h2v-8h1.5L12 5.2M12 3l9 8h-3v9h-5v-6h-2v6H6v-9H3l9-8z" fill-rule="evenodd"/>',
homeFill: '<path d="M12 3l9 8h-3v9h-5v-6h-2v6H6v-9H3l9-8z"/>',
calendar: '<path d="M19 4h-2V2h-2v2H9V2H7v2H5C3.9 4 3 4.9 3 6v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V10h14v10zm0-12H5V6h14v2z" fill-rule="evenodd"/>',
calendarFill: '<path d="M19 4h-2V2h-2v2H9V2H7v2H5C3.9 4 3 4.9 3 6v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V10h14v10zm0-12H5V6h14v2zM7 12h5v5H7z"/>',
settings: '<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" fill-rule="evenodd"/>',
settingsFill: '<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/>',
back: '<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>',
right: '<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>',
close: '<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>',
check: '<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>',
// Status & feedback
hot: '<path d="M13.5.67s.74 2.65.74 4.8c0 2.06-1.35 3.73-3.41 3.73-2.07 0-3.63-1.67-3.63-3.73l.03-.36C5.21 7.51 4 10.62 4 14c0 4.42 3.58 8 8 8s8-3.58 8-8C20 8.61 17.41 3.8 13.5.67zM11.71 19c-1.78 0-3.22-1.4-3.22-3.14 0-1.62 1.05-2.76 2.81-3.12 1.77-.36 3.6-1.21 4.62-2.58.39 1.29.59 2.65.59 4.04 0 2.65-2.15 4.8-4.8 4.8z" fill-rule="evenodd"/>',
hotFill: '<path d="M13.5.67s.74 2.65.74 4.8c0 2.06-1.35 3.73-3.41 3.73-2.07 0-3.63-1.67-3.63-3.73l.03-.36C5.21 7.51 4 10.62 4 14c0 4.42 3.58 8 8 8s8-3.58 8-8C20 8.61 17.41 3.8 13.5.67zM11.71 19c-1.78 0-3.22-1.4-3.22-3.14 0-1.62 1.05-2.76 2.81-3.12 1.77-.36 3.6-1.21 4.62-2.58.39 1.29.59 2.65.59 4.04 0 2.65-2.15 4.8-4.8 4.8z"/>',
like: '<path d="M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z" fill-rule="evenodd"/>',
likeFill: '<path d="M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"/>',
// Training specific
play: '<path d="M8 5v14l11-7z" fill-rule="evenodd"/>',
playFill: '<path d="M8 5v14l11-7z"/>',
stop: '<path d="M6 6h12v12H6z" fill-rule="evenodd"/>',
stopFill: '<path d="M6 6h12v12H6z"/>',
pause: '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" fill-rule="evenodd"/>',
pauseFill: '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>',
time: '<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z" fill-rule="evenodd"/>',
timeFill: '<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/>',
// Markers & targets
target: '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z" fill-rule="evenodd"/><circle cx="12" cy="12" r="5" fill-rule="evenodd"/><circle cx="12" cy="12" r="2"/>',
targetFill: '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm0-14c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm0 10c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>',
mark: '<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 010-5 2.5 2.5 0 010 5z" fill-rule="evenodd"/>',
markFill: '<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 010-5 2.5 2.5 0 010 5z"/>',
// Stats & charts
rank: '<path d="M3.5 18.49l6-6.01 4 4L22 6.92l-1.41-1.41-7.09 7.97-4-4L2 16.99z" fill-rule="evenodd"/>',
rankFill: '<path d="M3.5 18.49l6-6.01 4 4L22 6.92l-1.41-1.41-7.09 7.97-4-4L2 16.99z"/>',
crown: '<path d="M5 16L3 5l5.5 5L12 4l3.5 6L21 5l-2 11H5zm0 2h14v2H5z" fill-rule="evenodd"/>',
crownFill: '<path d="M5 16L3 5l5.5 5L12 4l3.5 6L21 5l-2 11H5zm0 2h14v2H5z"/>',
form: '<path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z" fill-rule="evenodd"/>',
formFill: '<path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/>',
// Notification
notification: '<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" fill-rule="evenodd"/>',
notificationFill: '<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>',
notificationForbid: '<path d="M20 18.69L7.84 6.14 5.27 3.49 4 4.76l2.8 2.8v.01c-.52.99-.8 2.16-.8 3.43v5l-2 2v1h13.69l2 2L21 19.72l-1-1.03zM12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6.69V11c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68c-.15.03-.29.07-.43.11-.07.02-.14.04-.2.06-.15.05-.29.1-.43.16-.06.02-.12.05-.18.08-.14.06-.28.13-.41.2-.05.03-.1.05-.15.08L18 14.31z" fill-rule="evenodd"/>',
notificationForbidFill: '<path d="M20 18.69L7.84 6.14 5.27 3.49 4 4.76l2.8 2.8v.01c-.52.99-.8 2.16-.8 3.43v5l-2 2v1h13.69l2 2L21 19.72l-1-1.03zM12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6.69V11c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68c-.15.03-.29.07-.43.11-.07.02-.14.04-.2.06-.15.05-.29.1-.43.16-.06.02-.12.05-.18.08-.14.06-.28.13-.41.2-.05.03-.1.05-.15.08L18 14.31z"/>',
// People & users
people: '<path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z" fill-rule="evenodd"/>',
peopleFill: '<path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/>',
// Actions
delete: '<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" fill-rule="evenodd"/>',
deleteFill: '<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>',
info: '<path d="M11 17h2v-6h-2v6zm1-15C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zM11 9h2V7h-2v2z" fill-rule="evenodd"/>',
infoFill: '<path d="M11 17h2v-6h-2v6zm1-15C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zM11 9h2V7h-2v2z"/>',
// Theme & preferences
skin: '<path d="M12 2C6.49 2 2 6.49 2 12s4.49 10 10 10c1.38 0 2.5-1.12 2.5-2.5 0-.61-.23-1.2-.64-1.67-.08-.1-.13-.21-.13-.33 0-.28.22-.5.5-.5H16c3.31 0 6-2.69 6-6 0-4.96-4.49-9-10-9zm5.5 11c-.83 0-1.5-.67-1.5-1.5S16.67 10 17.5 10s1.5.67 1.5 1.5S18.33 13 17.5 13zm-3-4c-.83 0-1.5-.67-1.5-1.5S13.67 7 14.5 7s1.5.67 1.5 1.5S15.33 9 14.5 9zM9 12c-.83 0-1.5-.67-1.5-1.5S8.17 9 9 9s1.5.67 1.5 1.5S9.83 12 9 12z" fill-rule="evenodd"/>',
skinFill: '<path d="M12 2C6.49 2 2 6.49 2 12s4.49 10 10 10c1.38 0 2.5-1.12 2.5-2.5 0-.61-.23-1.2-.64-1.67-.08-.1-.13-.21-.13-.33 0-.28.22-.5.5-.5H16c3.31 0 6-2.69 6-6 0-4.96-4.49-9-10-9zm5.5 11c-.83 0-1.5-.67-1.5-1.5S16.67 10 17.5 10s1.5.67 1.5 1.5S18.33 13 17.5 13zm-3-4c-.83 0-1.5-.67-1.5-1.5S13.67 7 14.5 7s1.5.67 1.5 1.5S15.33 9 14.5 9zM9 12c-.83 0-1.5-.67-1.5-1.5S8.17 9 9 9s1.5.67 1.5 1.5S9.83 12 9 12z"/>',
// Lightbulb & tip
light: '<path d="M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9v1zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7z" fill-rule="evenodd"/>',
lightFill: '<path d="M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9v1zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7z"/>',
// Voice
voice: '<path d="M12 15c1.66 0 2.99-1.34 2.99-3L15 6c0-1.66-1.34-3-3-3S9 4.34 9 6v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 15 6.7 12H5c0 3.41 2.72 6.23 6 6.72V22h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z" fill-rule="evenodd"/>',
voiceFill: '<path d="M12 15c1.66 0 2.99-1.34 2.99-3L15 6c0-1.66-1.34-3-3-3S9 4.34 9 6v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 15 6.7 12H5c0 3.41 2.72 6.23 6 6.72V22h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z"/>',
// Round check
roundCheck: '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" fill-rule="evenodd"/>',
roundCheckFill: '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>',
// Emoji / face
emoji: '<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" fill-rule="evenodd"/>',
emojiFill: '<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z"/>',
// Edit (pencil)
edit: '<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 00-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" fill-rule="evenodd"/>',
editFill: '<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 00-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>'
}
// 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 =
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="' +
color + '">' + path + '</svg>'
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:
* <name> = inactive (gray)
* <name>Fill = active (theme primary)
* Plus semantic aliases (delete, etc.) with appropriate colors.
*
* @param {object} theme - { primary, primaryLight, success, danger }
* @returns {object} { <iconName>: 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 }
+76 -26
View File
@@ -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 }
+124 -7
View File
@@ -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
}
+11 -9
View File
@@ -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,
+94
View File
@@ -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 }