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
+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;
}