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:
+26
-9
@@ -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() {}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user