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;
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"navigationBarTitleText": "排行榜",
|
||||
"enablePullDownRefresh": true,
|
||||
"backgroundColor": "#F5F5F5",
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: '确定要删除这条训练记录吗?',
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"usingComponents": {},
|
||||
"usingComponents": {
|
||||
"ui-card": "/components/ui-card/ui-card",
|
||||
"ui-btn": "/components/ui-btn/ui-btn"
|
||||
},
|
||||
"navigationBarTitleText": "设置"
|
||||
}
|
||||
|
||||
+127
-47
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user