commit ff2700c067b4fc09101ccb06b8942108823cbe94 Author: cnliucheng Date: Thu Jun 4 16:25:43 2026 +0800 backup: snapshot before optimization diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 0000000..9de0f16 --- /dev/null +++ b/.codegraph/.gitignore @@ -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 diff --git a/.codegraph/daemon.pid b/.codegraph/daemon.pid new file mode 100644 index 0000000..cfc98ed --- /dev/null +++ b/.codegraph/daemon.pid @@ -0,0 +1,6 @@ +{ + "pid": 75366, + "version": "0.9.7", + "socketPath": "/Users/liubleed/Documents/wx_pbzc/.codegraph/daemon.sock", + "startedAt": 1780561314477 +} diff --git a/app.js b/app.js new file mode 100644 index 0000000..d90bc41 --- /dev/null +++ b/app.js @@ -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 + } +}) diff --git a/app.json b/app.json new file mode 100644 index 0000000..2dc0ac8 --- /dev/null +++ b/app.json @@ -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" +} diff --git a/app.wxss b/app.wxss new file mode 100644 index 0000000..ad3b416 --- /dev/null +++ b/app.wxss @@ -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; +} diff --git a/components/calendar-heatmap/calendar-heatmap.js b/components/calendar-heatmap/calendar-heatmap.js new file mode 100644 index 0000000..350df6a --- /dev/null +++ b/components/calendar-heatmap/calendar-heatmap.js @@ -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' + } + } +}) diff --git a/components/calendar-heatmap/calendar-heatmap.json b/components/calendar-heatmap/calendar-heatmap.json new file mode 100644 index 0000000..a89ef4d --- /dev/null +++ b/components/calendar-heatmap/calendar-heatmap.json @@ -0,0 +1,4 @@ +{ + "component": true, + "usingComponents": {} +} diff --git a/components/calendar-heatmap/calendar-heatmap.wxml b/components/calendar-heatmap/calendar-heatmap.wxml new file mode 100644 index 0000000..5e9d114 --- /dev/null +++ b/components/calendar-heatmap/calendar-heatmap.wxml @@ -0,0 +1,31 @@ + + + {{year}}年{{month}}月 + + {{item}} + + + + + + {{cell.day}} + + + + + + + + + + + + diff --git a/components/calendar-heatmap/calendar-heatmap.wxss b/components/calendar-heatmap/calendar-heatmap.wxss new file mode 100644 index 0000000..484da29 --- /dev/null +++ b/components/calendar-heatmap/calendar-heatmap.wxss @@ -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; +} diff --git a/components/progress-ring/progress-ring.js b/components/progress-ring/progress-ring.js new file mode 100644 index 0000000..db48536 --- /dev/null +++ b/components/progress-ring/progress-ring.js @@ -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() + } + } + } +}) diff --git a/components/progress-ring/progress-ring.json b/components/progress-ring/progress-ring.json new file mode 100644 index 0000000..a89ef4d --- /dev/null +++ b/components/progress-ring/progress-ring.json @@ -0,0 +1,4 @@ +{ + "component": true, + "usingComponents": {} +} diff --git a/components/progress-ring/progress-ring.wxml b/components/progress-ring/progress-ring.wxml new file mode 100644 index 0000000..4d9d036 --- /dev/null +++ b/components/progress-ring/progress-ring.wxml @@ -0,0 +1,7 @@ + + + + {{displayTime}} + {{statusText}} + + diff --git a/components/progress-ring/progress-ring.wxss b/components/progress-ring/progress-ring.wxss new file mode 100644 index 0000000..cf761e5 --- /dev/null +++ b/components/progress-ring/progress-ring.wxss @@ -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; +} diff --git a/custom-tab-bar/index.js b/custom-tab-bar/index.js new file mode 100644 index 0000000..7e8ec76 --- /dev/null +++ b/custom-tab-bar/index.js @@ -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] }) + } + } +}) diff --git a/custom-tab-bar/index.json b/custom-tab-bar/index.json new file mode 100644 index 0000000..a89ef4d --- /dev/null +++ b/custom-tab-bar/index.json @@ -0,0 +1,4 @@ +{ + "component": true, + "usingComponents": {} +} diff --git a/custom-tab-bar/index.wxml b/custom-tab-bar/index.wxml new file mode 100644 index 0000000..a727751 --- /dev/null +++ b/custom-tab-bar/index.wxml @@ -0,0 +1,44 @@ + + + + + + + + + 训练 + + + + + + + + + + + 记录 + + + + + + + + + + 设置 + + diff --git a/custom-tab-bar/index.wxss b/custom-tab-bar/index.wxss new file mode 100644 index 0000000..ebcd093 --- /dev/null +++ b/custom-tab-bar/index.wxss @@ -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); +} diff --git a/pages/index/index.js b/pages/index/index.js new file mode 100644 index 0000000..d5e2a7a --- /dev/null +++ b/pages/index/index.js @@ -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 () {} +}) diff --git a/pages/index/index.json b/pages/index/index.json new file mode 100644 index 0000000..2d8aa85 --- /dev/null +++ b/pages/index/index.json @@ -0,0 +1,4 @@ +{ + "usingComponents": {}, + "navigationBarTitleText": "平板支撑训练" +} diff --git a/pages/index/index.wxml b/pages/index/index.wxml new file mode 100644 index 0000000..f60a77f --- /dev/null +++ b/pages/index/index.wxml @@ -0,0 +1,113 @@ + + + + + + {{todayDone ? '💪' : '👋'}} + {{todayDone ? '太棒了!' : '准备好了吗?'}} + + {{todayDone ? '继续保持这个势头' : '今天的平板支撑等着你'}} + + + + + + 🔥 + {{streakCount}} + 连续打卡 + + + + + 📋 + {{planName}} + + + + + + + + 第 {{planDay}} / {{planTotal}} 天 + + + + + + + + 🎯 + 今日目标 + + + + {{todayTargetText}} + + 平板支撑 + + + + + + 今日已完成 {{todayDuration}} 秒 + + + + ⏳ 还未开始训练 + + + + + + + + + + + + + + 💡 + 保持身体成一条直线,核心收紧,均匀呼吸 + + + + + + + ⏱ 选择训练时长 + + + {{item}} + + + + + 自定义: + + + + + 取消 + + diff --git a/pages/index/index.wxss b/pages/index/index.wxss new file mode 100644 index 0000000..7bc23f0 --- /dev/null +++ b/pages/index/index.wxss @@ -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; +} diff --git a/pages/records/records.js b/pages/records/records.js new file mode 100644 index 0000000..e7a104e --- /dev/null +++ b/pages/records/records.js @@ -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 }) + } + } + }) + } +}) diff --git a/pages/records/records.json b/pages/records/records.json new file mode 100644 index 0000000..6bfb9b6 --- /dev/null +++ b/pages/records/records.json @@ -0,0 +1,6 @@ +{ + "usingComponents": { + "calendar-heatmap": "../../components/calendar-heatmap/calendar-heatmap" + }, + "navigationBarTitleText": "训练记录" +} diff --git a/pages/records/records.wxml b/pages/records/records.wxml new file mode 100644 index 0000000..e028fe4 --- /dev/null +++ b/pages/records/records.wxml @@ -0,0 +1,91 @@ + + + + + 📊 + {{totalSessions}}次 + 训练次数 + + + + {{totalDurationText}} + 累计时长 + + + 🏆 + {{maxDurationText}} + 最长记录 + + + + + + + + + 🗓 + {{currentYear}}年 {{currentMonth}}月 + + + + + + + + + + 📋 最近记录 + + + 🏃 + 还没有训练记录 + 开始你的第一次平板支撑吧 + + + + + {{item.duration >= 120 ? '🔥' : item.duration >= 60 ? '💪' : '✅'}} + + + {{item.date}} + 第 {{item.day}} 天 + + + {{item.duration}}s + + + + + + + + + + 📅 + {{dayDetail.date}} + + + + {{dayDetail.sessions}}次 + 训练次数 + + + {{dayDetail.durationText}} + 总时长 + + + {{dayDetail.calories}} + 约消耗 (千卡) + + + + 关闭 + + + + diff --git a/pages/records/records.wxss b/pages/records/records.wxss new file mode 100644 index 0000000..14d10cf --- /dev/null +++ b/pages/records/records.wxss @@ -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; +} diff --git a/pages/settings/settings.js b/pages/settings/settings.js new file mode 100644 index 0000000..8b9f348 --- /dev/null +++ b/pages/settings/settings.js @@ -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 }) + } + }) + } +}) diff --git a/pages/settings/settings.json b/pages/settings/settings.json new file mode 100644 index 0000000..d935131 --- /dev/null +++ b/pages/settings/settings.json @@ -0,0 +1,4 @@ +{ + "usingComponents": {}, + "navigationBarTitleText": "设置" +} diff --git a/pages/settings/settings.wxml b/pages/settings/settings.wxml new file mode 100644 index 0000000..990f179 --- /dev/null +++ b/pages/settings/settings.wxml @@ -0,0 +1,115 @@ + + + + + 🎨 + 配色方案 + + + + + {{item.name}} + + + + + + + + + 📋 + 训练计划 + + + + + + + + {{item.name}} + {{item.desc}} + + + + + + + + + + 🔔 + 提醒 + + + + 📅 + 每日提醒 + + + + + + 🕐 + 提醒时间 + + + {{reminderTime}} + + + + + + + + + 训练偏好 + + + + 🔊 + 语音引导 + + + + + + 📳 + 振动反馈 + + + + + + + + + + 关于 + + + + 版本 + v1.0.0 + + + 开发者 + 刘承 + + + + Made with ❤️ for better fitness + + + diff --git a/pages/settings/settings.wxss b/pages/settings/settings.wxss new file mode 100644 index 0000000..b5518bd --- /dev/null +++ b/pages/settings/settings.wxss @@ -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; +} diff --git a/pages/timer/timer.js b/pages/timer/timer.js new file mode 100644 index 0000000..704b693 --- /dev/null +++ b/pages/timer/timer.js @@ -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) + } +}) diff --git a/pages/timer/timer.json b/pages/timer/timer.json new file mode 100644 index 0000000..f5d17c4 --- /dev/null +++ b/pages/timer/timer.json @@ -0,0 +1,6 @@ +{ + "usingComponents": { + "progress-ring": "../../components/progress-ring/progress-ring" + }, + "navigationBarTitleText": "训练中" +} diff --git a/pages/timer/timer.wxml b/pages/timer/timer.wxml new file mode 100644 index 0000000..0d7a8e3 --- /dev/null +++ b/pages/timer/timer.wxml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + 💫 + 🌟 + + 💪 + + + + 🔥 + +{{overtime}}s + + + + + + + 🧘 + + 自由训练 · 目标 {{duration}} 秒 + 今日目标 {{duration}} 秒 · 第 {{planDay}} 天 + + + + 💪 + 保持姿势,核心收紧! + + + + 已暂停 + + + 🎉 + 目标完成! 继续坚持! + + + + + + + + + + + + + + diff --git a/pages/timer/timer.wxss b/pages/timer/timer.wxss new file mode 100644 index 0000000..97828a1 --- /dev/null +++ b/pages/timer/timer.wxss @@ -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; +} diff --git a/project.config.json b/project.config.json new file mode 100644 index 0000000..1922dca --- /dev/null +++ b/project.config.json @@ -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": {} +} \ No newline at end of file diff --git a/project.private.config.json b/project.private.config.json new file mode 100644 index 0000000..d1dc72f --- /dev/null +++ b/project.private.config.json @@ -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 + } +} \ No newline at end of file diff --git a/sitemap.json b/sitemap.json new file mode 100644 index 0000000..1de189d --- /dev/null +++ b/sitemap.json @@ -0,0 +1,8 @@ +{ + "rules": [ + { + "action": "allow", + "page": "*" + } + ] +} diff --git a/static/icons/home-active.png b/static/icons/home-active.png new file mode 100644 index 0000000..08cd6f2 Binary files /dev/null and b/static/icons/home-active.png differ diff --git a/static/icons/home.png b/static/icons/home.png new file mode 100644 index 0000000..08cd6f2 Binary files /dev/null and b/static/icons/home.png differ diff --git a/static/icons/records-active.png b/static/icons/records-active.png new file mode 100644 index 0000000..08cd6f2 Binary files /dev/null and b/static/icons/records-active.png differ diff --git a/static/icons/records.png b/static/icons/records.png new file mode 100644 index 0000000..08cd6f2 Binary files /dev/null and b/static/icons/records.png differ diff --git a/static/icons/settings-active.png b/static/icons/settings-active.png new file mode 100644 index 0000000..08cd6f2 Binary files /dev/null and b/static/icons/settings-active.png differ diff --git a/static/icons/settings.png b/static/icons/settings.png new file mode 100644 index 0000000..08cd6f2 Binary files /dev/null and b/static/icons/settings.png differ diff --git a/utils/plan.js b/utils/plan.js new file mode 100644 index 0000000..271edd9 --- /dev/null +++ b/utils/plan.js @@ -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 } diff --git a/utils/storage.js b/utils/storage.js new file mode 100644 index 0000000..1665e9c --- /dev/null +++ b/utils/storage.js @@ -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 +} diff --git a/utils/theme.js b/utils/theme.js new file mode 100644 index 0000000..1182a23 --- /dev/null +++ b/utils/theme.js @@ -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 +} diff --git a/utils/timer.js b/utils/timer.js new file mode 100644 index 0000000..f69cb0e --- /dev/null +++ b/utils/timer.js @@ -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 diff --git a/utils/util.js b/utils/util.js new file mode 100644 index 0000000..d0ec503 --- /dev/null +++ b/utils/util.js @@ -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 +}