backup: snapshot before optimization

This commit is contained in:
2026-06-04 16:25:43 +08:00
commit ff2700c067
47 changed files with 3332 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
# CodeGraph data files
# These are local to each machine and should not be committed
# Database
*.db
*.db-wal
*.db-shm
# Cache
cache/
# Logs
*.log
# Hook markers
.dirty
+6
View File
@@ -0,0 +1,6 @@
{
"pid": 75366,
"version": "0.9.7",
"socketPath": "/Users/liubleed/Documents/wx_pbzc/.codegraph/daemon.sock",
"startedAt": 1780561314477
}
+26
View File
@@ -0,0 +1,26 @@
var themeMod = require('./utils/theme')
App({
onLaunch: function () {
var settings = wx.getStorageSync('user_settings')
if (!settings) {
wx.setStorageSync('user_settings', {
planId: 'beginner',
dailyReminder: true,
reminderTime: '08:00',
voiceGuide: true,
vibrate: true
})
}
var streak = wx.getStorageSync('current_streak')
if (!streak) {
wx.setStorageSync('current_streak', { count: 0, lastDate: '' })
}
this.globalData.theme = themeMod.getCurrentTheme()
},
globalData: {
planId: 'beginner',
theme: null
}
})
+27
View File
@@ -0,0 +1,27 @@
{
"pages": [
"pages/index/index",
"pages/timer/timer",
"pages/records/records",
"pages/settings/settings"
],
"window": {
"navigationBarBackgroundColor": "#FF6B35",
"navigationBarTitleText": "平板支撑训练",
"navigationBarTextStyle": "white",
"backgroundColor": "#F5F5F5"
},
"tabBar": {
"custom": true,
"color": "#999999",
"selectedColor": "#FF6B35",
"backgroundColor": "#FFFFFF",
"borderStyle": "black",
"list": [
{ "pagePath": "pages/index/index", "text": "训练" },
{ "pagePath": "pages/records/records", "text": "记录" },
{ "pagePath": "pages/settings/settings", "text": "设置" }
]
},
"sitemapLocation": "sitemap.json"
}
+146
View File
@@ -0,0 +1,146 @@
page {
--primary: #FF6B35;
--primary-light: #FF8C5A;
--primary-bg: #FFF3ED;
--primary-rgb: 255,107,53;
--text: #333333;
--text-secondary: #999999;
--bg: #F5F5F5;
--card-bg: #FFFFFF;
--success: #4CAF50;
--border: #EEEEEE;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
font-size: 28rpx;
color: var(--text);
background-color: var(--bg);
}
.container {
padding: 24rpx;
padding-bottom: calc(134rpx + env(safe-area-inset-bottom));
min-height: 100vh;
box-sizing: border-box;
}
.card {
background: var(--card-bg);
border-radius: 24rpx;
padding: 32rpx;
margin-bottom: 24rpx;
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.06);
animation: cardIn 0.45s ease both;
}
.card:nth-child(1) { animation-delay: 0.05s; }
.card:nth-child(2) { animation-delay: 0.15s; }
.card:nth-child(3) { animation-delay: 0.25s; }
.card:nth-child(4) { animation-delay: 0.35s; }
@keyframes cardIn {
from {
opacity: 0;
transform: translateY(40rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.btn-primary {
background: linear-gradient(135deg, var(--primary), var(--primary-light));
color: #fff;
border: none;
border-radius: 48rpx;
font-size: 34rpx;
font-weight: 600;
padding: 24rpx 0;
text-align: center;
transition: transform 0.15s ease, box-shadow 0.15s ease;
box-shadow: 0 8rpx 24rpx rgba(var(--primary-rgb), 0.3);
}
.btn-primary:active {
transform: scale(0.96);
box-shadow: 0 4rpx 12rpx rgba(var(--primary-rgb), 0.2);
}
/* ---- pulse ring (used on timer page) ---- */
@keyframes breathePulse {
0%, 100% {
transform: scale(0.92);
opacity: 0.6;
}
50% {
transform: scale(1.08);
opacity: 0.15;
}
}
@keyframes breathePulseFast {
0%, 100% {
transform: scale(0.94);
opacity: 0.7;
}
50% {
transform: scale(1.06);
opacity: 0.2;
}
}
/* ---- float ---- */
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-12rpx); }
}
/* ---- pop / bounce ---- */
@keyframes popIn {
0% { transform: scale(0); opacity: 0; }
60% { transform: scale(1.15); }
100% { transform: scale(1); opacity: 1; }
}
@keyframes bounceSoft {
0%, 100% { transform: translateY(0); }
30% { transform: translateY(-16rpx); }
50% { transform: translateY(0); }
70% { transform: translateY(-6rpx); }
}
/* ---- shimmer ---- */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
/* ---- spin ---- */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.section-title {
font-size: 32rpx;
font-weight: 600;
color: var(--text);
margin-bottom: 20rpx;
}
/* ---- icon sizing ---- */
.icon {
line-height: 1;
flex-shrink: 0;
}
.icon-lg { font-size: 40rpx; }
.icon-md { font-size: 28rpx; }
.icon-sm { font-size: 22rpx; }
.icon-inline {
display: inline-flex;
align-items: center;
margin-right: 8rpx;
}
@@ -0,0 +1,85 @@
var util = require('../../utils/util')
Component({
properties: {
year: { type: Number, value: new Date().getFullYear() },
month: { type: Number, value: new Date().getMonth() + 1 },
records: { type: Array, value: [] }
},
data: {
weeks: []
},
observers: {
'year, month, records': function (year, month, records) {
this._compute(year, month, records)
}
},
lifetimes: {
ready: function () {
this._compute(this.properties.year, this.properties.month, this.properties.records)
}
},
methods: {
_compute: function (year, month, records) {
var weeks = util.getMonthCalendar(year, month)
var recordMap = {}
if (records && records.length) {
records.forEach(function (r) {
var d = parseInt(r.date.split('-')[2])
recordMap[d] = (recordMap[d] || 0) + r.duration
})
}
var data = weeks.map(function (week) {
return week.map(function (day) {
if (!day) return null
var duration = recordMap[day] || 0
var color = 'transparent'
if (duration > 0) {
if (duration < 30) color = '#FFE0D0'
else if (duration < 60) color = '#FFB088'
else if (duration < 120) color = '#FF6B35'
else color = '#E05520'
}
return { day: day, duration: duration, color: color }
})
})
this.setData({ weeks: data })
},
onDayTap: function (e) {
var day = e.currentTarget.dataset.day
if (!day) return
var cell = null
var weeks = this.data.weeks
for (var i = 0; i < weeks.length; i++) {
for (var j = 0; j < weeks[i].length; j++) {
var c = weeks[i][j]
if (c && c.day === day) { cell = c; break }
}
if (cell) break
}
if (!cell || cell.duration <= 0) return
this.triggerEvent('daytap', {
year: this.properties.year,
month: this.properties.month,
day: day,
duration: cell.duration
})
},
getHeatColor: function (cell) {
if (!cell || cell.duration <= 0) return 'transparent'
var d = cell.duration
if (d < 30) return '#FFE0D0'
if (d < 60) return '#FFB088'
if (d < 120) return '#FF6B35'
return '#E05520'
}
}
})
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
@@ -0,0 +1,31 @@
<view class="heatmap-container">
<view class="calendar-header">
<text class="month-label">{{year}}年{{month}}月</text>
<view class="weekday-labels">
<text class="weekday" wx:for="{{['日','一','二','三','四','五','六']}}" wx:key="*this">{{item}}</text>
</view>
</view>
<view class="calendar-grid">
<view class="calendar-week" wx:for="{{weeks}}" wx:key="index">
<view
class="calendar-day {{cell && cell.duration > 0 ? 'has-data' : 'empty'}}"
wx:for="{{item}}"
wx:for-item="cell"
wx:key="index"
style="background-color: {{cell ? cell.color : 'transparent'}};"
data-day="{{cell ? cell.day : ''}}"
bindtap="onDayTap"
>
<text class="day-num" wx:if="{{cell}}">{{cell.day}}</text>
</view>
</view>
</view>
<view class="heat-legend">
<text class="legend-label">少</text>
<view class="legend-block" style="background:#FFE0D0;"></view>
<view class="legend-block" style="background:#FFB088;"></view>
<view class="legend-block" style="background:#FF6B35;"></view>
<view class="legend-block" style="background:#E05520;"></view>
<text class="legend-label">多</text>
</view>
</view>
@@ -0,0 +1,77 @@
.heatmap-container {
padding: 0;
}
.calendar-header {
margin-bottom: 16rpx;
}
.month-label {
font-size: 28rpx;
font-weight: 600;
color: #333;
margin-bottom: 12rpx;
display: block;
text-align: center;
}
.weekday-labels {
display: flex;
justify-content: space-around;
}
.weekday {
width: 56rpx;
text-align: center;
font-size: 22rpx;
color: #999;
}
.calendar-grid {
display: flex;
flex-direction: column;
}
.calendar-week {
display: flex;
justify-content: space-around;
margin-bottom: 8rpx;
}
.calendar-day {
width: 56rpx;
height: 56rpx;
border-radius: 10rpx;
display: flex;
align-items: center;
justify-content: center;
}
.calendar-day.empty {
background: transparent;
}
.day-num {
font-size: 22rpx;
color: #333;
font-weight: 500;
}
.heat-legend {
display: flex;
align-items: center;
justify-content: center;
margin-top: 16rpx;
gap: 8rpx;
}
.legend-label {
font-size: 20rpx;
color: #999;
}
.legend-block {
width: 32rpx;
height: 24rpx;
border-radius: 6rpx;
}
+93
View File
@@ -0,0 +1,93 @@
var util = require('../../utils/util')
Component({
properties: {
duration: { type: Number, value: 60 },
remaining: { type: Number, value: 60 },
size: { type: Number, value: 280 },
ringWidth: { type: Number, value: 14 },
status: { type: String, value: 'idle' },
primaryColor: { type: String, value: '#FF6B35' },
primaryLightColor: { type: String, value: '#FF8C5A' }
},
data: {
displayTime: '00:00',
statusText: '准备开始'
},
observers: {
'remaining, status, duration, size, ringWidth, primaryColor': function () {
var texts = { idle: '准备开始', running: '坚持住', paused: '已暂停', completed: '完成!' }
this.setData({
displayTime: util.formatTime(this.data.remaining),
statusText: texts[this.data.status] || ''
})
this._draw()
}
},
lifetimes: {
ready: function () {
var self = this
var query = this.createSelectorQuery()
query.select('#ringCanvas')
.fields({ node: true, size: true })
.exec(function (res) {
if (!res || !res[0] || !res[0].node) return
var canvas = res[0].node
var dpr = wx.getSystemInfoSync().pixelRatio || 2
var displaySize = self.data.size
canvas.width = displaySize * dpr
canvas.height = displaySize * dpr
self._ctx = canvas.getContext('2d')
self._dpr = dpr
self._draw()
})
}
},
methods: {
_draw: function () {
var ctx = this._ctx
if (!ctx) return
var size = this.data.size
var dpr = this._dpr
var ringWidth = this.data.ringWidth
var duration = this.data.duration
var remaining = this.data.remaining
var primaryColor = this.data.primaryColor
var w = size * dpr
var h = size * dpr
ctx.clearRect(0, 0, w, h)
var cx = w / 2
var cy = h / 2
var radius = (size - ringWidth) / 2 * dpr
var lw = ringWidth * dpr
// track ring
ctx.beginPath()
ctx.arc(cx, cy, radius, 0, Math.PI * 2)
ctx.strokeStyle = '#EEEEEE'
ctx.lineWidth = lw
ctx.lineCap = 'round'
ctx.stroke()
// progress arc (clockwise from 12 o'clock, represents remaining time)
var ratio = duration > 0 ? Math.max(0, Math.min(1, remaining / duration)) : 0
if (ratio > 0.001) {
var startAngle = -Math.PI / 2
var endAngle = startAngle + ratio * Math.PI * 2
ctx.beginPath()
ctx.arc(cx, cy, radius, startAngle, endAngle)
ctx.strokeStyle = primaryColor
ctx.lineWidth = lw
ctx.lineCap = 'round'
ctx.stroke()
}
}
}
})
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
@@ -0,0 +1,7 @@
<view class="ring-wrap" style="width: {{size}}rpx; height: {{size}}rpx;">
<canvas type="2d" id="ringCanvas" class="ring-canvas" style="width: {{size}}rpx; height: {{size}}rpx;"></canvas>
<view class="ring-center">
<text class="time-text">{{displayTime}}</text>
<text class="status-text">{{statusText}}</text>
</view>
</view>
@@ -0,0 +1,37 @@
.ring-wrap {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
}
.ring-canvas {
position: absolute;
top: 0;
left: 0;
}
.ring-center {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 1;
}
.time-text {
font-size: 80rpx;
font-weight: 700;
color: var(--text);
font-variant-numeric: tabular-nums;
letter-spacing: 2rpx;
line-height: 1;
}
.status-text {
font-size: 28rpx;
color: var(--text-secondary);
margin-top: 10rpx;
line-height: 1;
}
+37
View File
@@ -0,0 +1,37 @@
var themeMod = require('../utils/theme')
Component({
data: {
selected: 0,
themeStyle: ''
},
lifetimes: {
attached: function () {
var theme = themeMod.getCurrentTheme()
this.setData({ themeStyle: themeMod.getThemeStyle(theme) })
}
},
pageLifetimes: {
show: function () {
var theme = themeMod.getCurrentTheme()
this.setData({ themeStyle: themeMod.getThemeStyle(theme) })
}
},
methods: {
switchTab: function (e) {
var index = e.currentTarget.dataset.index
var pages = [
'/pages/index/index',
'/pages/records/records',
'/pages/settings/settings'
]
if (index === this.data.selected) return
wx.switchTab({ url: pages[index] })
}
}
})
+4
View File
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
+44
View File
@@ -0,0 +1,44 @@
<view class="tab-bar" style="{{themeStyle}}">
<view
class="tab-item {{selected === 0 ? 'active' : ''}}"
data-index="0"
bindtap="switchTab"
>
<view class="tab-icon">
<view class="icon-home {{selected === 0 ? 'active' : ''}}">
<view class="home-roof"></view>
<view class="home-body"></view>
</view>
</view>
<text class="tab-text">训练</text>
</view>
<view
class="tab-item {{selected === 1 ? 'active' : ''}}"
data-index="1"
bindtap="switchTab"
>
<view class="tab-icon">
<view class="icon-records {{selected === 1 ? 'active' : ''}}">
<view class="bar bar1"></view>
<view class="bar bar2"></view>
<view class="bar bar3"></view>
</view>
</view>
<text class="tab-text">记录</text>
</view>
<view
class="tab-item {{selected === 2 ? 'active' : ''}}"
data-index="2"
bindtap="switchTab"
>
<view class="tab-icon">
<view class="icon-settings {{selected === 2 ? 'active' : ''}}">
<view class="gear-outer"></view>
<view class="gear-center"></view>
</view>
</view>
<text class="tab-text">设置</text>
</view>
</view>
+146
View File
@@ -0,0 +1,146 @@
.tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 110rpx;
background: #FFFFFF;
display: flex;
align-items: center;
justify-content: space-around;
border-top: 1rpx solid rgba(0, 0, 0, 0.06);
padding-bottom: env(safe-area-inset-bottom);
z-index: 999;
}
.tab-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 6rpx 0;
flex: 1;
}
.tab-item:active {
transform: scale(0.9);
}
.tab-text {
font-size: 24rpx;
color: #666;
margin-top: 8rpx;
line-height: 1;
}
.tab-item.active .tab-text {
color: var(--primary);
font-weight: 600;
}
/* ---- icon: home ---- */
.icon-home {
position: relative;
width: 44rpx;
height: 40rpx;
}
.tab-item.active .icon-home {
animation: bounceSoft 0.5s ease;
}
.home-roof {
width: 0;
height: 0;
border-left: 22rpx solid transparent;
border-right: 22rpx solid transparent;
border-bottom: 18rpx solid #888;
position: absolute;
top: 0;
left: 0;
}
.tab-item.active .home-roof {
border-bottom-color: var(--primary);
}
.home-body {
width: 22rpx;
height: 20rpx;
background: #888;
position: absolute;
bottom: 0;
left: 11rpx;
border-radius: 2rpx 2rpx 0 0;
}
.tab-item.active .home-body {
background: var(--primary);
}
/* ---- icon: records (3 bars) ---- */
.icon-records {
width: 40rpx;
height: 34rpx;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 6rpx;
}
.tab-item.active .icon-records {
animation: bounceSoft 0.5s ease;
}
.bar {
height: 6rpx;
background: #888;
border-radius: 3rpx;
}
.bar1 { width: 100%; }
.bar2 { width: 65%; }
.bar3 { width: 80%; }
.tab-item.active .bar {
background: var(--primary);
}
/* ---- icon: settings (gear) ---- */
.icon-settings {
position: relative;
width: 42rpx;
height: 42rpx;
}
.tab-item.active .icon-settings {
animation: bounceSoft 0.5s ease;
}
.gear-outer {
width: 36rpx;
height: 36rpx;
border: 5rpx solid #888;
border-radius: 50%;
position: absolute;
top: 3rpx;
left: 3rpx;
}
.tab-item.active .gear-outer {
border-color: var(--primary);
}
.gear-center {
width: 12rpx;
height: 12rpx;
background: #888;
border-radius: 50%;
position: absolute;
top: 15rpx;
left: 15rpx;
}
.tab-item.active .gear-center {
background: var(--primary);
}
+104
View File
@@ -0,0 +1,104 @@
var storage = require('../../utils/storage')
var planMod = require('../../utils/plan')
var util = require('../../utils/util')
var themeMod = require('../../utils/theme')
Page({
data: {
theme: { primary: '#FF6B35', primaryLight: '#FF8C5A', primaryBg: '#FFF3ED', primaryRgb: '255,107,53' },
themeStyle: '',
todayTarget: 0,
todayDone: false,
todayDuration: 0,
todayTargetText: '',
streakCount: 0,
planName: '',
planDay: 1,
planTotal: 7,
planProgress: 0,
showPicker: false,
presets: [30, 60, 90, 120, 180, 300],
customDuration: 60,
customInput: ''
},
onLoad: function () {
this.refresh()
},
onShow: function () {
themeMod.applyThemeToPage(this)
try {
var tb = this.getTabBar()
if (tb) tb.setData({ selected: 0 })
} catch (e) {}
this.refresh()
},
onPullDownRefresh: function () {
this.refresh()
wx.stopPullDownRefresh()
},
refresh: function () {
var settings = storage.getSettings()
var streak = storage.getStreak()
var todayRecord = storage.getTodayRecord()
var records = []
var allRecords = storage.getRecords()
Object.keys(allRecords).forEach(function (key) {
records = records.concat(allRecords[key])
})
var plan = planMod.getPlan(settings.planId)
var planDay = planMod.getPlanDay(settings.planId, records)
var target = planMod.getTodayTarget(settings.planId, Math.min(planDay, plan.totalDays))
this.setData({
todayTarget: target,
todayDone: todayRecord && todayRecord.duration >= target,
todayDuration: todayRecord ? todayRecord.duration : 0,
todayTargetText: util.formatTime(target),
streakCount: streak.count,
planName: plan.name,
planDay: Math.min(planDay, plan.totalDays),
planTotal: plan.totalDays,
planProgress: Math.round((Math.min(planDay, plan.totalDays) / plan.totalDays) * 100)
})
},
onStartTrain: function () {
wx.navigateTo({ url: '/pages/timer/timer' })
},
onFreeTrain: function () {
this.setData({ showPicker: true, customInput: '', customDuration: 60 })
},
onClosePicker: function () {
this.setData({ showPicker: false })
},
onSelectPreset: function (e) {
var val = e.currentTarget.dataset.value
this.setData({ customDuration: val, customInput: '' })
},
onCustomInput: function (e) {
var val = parseInt(e.detail.value) || 0
this.setData({ customInput: e.detail.value, customDuration: val })
},
onConfirmFree: function () {
var dur = this.data.customDuration
if (!dur || dur <= 0) {
wx.showToast({ title: '请输入有效时长', icon: 'none' })
return
}
this.setData({ showPicker: false })
wx.navigateTo({ url: '/pages/timer/timer?free=' + dur })
},
noop: function () {}
})
+4
View File
@@ -0,0 +1,4 @@
{
"usingComponents": {},
"navigationBarTitleText": "平板支撑训练"
}
+113
View File
@@ -0,0 +1,113 @@
<view class="container" style="{{themeStyle}}">
<!-- 顶部问候 -->
<view class="greeting-row">
<view class="greeting-text">
<text class="greeting-emoji">{{todayDone ? '💪' : '👋'}}</text>
<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>
</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>
</view>
</view>
<!-- 今日目标卡片 -->
<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>
</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>
</block>
<block wx:else>
<text class="pending-text">⏳ 还未开始训练</text>
</block>
</view>
</view>
<!-- 操作按钮区 -->
<view class="action-buttons">
<button class="start-btn btn-primary" bindtap="onStartTrain">
<text class="btn-icon">{{todayDone ? '🔄' : '▶'}}</text>
<text>{{todayDone ? '再次训练' : '开始训练'}}</text>
</button>
<button class="free-btn" bindtap="onFreeTrain">
<text class="free-icon">⏱</text>
<text>自由训练 · 自定义时长</text>
</button>
</view>
<!-- 小提示 -->
<view class="tip-card">
<text class="tip-icon">💡</text>
<text class="tip-text">保持身体成一条直线,核心收紧,均匀呼吸</text>
</view>
</view>
<!-- 时间选择弹窗 -->
<view class="modal-overlay" wx:if="{{showPicker}}" bindtap="onClosePicker">
<view class="picker-modal" catchtap="noop">
<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>
<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>
+414
View File
@@ -0,0 +1,414 @@
/* ---- greeting ---- */
.greeting-row {
padding: 16rpx 8rpx 24rpx;
animation: cardIn 0.4s ease both;
}
.greeting-text {
display: flex;
align-items: center;
margin-bottom: 8rpx;
}
.greeting-emoji {
font-size: 40rpx;
margin-right: 12rpx;
line-height: 1;
}
.greeting-title {
font-size: 40rpx;
font-weight: 700;
color: var(--text);
line-height: 1.2;
}
.greeting-sub {
font-size: 26rpx;
color: var(--text-secondary);
margin-left: 56rpx;
line-height: 1;
}
/* ---- header card ---- */
.header-card {
display: flex;
align-items: center;
padding: 36rpx 32rpx;
}
.streak-section {
display: flex;
flex-direction: column;
align-items: center;
min-width: 140rpx;
}
.streak-icon {
font-size: 40rpx;
margin-bottom: 4rpx;
line-height: 1;
animation: float 2.5s ease-in-out infinite;
}
.streak-num {
font-size: 56rpx;
font-weight: 700;
color: var(--primary);
line-height: 1.1;
}
.streak-label {
font-size: 22rpx;
color: var(--text-secondary);
margin-top: 4rpx;
line-height: 1;
}
.divider {
width: 2rpx;
height: 80rpx;
background: var(--border);
margin: 0 28rpx;
}
.plan-info {
flex: 1;
display: flex;
flex-direction: column;
}
.plan-badge {
display: flex;
align-items: center;
margin-bottom: 16rpx;
}
.plan-badge-icon {
font-size: 28rpx;
margin-right: 8rpx;
line-height: 1;
}
.plan-badge-text {
font-size: 26rpx;
font-weight: 600;
color: var(--text);
line-height: 1;
}
.progress-bar-wrap {
display: flex;
flex-direction: column;
}
.progress-bar {
width: 100%;
height: 8rpx;
background: var(--border);
border-radius: 4rpx;
overflow: visible;
margin-bottom: 8rpx;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary), var(--primary-light));
border-radius: 4rpx;
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.progress-glow {
position: absolute;
right: -4rpx;
top: -4rpx;
width: 16rpx;
height: 16rpx;
background: var(--primary);
border-radius: 50%;
box-shadow: 0 0 10rpx var(--primary);
}
.progress-label {
font-size: 22rpx;
color: var(--text-secondary);
line-height: 1;
}
/* ---- target card ---- */
.target-section {
text-align: center;
padding: 36rpx 32rpx;
}
.target-header {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8rpx;
}
.target-header .section-title {
margin-bottom: 0;
}
.target-icon {
font-size: 28rpx;
margin-right: 8rpx;
line-height: 1;
}
.target-display {
margin: 20rpx 0;
}
.target-ring {
display: inline-flex;
align-items: center;
justify-content: center;
width: 200rpx;
height: 200rpx;
border-radius: 50%;
background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.08), rgba(var(--primary-rgb), 0.02));
border: 4rpx solid rgba(var(--primary-rgb), 0.15);
margin-bottom: 16rpx;
}
.target-time {
font-size: 64rpx;
font-weight: 700;
color: var(--primary);
font-variant-numeric: tabular-nums;
line-height: 1;
}
.target-unit {
display: block;
font-size: 26rpx;
color: var(--text-secondary);
margin-top: 4rpx;
line-height: 1;
}
.today-status {
margin-top: 16rpx;
}
.done-badge {
display: inline-flex;
align-items: center;
background: rgba(76, 175, 80, 0.08);
padding: 10rpx 24rpx;
border-radius: 24rpx;
}
.done-emoji {
font-size: 28rpx;
margin-right: 6rpx;
line-height: 1;
}
.done-text {
font-size: 26rpx;
color: var(--success);
line-height: 1;
}
.pending-text {
font-size: 26rpx;
color: #CCC;
line-height: 1;
}
/* ---- 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;
}
/* ---- tip card ---- */
.tip-card {
display: flex;
align-items: flex-start;
padding: 24rpx 32rpx;
margin-top: 32rpx;
background: rgba(var(--primary-rgb), 0.04);
border-radius: 20rpx;
animation: cardIn 0.45s 0.4s ease both;
}
.tip-icon {
font-size: 28rpx;
margin-right: 10rpx;
flex-shrink: 0;
line-height: 1.6;
}
.tip-text {
font-size: 28rpx;
color: var(--text-secondary);
line-height: 1.6;
}
/* ---- picker modal ---- */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 1000;
display: flex;
align-items: flex-end;
justify-content: center;
}
.picker-modal {
width: 100%;
background: #fff;
border-radius: 32rpx 32rpx 0 0;
padding: 40rpx 32rpx;
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
animation: slideUp 0.3s cubic-bezier(0.32, 0.72, 0, 1);
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.picker-title {
font-size: 34rpx;
font-weight: 600;
color: var(--text);
display: block;
text-align: center;
margin-bottom: 32rpx;
}
.preset-grid {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-bottom: 28rpx;
}
.preset-item {
flex: 0 0 calc(33.33% - 12rpx);
background: #F5F5F5;
border-radius: 16rpx;
padding: 20rpx 0;
text-align: center;
display: flex;
flex-direction: column;
}
.preset-item:active {
transform: scale(0.94);
}
.preset-item.selected {
background: var(--primary-bg);
border: 2rpx solid var(--primary);
}
.preset-num {
font-size: 36rpx;
font-weight: 700;
color: var(--text);
line-height: 1.1;
}
.preset-item.selected .preset-num {
color: var(--primary);
}
.preset-unit {
font-size: 20rpx;
color: var(--text-secondary);
margin-top: 2rpx;
line-height: 1;
}
.custom-input-row {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 28rpx;
}
.custom-label {
font-size: 28rpx;
color: #666;
line-height: 1;
}
.custom-input {
width: 160rpx;
height: 64rpx;
background: #F5F5F5;
border-radius: 12rpx;
text-align: center;
font-size: 32rpx;
font-weight: 600;
color: var(--primary);
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;
}
+140
View File
@@ -0,0 +1,140 @@
var storage = require('../../utils/storage')
var util = require('../../utils/util')
var themeMod = require('../../utils/theme')
Page({
data: {
theme: { primary: '#FF6B35', primaryLight: '#FF8C5A', primaryBg: '#FFF3ED', primaryRgb: '255,107,53' },
themeStyle: '',
totalDuration: 0,
totalDurationText: '',
totalSessions: 0,
maxDuration: 0,
maxDurationText: '',
currentYear: new Date().getFullYear(),
currentMonth: new Date().getMonth() + 1,
monthRecords: [],
historyList: [],
showDayDetail: false,
dayDetail: {}
},
onLoad: function () {
this.refresh()
},
onShow: function () {
themeMod.applyThemeToPage(this)
try {
var tb = this.getTabBar()
if (tb) tb.setData({ selected: 1 })
} catch (e) {}
this.refresh()
},
onPullDownRefresh: function () {
this.refresh()
wx.stopPullDownRefresh()
},
refresh: function () {
var stats = storage.getTotalStats()
var monthKey = this.data.currentYear + '-' +
(this.data.currentMonth < 10 ? '0' : '') + this.data.currentMonth
var records = storage.getRecordsByMonth(monthKey)
var allRecords = []
var all = storage.getRecords()
Object.keys(all).forEach(function (key) {
allRecords = allRecords.concat(all[key])
})
allRecords.sort(function (a, b) { return b.date.localeCompare(a.date) })
this.setData({
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)
})
},
onPrevMonth: function () {
var year = this.data.currentYear
var month = this.data.currentMonth
if (month === 1) {
year--
month = 12
} else {
month--
}
this.setData({ currentYear: year, currentMonth: month })
this.refresh()
},
onNextMonth: function () {
var year = this.data.currentYear
var month = this.data.currentMonth
if (month === 12) {
year++
month = 1
} else {
month++
}
this.setData({ currentYear: year, currentMonth: month })
this.refresh()
},
onDayTap: function (e) {
var detail = e.detail
var dateStr = detail.year + '-' +
(detail.month < 10 ? '0' : '') + detail.month + '-' +
(detail.day < 10 ? '0' : '') + detail.day
var allRecords = []
var all = storage.getRecords()
Object.keys(all).forEach(function (key) {
allRecords = allRecords.concat(all[key])
})
var sessions = allRecords.filter(function (r) { return r.date === dateStr })
var totalDur = 0
sessions.forEach(function (r) { totalDur += r.duration })
var calories = Math.round(totalDur * 0.068)
this.setData({
showDayDetail: true,
dayDetail: {
date: dateStr,
sessions: sessions.length,
duration: totalDur,
durationText: util.formatDuration(totalDur),
calories: calories
}
})
},
onCloseDayDetail: function () {
this.setData({ showDayDetail: false })
},
noop: function () {},
onDeleteRecord: function (e) {
var id = e.currentTarget.dataset.id
var self = this
wx.showModal({
title: '删除记录',
content: '确定要删除这条训练记录吗?',
success: function (res) {
if (res.confirm) {
storage.deleteRecord(id)
self.refresh()
wx.showToast({ title: '已删除', icon: 'none', duration: 1200 })
}
}
})
}
})
+6
View File
@@ -0,0 +1,6 @@
{
"usingComponents": {
"calendar-heatmap": "../../components/calendar-heatmap/calendar-heatmap"
},
"navigationBarTitleText": "训练记录"
}
+91
View File
@@ -0,0 +1,91 @@
<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>
</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>
<!-- 日历热力图 -->
<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>
</view>
<view class="month-arrow" bindtap="onNextMonth"></view>
</view>
<calendar-heatmap
year="{{currentYear}}"
month="{{currentMonth}}"
records="{{monthRecords}}"
binddaytap="onDayTap"
></calendar-heatmap>
</view>
<!-- 历史记录 -->
<view class="history-section card">
<view class="history-header">
<text class="section-title">📋 最近记录</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>
</view>
<view class="history-right">
<text class="history-duration">{{item.duration}}s</text>
</view>
</view>
</view>
</view>
<!-- 日期详情弹窗 -->
<view class="modal-overlay" 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>
</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>
<view class="detail-close" bindtap="onCloseDayDetail">
<text>关闭</text>
</view>
</view>
</view>
</view>
+230
View File
@@ -0,0 +1,230 @@
.stats {
display: flex;
justify-content: space-around;
text-align: center;
padding: 32rpx 16rpx;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.stat-emoji {
font-size: 28rpx;
width: 56rpx;
height: 40rpx;
line-height: 40rpx;
text-align: center;
margin-bottom: 4rpx;
}
.stat-num {
font-size: 36rpx;
font-weight: 700;
color: var(--primary);
animation: popIn 0.5s ease;
text-align: center;
}
.stat-label {
font-size: 22rpx;
color: var(--text-secondary);
margin-top: 4rpx;
text-align: center;
}
/* ---- calendar ---- */
.calendar-section {
padding: 24rpx 24rpx 28rpx;
}
.month-picker {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.month-title-wrap {
display: flex;
align-items: center;
gap: 10rpx;
}
.month-emoji {
font-size: 28rpx;
}
.month-title {
font-size: 28rpx;
font-weight: 600;
color: var(--text);
}
.month-arrow {
font-size: 44rpx;
color: var(--primary);
padding: 0 20rpx;
font-weight: 300;
transition: opacity 0.15s;
}
.month-arrow:active {
opacity: 0.5;
}
/* ---- history ---- */
.history-header {
margin-bottom: 8rpx;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 60rpx 0;
}
.empty-emoji {
font-size: 64rpx;
margin-bottom: 16rpx;
animation: float 3s ease-in-out infinite;
}
.empty-text {
color: #CCC;
font-size: 28rpx;
margin-bottom: 8rpx;
}
.empty-sub {
font-size: 24rpx;
color: #E0E0E0;
}
.history-list {
margin-top: 8rpx;
}
.history-item {
display: flex;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid var(--border);
animation: cardIn 0.35s ease both;
}
.history-item:last-child {
border-bottom: none;
}
.history-left {
display: flex;
flex-direction: column;
flex: 1;
}
.history-date {
font-size: 28rpx;
color: var(--text);
}
.history-plan {
font-size: 22rpx;
color: var(--text-secondary);
margin-top: 4rpx;
}
.history-right {
display: flex;
align-items: center;
}
.history-duration {
font-size: 32rpx;
font-weight: 600;
color: var(--primary);
}
/* ---- day detail popup ---- */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.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;
}
.detail-emoji {
font-size: 32rpx;
margin-right: 10rpx;
line-height: 1;
}
.detail-date {
font-size: 34rpx;
font-weight: 700;
color: var(--text);
}
.detail-grid {
display: flex;
justify-content: space-around;
margin-bottom: 36rpx;
}
.detail-item {
display: flex;
flex-direction: column;
align-items: center;
}
.detail-num {
font-size: 36rpx;
font-weight: 700;
color: var(--primary);
margin-bottom: 6rpx;
}
.detail-label {
font-size: 22rpx;
color: var(--text-secondary);
}
.detail-close {
padding: 16rpx 0;
font-size: 28rpx;
color: var(--text-secondary);
border-top: 1rpx solid var(--border);
}
.history-icon {
font-size: 28rpx;
margin-right: 16rpx;
flex-shrink: 0;
line-height: 1;
}
+104
View File
@@ -0,0 +1,104 @@
var storage = require('../../utils/storage')
var themeMod = require('../../utils/theme')
Page({
data: {
theme: { primary: '#FF6B35', primaryLight: '#FF8C5A', primaryBg: '#FFF3ED', primaryRgb: '255,107,53' },
themeStyle: '',
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 }
],
currentPlanId: 'beginner',
dailyReminder: true,
reminderTime: '08:00',
voiceGuide: true,
vibrate: true
},
onLoad: function () {
var s = storage.getSettings()
var theme = themeMod.getCurrentTheme()
themeMod.applyThemeToPage(this)
this.setData({
currentPlanId: s.planId,
dailyReminder: s.dailyReminder,
reminderTime: s.reminderTime,
voiceGuide: s.voiceGuide,
vibrate: s.vibrate,
currentThemeId: theme.id
})
},
onShow: function () {
try {
var tb = this.getTabBar()
if (tb) tb.setData({ selected: 2 })
} catch (e) {}
themeMod.applyThemeToPage(this)
},
onSelectTheme: function (e) {
var id = e.currentTarget.dataset.id
var theme = themeMod.setTheme(id)
this.setData({ currentThemeId: id })
try {
var tb = this.getTabBar()
if (tb) {
tb.setData({ themeStyle: themeMod.getThemeStyle(theme) })
}
} catch (e) {}
themeMod.applyThemeToPage(this)
},
onSelectPlan: function (e) {
var planId = e.currentTarget.dataset.id
var s = storage.getSettings()
s.planId = planId
storage.saveSettings(s)
this.setData({ currentPlanId: planId })
wx.showToast({ title: '计划已切换', icon: 'success', duration: 1200 })
},
onToggleReminder: function (e) {
var s = storage.getSettings()
s.dailyReminder = e.detail.value
storage.saveSettings(s)
this.setData({ dailyReminder: e.detail.value })
},
onReminderTimeChange: function (e) {
var s = storage.getSettings()
s.reminderTime = e.detail.value
storage.saveSettings(s)
this.setData({ reminderTime: e.detail.value })
},
onToggleVoice: function (e) {
var s = storage.getSettings()
s.voiceGuide = e.detail.value
storage.saveSettings(s)
this.setData({ voiceGuide: e.detail.value })
},
onToggleVibrate: function (e) {
var s = storage.getSettings()
s.vibrate = e.detail.value
storage.saveSettings(s)
this.setData({ vibrate: e.detail.value })
},
onCopyWechat: function () {
wx.setClipboardData({
data: '刘承',
success: function () {
wx.showToast({ title: '已复制', icon: 'success', duration: 1500 })
}
})
}
})
+4
View File
@@ -0,0 +1,4 @@
{
"usingComponents": {},
"navigationBarTitleText": "设置"
}
+115
View File
@@ -0,0 +1,115 @@
<view class="container" style="{{themeStyle}}">
<!-- 配色方案 -->
<view class="card">
<view class="section-header">
<text class="section-emoji">🎨</text>
<text class="section-title">配色方案</text>
</view>
<view class="theme-list">
<view
class="theme-item {{currentThemeId === item.id ? 'active' : ''}}"
wx:for="{{themes}}"
wx:key="id"
data-id="{{item.id}}"
bindtap="onSelectTheme"
>
<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>
</view>
</view>
</view>
<!-- 训练计划选择 -->
<view class="card">
<view class="section-header">
<text class="section-emoji">📋</text>
<text class="section-title">训练计划</text>
</view>
<view class="plan-list">
<view
class="plan-item {{currentPlanId === item.id ? 'active' : ''}}"
wx:for="{{plans}}"
wx:key="id"
data-id="{{item.id}}"
bindtap="onSelectPlan"
>
<view class="plan-radio">
<view class="radio-dot" wx:if="{{currentPlanId === item.id}}"></view>
</view>
<view class="plan-content">
<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>
</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>
<!-- 训练偏好 -->
<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="{{voiceGuide}}" bindchange="onToggleVoice" color="{{theme.primary}}"/>
</view>
<view class="setting-row">
<view class="setting-left">
<text class="setting-icon">📳</text>
<text class="setting-label">振动反馈</text>
</view>
<switch checked="{{vibrate}}" bindchange="onToggleVibrate" color="{{theme.primary}}"/>
</view>
</view>
<!-- 关于 -->
<view class="card about-card">
<view class="section-header">
<text class="section-emoji"></text>
<text class="section-title">关于</text>
</view>
<view class="about-list">
<view class="about-row">
<text class="about-label">版本</text>
<text class="about-value">v1.0.0</text>
</view>
<view class="about-row" bindtap="onCopyWechat">
<text class="about-label">开发者</text>
<text class="about-value about-link">刘承</text>
</view>
</view>
<view class="about-footer">
<text class="about-copy">Made with ❤️ for better fitness</text>
</view>
</view>
</view>
+220
View File
@@ -0,0 +1,220 @@
.section-header {
display: flex;
align-items: center;
margin-bottom: 20rpx;
}
.section-emoji {
font-size: 28rpx;
margin-right: 10rpx;
line-height: 1;
}
.section-header .section-title {
margin-bottom: 0;
}
/* ---- theme picker ---- */
.theme-list {
margin-top: 0;
}
.theme-item {
display: flex;
align-items: center;
padding: 22rpx 0;
border-bottom: 1rpx solid var(--border);
transition: background 0.2s ease;
}
.theme-item:last-child {
border-bottom: none;
}
.theme-item.active {
background: var(--primary-bg);
margin: 0 -32rpx;
padding-left: 32rpx;
padding-right: 32rpx;
border-radius: 16rpx;
border-bottom: none;
}
.theme-item.active + .theme-item {
border-top: 1rpx solid transparent;
}
.theme-color-dot {
width: 44rpx;
height: 44rpx;
border-radius: 50%;
margin-right: 20rpx;
flex-shrink: 0;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.15);
transition: transform 0.2s ease;
}
.theme-item:active .theme-color-dot {
transform: scale(1.15);
}
.theme-name {
flex: 1;
font-size: 28rpx;
color: var(--text);
}
/* ---- plan list ---- */
.plan-list {
margin-top: 0;
}
.plan-item {
display: flex;
align-items: center;
padding: 24rpx 0;
border-bottom: 1rpx solid var(--border);
}
.plan-item:last-child {
border-bottom: none;
}
.plan-item.active {
background: var(--primary-bg);
margin: 0 -32rpx;
padding-left: 32rpx;
padding-right: 32rpx;
border-radius: 16rpx;
border-bottom: none;
}
.plan-radio {
width: 40rpx;
height: 40rpx;
border: 3rpx solid #DDD;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
flex-shrink: 0;
transition: border-color 0.2s;
}
.plan-item.active .plan-radio {
border-color: var(--primary);
}
.radio-dot {
width: 22rpx;
height: 22rpx;
background: var(--primary);
border-radius: 50%;
animation: popIn 0.3s ease;
}
.plan-content {
flex: 1;
display: flex;
flex-direction: column;
}
.plan-item-name {
font-size: 28rpx;
font-weight: 600;
color: var(--text);
}
.plan-item-desc {
font-size: 22rpx;
color: var(--text-secondary);
margin-top: 4rpx;
}
.plan-check {
color: var(--primary);
font-size: 32rpx;
font-weight: 700;
}
/* ---- setting rows ---- */
.setting-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid var(--border);
}
.setting-row:last-child {
border-bottom: none;
}
.setting-left {
display: flex;
align-items: center;
}
.setting-icon {
font-size: 28rpx;
margin-right: 12rpx;
}
.setting-label {
font-size: 28rpx;
color: var(--text);
}
.setting-value {
font-size: 28rpx;
color: var(--primary);
}
/* ---- about ---- */
.about-card {
margin-top: 8rpx;
}
.about-list {
margin-top: 12rpx;
}
.about-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 18rpx 0;
border-bottom: 1rpx solid var(--border);
}
.about-row:last-child {
border-bottom: none;
}
.about-label {
font-size: 26rpx;
color: var(--text-secondary);
}
.about-value {
font-size: 26rpx;
color: var(--text);
}
.about-link {
color: var(--primary);
font-weight: 500;
}
.about-footer {
margin-top: 24rpx;
padding-top: 20rpx;
border-top: 1rpx solid var(--border);
text-align: center;
}
.about-copy {
font-size: 22rpx;
color: #CCCCCC;
}
+117
View File
@@ -0,0 +1,117 @@
var Timer = require('../../utils/timer')
var storage = require('../../utils/storage')
var planMod = require('../../utils/plan')
var themeMod = require('../../utils/theme')
Page({
data: {
theme: { primary: '#FF6B35', primaryLight: '#FF8C5A', primaryBg: '#FFF3ED', primaryRgb: '255,107,53' },
themeStyle: '',
duration: 60,
remaining: 60,
status: 'idle',
isRunning: false,
isPaused: false,
isCompleted: false,
overtime: 0,
todayTarget: 0,
planDay: 1,
isFreeMode: false
},
onLoad: function (options) {
themeMod.applyThemeToPage(this)
var target
var planDay = 1
var isFreeMode = false
if (options && options.free) {
target = parseInt(options.free) || 60
isFreeMode = true
} else {
var settings = storage.getSettings()
var records = []
var allRecords = storage.getRecords()
Object.keys(allRecords).forEach(function (key) {
records = records.concat(allRecords[key])
})
var plan = planMod.getPlan(settings.planId)
planDay = planMod.getPlanDay(settings.planId, records)
target = planMod.getTodayTarget(settings.planId, Math.min(planDay, plan.totalDays))
}
this.setData({
duration: target,
remaining: target,
todayTarget: target,
planDay: isFreeMode ? 0 : Math.min(planDay, 99),
isFreeMode: isFreeMode
})
var self = this
this._timer = new Timer({
onTick: function (tick) {
self.setData({
remaining: tick.remaining > 0 ? tick.remaining : 0,
overtime: tick.remaining <= 0 ? tick.elapsed - self.data.duration : 0
})
},
onComplete: function () {
self.setData({ status: 'completed', isCompleted: true })
if (storage.getSettings().vibrate) {
try { wx.vibrateLong() } catch (e) {}
}
wx.showToast({ title: '目标完成! 太棒了!', icon: 'success', duration: 2000 })
}
})
},
onUnload: function () {
if (this._timer) this._timer.stop()
},
onStart: function () {
if (this.data.isPaused) {
this._timer.resume()
this.setData({ isRunning: true, isPaused: false, status: 'running' })
return
}
if (this.data.isRunning) return
this._timer.start(this.data.duration)
this.setData({ isRunning: true, status: 'running' })
},
onPause: function () {
if (!this.data.isRunning || this.data.isPaused) return
this._timer.pause()
this.setData({ isPaused: true, status: 'paused' })
},
onStop: function () {
var self = this
wx.showModal({
title: '结束训练',
content: '确定要结束本次训练吗?',
success: function (res) {
if (res.confirm) self.finishTraining()
}
})
},
finishTraining: function () {
var elapsed = this._timer.stop()
var today = storage.getToday()
storage.saveRecord({
date: today,
duration: elapsed,
planId: storage.getSettings().planId,
day: this.data.planDay
})
storage.updateStreak(today)
wx.showToast({ title: '已记录 ' + elapsed + '秒', icon: 'none', duration: 1500 })
setTimeout(function () { wx.navigateBack() }, 1500)
}
})
+6
View File
@@ -0,0 +1,6 @@
{
"usingComponents": {
"progress-ring": "../../components/progress-ring/progress-ring"
},
"navigationBarTitleText": "训练中"
}
+95
View File
@@ -0,0 +1,95 @@
<view class="container timer-page" style="{{themeStyle}}">
<!-- 呼吸引导环 + 进度条 -->
<view class="ring-wrapper">
<view class="breathe-ring breathe-ring-1 {{status === 'running' ? 'fast' : ''}}"
wx:if="{{status !== 'completed'}}"></view>
<view class="breathe-ring breathe-ring-2 {{status === 'running' ? 'fast' : ''}}"
wx:if="{{status !== 'completed'}}"></view>
<view class="breathe-ring breathe-ring-3 {{status === 'running' ? 'fast' : ''}}"
wx:if="{{status !== 'completed'}}"></view>
<progress-ring
duration="{{duration}}"
remaining="{{isCompleted ? 0 : remaining}}"
size="{{360}}"
ringWidth="{{16}}"
status="{{status}}"
primaryColor="{{theme.primary}}"
primaryLightColor="{{theme.primaryLight}}"
></progress-ring>
<!-- 完成庆祝粒子 -->
<view class="celebrate-burst" wx:if="{{isCompleted}}">
<view class="spark s1">✨</view>
<view class="spark s2">⭐</view>
<view class="spark s3">💫</view>
<view class="spark s4">🌟</view>
<view class="spark s5">✨</view>
<view class="spark s6">💪</view>
</view>
<view class="overtime-badge" wx:if="{{isCompleted && overtime > 0}}">
<text class="overtime-icon">🔥</text>
<text>+{{overtime}}s</text>
</view>
</view>
<!-- 状态提示 -->
<view class="hint-section">
<view class="hint-row" wx:if="{{status === 'idle'}}">
<text class="hint-emoji">🧘</text>
<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>
<text class="hint-text running">保持姿势,核心收紧!</text>
</view>
<view class="hint-row" wx:elif="{{status === 'paused'}}">
<text class="hint-emoji">⏸</text>
<text class="hint-text paused">已暂停</text>
</view>
<view class="hint-row completed-hint" wx:elif="{{status === 'completed'}}">
<text class="hint-emoji completed-bounce">🎉</text>
<text class="hint-text completed">目标完成! 继续坚持!</text>
</view>
</view>
<!-- 控制按钮 -->
<view class="controls">
<button
wx:if="{{!isRunning && !isPaused}}"
class="ctrl-btn start-btn"
bindtap="onStart"
>
<text class="ctrl-icon">▶</text>
<text>开始</text>
</button>
<view class="ctrl-row" wx:if="{{isRunning || isPaused}}">
<button
wx:if="{{!isPaused}}"
class="ctrl-btn pause-btn"
bindtap="onPause"
>
<text class="ctrl-icon">⏸</text>
<text>暂停</text>
</button>
<button
wx:if="{{isPaused}}"
class="ctrl-btn resume-btn"
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>
</view>
</view>
</view>
+245
View File
@@ -0,0 +1,245 @@
.timer-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
padding-top: 80rpx;
padding-bottom: calc(170rpx + env(safe-area-inset-bottom));
min-height: 100vh;
box-sizing: border-box;
}
/* ---- ring wrapper ---- */
.ring-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
/* breathing guide rings — three concentric layers */
.breathe-ring {
position: absolute;
border-radius: 50%;
border: 3rpx solid rgba(var(--primary-rgb), 0.15);
animation: breathePulse 3.5s ease-in-out infinite;
pointer-events: none;
z-index: 0;
}
.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.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 {
position: absolute;
width: 520rpx;
height: 520rpx;
pointer-events: none;
z-index: 2;
}
.spark {
position: absolute;
font-size: 36rpx;
animation: sparkBurst 1s ease-out forwards;
}
.s1 { top: -10rpx; left: 50%; margin-left: -18rpx; animation-delay: 0s; }
.s2 { top: 30rpx; right: -10rpx; animation-delay: 0.1s; }
.s3 { bottom: -10rpx; right: 50rpx; animation-delay: 0.2s; }
.s4 { bottom: 20rpx; left: 30rpx; animation-delay: 0.15s; }
.s5 { top: 50%; right: -20rpx; animation-delay: 0.25s; }
.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);
}
}
/* overtime badge */
.overtime-badge {
position: absolute;
bottom: 10rpx;
left: 50%;
transform: translateX(-50%);
background: rgba(76, 175, 80, 0.12);
border: 2rpx solid rgba(76, 175, 80, 0.3);
color: var(--success);
padding: 8rpx 24rpx;
border-radius: 24rpx;
font-size: 28rpx;
font-weight: 600;
display: flex;
align-items: center;
gap: 6rpx;
animation: popIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.overtime-icon {
font-size: 28rpx;
line-height: 1;
}
/* ---- hint section ---- */
.hint-section {
margin-top: 36rpx;
text-align: center;
}
.hint-row {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10rpx;
}
.hint-emoji {
font-size: 28rpx;
line-height: 1;
}
.running-pulse {
animation: float 1.5s ease-in-out infinite;
}
.completed-bounce {
animation: bounceSoft 0.6s ease infinite;
}
.hint-text {
font-size: 28rpx;
color: var(--text-secondary);
line-height: 1.3;
}
.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;
}
.completed-hint {
animation: popIn 0.5s ease;
}
/* ---- controls ---- */
.controls {
margin-top: 60rpx;
width: 100%;
display: flex;
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;
}
+59
View File
@@ -0,0 +1,59 @@
{
"description": "平板支撑训练小程序",
"packOptions": {
"ignore": [],
"include": []
},
"setting": {
"bundle": false,
"userConfirmedBundleSwitch": false,
"urlCheck": true,
"scopeDataCheck": false,
"coverView": true,
"es6": true,
"postcss": true,
"compileHotReLoad": false,
"lazyloadPlaceholderEnable": false,
"preloadBackgroundData": false,
"minified": true,
"autoAudits": false,
"newFeature": false,
"uglifyFileName": false,
"uploadWithSourceMap": true,
"useIsolateContext": true,
"nodeModules": false,
"enhance": true,
"useMultiFrameRuntime": true,
"useApiHook": true,
"useApiHostProcess": true,
"showShadowRootInWxmlPanel": true,
"packNpmManually": false,
"enableEngineNative": false,
"packNpmRelationList": [],
"minifyWXSS": true,
"showES6CompileOption": false,
"minifyWXML": true,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
},
"condition": false,
"compileWorklet": false,
"localPlugins": false,
"disableUseStrict": false,
"useCompilerPlugins": false,
"swc": false,
"disableSWC": true
},
"compileType": "miniprogram",
"libVersion": "3.3.4",
"appid": "wxb7f1bc86924869bc",
"projectname": "pbzc",
"condition": {},
"editorSetting": {
"tabIndent": "insertSpaces",
"tabSize": 2
},
"simulatorPluginLibVersion": {}
}
+23
View File
@@ -0,0 +1,23 @@
{
"libVersion": "3.16.0",
"projectname": "wx_pbzc",
"condition": {},
"setting": {
"urlCheck": true,
"coverView": true,
"lazyloadPlaceholderEnable": false,
"skylineRenderEnable": false,
"preloadBackgroundData": false,
"autoAudits": false,
"useApiHook": true,
"showShadowRootInWxmlPanel": true,
"useStaticServer": false,
"useLanDebug": false,
"showES6CompileOption": false,
"compileHotReLoad": true,
"checkInvalidKey": true,
"ignoreDevUnusedFiles": true,
"bigPackageSizeSupport": false,
"useIsolateContext": true
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"rules": [
{
"action": "allow",
"page": "*"
}
]
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+73
View File
@@ -0,0 +1,73 @@
var plans = {
beginner: {
id: 'beginner',
name: '初级 (7天)',
totalDays: 7,
description: '适合初学者,从30秒起步',
days: [
{ day: 1, target: 30 },
{ day: 2, target: 40 },
{ day: 3, target: 50 },
{ day: 4, target: 60 },
{ day: 5, target: 70 },
{ day: 6, target: 80 },
{ day: 7, target: 90 }
]
},
intermediate: {
id: 'intermediate',
name: '中级 (14天)',
totalDays: 14,
description: '有一定基础,从60秒起步',
days: [
{ day: 1, target: 60 }, { day: 2, target: 60 },
{ day: 3, target: 75 }, { day: 4, target: 75 },
{ day: 5, target: 90 }, { day: 6, target: 90 },
{ day: 7, target: 105 }, { day: 8, target: 105 },
{ day: 9, target: 120 }, { day: 10, target: 120 },
{ day: 11, target: 135 }, { day: 12, target: 135 },
{ day: 13, target: 150 }, { day: 14, target: 150 }
]
},
advanced: {
id: 'advanced',
name: '高级 (30天)',
totalDays: 30,
description: '挑战自我,从90秒起步',
days: (function () {
var arr = []
for (var i = 1; i <= 30; i++) {
arr.push({ day: i, target: 90 + Math.floor((i - 1) / 3) * 15 })
}
return arr
})()
}
}
function getPlan(planId) {
return plans[planId] || plans.beginner
}
function getTodayTarget(planId, currentDay) {
var plan = getPlan(planId)
for (var i = 0; i < plan.days.length; i++) {
if (plan.days[i].day === currentDay) return plan.days[i].target
}
return plan.days[0].target
}
function getPlanDay(planId, records) {
var plan = getPlan(planId)
var now = new Date()
var today = now.getFullYear() + '-' +
('0' + (now.getMonth() + 1)).slice(-2) + '-' +
('0' + now.getDate()).slice(-2)
var dates = {}
records.forEach(function (r) {
if (r.date !== today) dates[r.date] = true
})
var trainedDays = Object.keys(dates).length
return Math.min(trainedDays + 1, plan.totalDays)
}
module.exports = { plans: plans, getPlan: getPlan, getTodayTarget: getTodayTarget, getPlanDay: getPlanDay }
+132
View File
@@ -0,0 +1,132 @@
var RECORDS_KEY = 'training_records'
var SETTINGS_KEY = 'user_settings'
var STREAK_KEY = 'current_streak'
function getRecords() {
return wx.getStorageSync(RECORDS_KEY) || {}
}
function saveRecord(record) {
record.id = Date.now()
var records = getRecords()
var month = record.date.substring(0, 7)
if (!records[month]) records[month] = []
records[month].push(record)
records[month].sort(function (a, b) { return b.date.localeCompare(a.date) })
wx.setStorageSync(RECORDS_KEY, records)
}
function deleteRecord(recordId) {
var records = getRecords()
Object.keys(records).forEach(function (month) {
records[month] = records[month].filter(function (r) { return r.id !== recordId })
if (records[month].length === 0) delete records[month]
})
wx.setStorageSync(RECORDS_KEY, records)
}
function getRecordsByMonth(month) {
var records = getRecords()
return records[month] || []
}
function getTotalStats() {
var records = getRecords()
var totalDuration = 0
var totalSessions = 0
var maxDuration = 0
Object.keys(records).forEach(function (key) {
var list = records[key]
list.forEach(function (r) {
totalDuration += r.duration
totalSessions++
if (r.duration > maxDuration) maxDuration = r.duration
})
})
return { totalDuration: totalDuration, totalSessions: totalSessions, maxDuration: maxDuration }
}
function getSettings() {
return wx.getStorageSync(SETTINGS_KEY) || {
planId: 'beginner',
dailyReminder: true,
reminderTime: '08:00',
voiceGuide: true,
vibrate: true
}
}
function saveSettings(settings) {
wx.setStorageSync(SETTINGS_KEY, settings)
}
function getStreak() {
return wx.getStorageSync(STREAK_KEY) || { count: 0, lastDate: '' }
}
function updateStreak(date) {
var streak = getStreak()
var today = date || getToday()
var yesterday = getDateOffset(today, -1)
if (streak.lastDate === today) return streak
if (streak.lastDate === yesterday) {
streak.count += 1
} else {
streak.count = 1
}
streak.lastDate = today
wx.setStorageSync(STREAK_KEY, streak)
return streak
}
function getToday() {
return formatDate(new Date())
}
function formatDate(date) {
var y = date.getFullYear()
var m = String(date.getMonth() + 1)
if (m.length < 2) m = '0' + m
var d = String(date.getDate())
if (d.length < 2) d = '0' + d
return y + '-' + m + '-' + d
}
function getDateOffset(dateStr, offset) {
var d = new Date(dateStr)
d.setDate(d.getDate() + offset)
return formatDate(d)
}
function getTodayRecord() {
var today = getToday()
var month = today.substring(0, 7)
var records = getRecordsByMonth(month)
var totalDuration = 0
var hasRecord = false
for (var i = 0; i < records.length; i++) {
if (records[i].date === today) {
totalDuration += records[i].duration
hasRecord = true
}
}
return hasRecord ? { date: today, duration: totalDuration } : null
}
module.exports = {
getRecords: getRecords,
saveRecord: saveRecord,
deleteRecord: deleteRecord,
getRecordsByMonth: getRecordsByMonth,
getTotalStats: getTotalStats,
getSettings: getSettings,
saveSettings: saveSettings,
getStreak: getStreak,
updateStreak: updateStreak,
getToday: getToday,
formatDate: formatDate,
getDateOffset: getDateOffset,
getTodayRecord: getTodayRecord
}
+103
View File
@@ -0,0 +1,103 @@
var THEMES = [
{
id: 'orange',
name: '活力橙',
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryBg: '#FFF3ED',
primaryRgb: '255,107,53'
},
{
id: 'blue',
name: '深海蓝',
primary: '#2979FF',
primaryLight: '#5C9CFF',
primaryBg: '#EDF4FF',
primaryRgb: '41,121,255'
},
{
id: 'green',
name: '森林绿',
primary: '#43A047',
primaryLight: '#6EC072',
primaryBg: '#EDF7EE',
primaryRgb: '67,160,71'
},
{
id: 'purple',
name: '优雅紫',
primary: '#7E57C2',
primaryLight: '#A08AD6',
primaryBg: '#F5F0FA',
primaryRgb: '126,87,194'
},
{
id: 'pink',
name: '樱花粉',
primary: '#EC407A',
primaryLight: '#F06A9A',
primaryBg: '#FDEEF3',
primaryRgb: '236,64,122'
}
]
var DEFAULT_THEME_ID = 'orange'
var THEME_KEY = 'app_theme'
var _lastNavColor = ''
var _navColorTimer = null
function getThemeById(id) {
return THEMES.find(function (t) { return t.id === id }) || THEMES[0]
}
function getCurrentTheme() {
var id = wx.getStorageSync(THEME_KEY) || DEFAULT_THEME_ID
return getThemeById(id)
}
function _setNavBarColor(primary) {
if (_lastNavColor === primary) return
_lastNavColor = primary
if (_navColorTimer) clearTimeout(_navColorTimer)
_navColorTimer = setTimeout(function () {
try {
wx.setNavigationBarColor({
frontColor: '#ffffff',
backgroundColor: primary
})
} catch (e) {}
}, 50)
}
function setTheme(id) {
wx.setStorageSync(THEME_KEY, id)
var theme = getThemeById(id)
_setNavBarColor(theme.primary)
var app = getApp()
if (app && app.globalData) app.globalData.theme = theme
return theme
}
function applyThemeToPage(self) {
var theme = getCurrentTheme()
self.setData({
theme: theme,
themeStyle: '--primary:' + theme.primary + ';--primary-light:' + theme.primaryLight + ';--primary-bg:' + theme.primaryBg + ';--primary-rgb:' + theme.primaryRgb + ';'
})
_setNavBarColor(theme.primary)
}
function getThemeStyle(theme) {
var t = theme || getCurrentTheme()
return '--primary:' + t.primary + ';--primary-light:' + t.primaryLight + ';--primary-bg:' + t.primaryBg + ';--primary-rgb:' + t.primaryRgb + ';'
}
module.exports = {
THEMES: THEMES,
DEFAULT_THEME_ID: DEFAULT_THEME_ID,
getThemeById: getThemeById,
getCurrentTheme: getCurrentTheme,
setTheme: setTheme,
applyThemeToPage: applyThemeToPage,
getThemeStyle: getThemeStyle
}
+92
View File
@@ -0,0 +1,92 @@
function Timer(options) {
options = options || {}
this.onTick = options.onTick || function () {}
this.onComplete = options.onComplete || function () {}
this._duration = 0
this._remaining = 0
this._elapsed = 0
this._startTime = 0
this._pausedTime = 0
this._intervalId = null
this._running = false
this._paused = false
this._completed = false
}
Timer.prototype.start = function (duration) {
this._duration = duration
this._remaining = duration
this._elapsed = 0
this._completed = false
this._running = true
this._paused = false
this._startTime = Date.now()
this._tick()
var self = this
this._intervalId = setInterval(function () { self._tick() }, 1000)
}
Timer.prototype.pause = function () {
if (!this._running || this._paused) return
this._paused = true
this._pausedTime = Date.now()
clearInterval(this._intervalId)
this._intervalId = null
}
Timer.prototype.resume = function () {
if (!this._running || !this._paused) return
this._startTime += Date.now() - this._pausedTime
this._paused = false
this._tick()
var self = this
this._intervalId = setInterval(function () { self._tick() }, 1000)
}
Timer.prototype.stop = function () {
this._running = false
this._paused = false
clearInterval(this._intervalId)
this._intervalId = null
return this._elapsed
}
Timer.prototype._tick = function () {
if (!this._running || this._paused) return
this._elapsed = Math.floor((Date.now() - this._startTime) / 1000)
this._remaining = Math.max(0, this._duration - this._elapsed)
this.onTick({
remaining: this._remaining,
elapsed: this._elapsed,
duration: this._duration
})
if (this._remaining <= 0 && !this._completed) {
this._completed = true
this.onComplete({ elapsed: this._elapsed, duration: this._duration })
}
}
Object.defineProperty(Timer.prototype, 'isRunning', {
get: function () { return this._running && !this._paused }
})
Object.defineProperty(Timer.prototype, 'isPaused', {
get: function () { return this._paused }
})
Object.defineProperty(Timer.prototype, 'isCompleted', {
get: function () { return this._completed }
})
Object.defineProperty(Timer.prototype, 'remaining', {
get: function () { return this._remaining }
})
Object.defineProperty(Timer.prototype, 'elapsed', {
get: function () { return this._elapsed }
})
module.exports = Timer
+44
View File
@@ -0,0 +1,44 @@
function formatTime(seconds) {
var m = Math.floor(seconds / 60)
var s = seconds % 60
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s
}
function formatDuration(seconds) {
if (seconds < 60) return seconds + '秒'
var m = Math.floor(seconds / 60)
var s = seconds % 60
return s > 0 ? m + '分' + s + '秒' : m + '分钟'
}
function getDaysInMonth(year, month) {
return new Date(year, month, 0).getDate()
}
function getMonthCalendar(year, month) {
var firstDay = new Date(year, month - 1, 1)
var lastDay = new Date(year, month, 0)
var daysInMonth = lastDay.getDate()
var startDayOfWeek = firstDay.getDay()
var weeks = []
var week = [null, null, null, null, null, null, null]
for (var d = 1; d <= daysInMonth; d++) {
var dow = (startDayOfWeek + d - 1) % 7
week[dow] = d
if (dow === 6 || d === daysInMonth) {
weeks.push(week.slice())
week = [null, null, null, null, null, null, null]
}
}
return weeks
}
module.exports = {
formatTime: formatTime,
formatDuration: formatDuration,
getDaysInMonth: getDaysInMonth,
getMonthCalendar: getMonthCalendar
}