feat: v1.5 — plan editor, voice prompts, UI polish
Major features: - Training plan editor: edit preset days/duration per plan (in-place override via custom_plans storage; preset ids preserved so existing records stay valid) - Voice prompts at 4 fixed points during training (halfway / 30s / 10s / done) via Tencent Cloud TTS (cloudfunctions/tts + utils/voice.js) - Free-training button: switched from outline (transparent) to ghost variant (theme-tinted) for visual weight Bug fixes: - 4 functional pages: SVG data URIs failed to render in WeChat because '\#' in colors was parsed as a data-URI fragment delimiter. encode the whole SVG via encodeURIComponent in utils/icons.js build(). - Old records (saved before id field was added) could not be deleted (data-id was empty, triggered defensive guard). Backfill stable legacy-<month>-<index>-<duration> ids in getRecords() and persist. - settings.js plan-row editor button event was bubbling up to the row's bindtap (which also fired onSelectPlan). Wrapped in catch:tap. UI: - Settings page: 3 hardcoded plans replaced with dynamic buildPlansList that overlays custom_plans on top of presets - Plan editor: bottom-sheet modal in settings page (regular view, not ui-modal — WeChat custom component root element drops position:fixed in this runtime) - Free-training button: rounded pill style, full-width, 24rpx gap from primary action; bottom sheet uses max-height: 88vh + internal scroll - Version bumped to v1.5, last updated 2026-06-10 Removed: - Daily reminder section (dailyReminder / reminderTime) — replaced by the 4 voice prompts which cover the same user need without requiring long-term scheduling that WeChat mini-programs can't actually do Misc: - utils/plan.js refactored to formula-driven: presets declare totalDays/startTarget/increment/cycleDays, days[] is generated. Same formula applies to custom plans. - Timer _remind() guards each prompt on minimum duration so short free-mode sessions don't fire 'last30' at the start - Cloud storage cloud_plans field added to data push payload; restored on first install via _restoreFromCloud - .gitignore added for local AI tool caches (.reasonix/, reasonix.toml, .codegraph/daemon.pid)
This commit is contained in:
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"pid": 75366,
|
||||
"version": "0.9.7",
|
||||
"socketPath": "/Users/liubleed/Documents/wx_pbzc/.codegraph/daemon.sock",
|
||||
"startedAt": 1780561314477
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# Local tool caches / daemons — never project-level
|
||||
.reasonix/
|
||||
reasonix.toml
|
||||
.codegraph/daemon.pid
|
||||
@@ -1,5 +1,6 @@
|
||||
const themeMod = require('./utils/theme')
|
||||
const cloud = require('./utils/cloud')
|
||||
const storage = require('./utils/storage')
|
||||
|
||||
App({
|
||||
onLaunch() {
|
||||
@@ -10,11 +11,14 @@ App({
|
||||
if (!settings) {
|
||||
wx.setStorageSync('user_settings', {
|
||||
planId: 'beginner',
|
||||
dailyReminder: true,
|
||||
reminderTime: '08:00',
|
||||
voiceGuide: true,
|
||||
vibrate: true
|
||||
vibrate: true,
|
||||
planStartDate: storage.getToday()
|
||||
})
|
||||
} else if (!settings.planStartDate) {
|
||||
// Migrate: add planStartDate for existing users
|
||||
settings.planStartDate = storage.getToday()
|
||||
wx.setStorageSync('user_settings', settings)
|
||||
}
|
||||
|
||||
const streak = wx.getStorageSync('current_streak')
|
||||
@@ -24,22 +28,14 @@ App({
|
||||
|
||||
this.globalData.theme = themeMod.getCurrentTheme()
|
||||
|
||||
// Sync with cloud: pull on empty local, push on empty cloud
|
||||
// Sync with cloud: pull on empty local, push on non-empty local
|
||||
const records = wx.getStorageSync('training_records')
|
||||
if (!records || Object.keys(records).length === 0) {
|
||||
// Case 1: Fresh install — restore from cloud
|
||||
cloud.pullAll().then(cloudData => this._restoreFromCloud(cloudData))
|
||||
} else {
|
||||
// Case 2: Has local data — check if cloud needs seeding
|
||||
cloud.pullAll().then(cloudData => {
|
||||
if (!cloudData) {
|
||||
// Cloud empty: push existing local data up
|
||||
// Case 2: Has local data — push to cloud (local is authoritative)
|
||||
cloud.pushAll()
|
||||
} else {
|
||||
// Both have data — trust local (latest) and re-push
|
||||
cloud.pushAll()
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -55,6 +51,9 @@ App({
|
||||
if (cloudData.streak) {
|
||||
wx.setStorageSync('current_streak', cloudData.streak)
|
||||
}
|
||||
if (cloudData.customPlans && typeof cloudData.customPlans === 'object') {
|
||||
wx.setStorageSync('custom_plans', cloudData.customPlans)
|
||||
}
|
||||
if (cloudData.themeId) {
|
||||
themeMod.setTheme(cloudData.themeId)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"pages/index/index",
|
||||
"pages/timer/timer",
|
||||
"pages/records/records",
|
||||
"pages/leaderboard/leaderboard",
|
||||
"pages/settings/settings"
|
||||
],
|
||||
"window": {
|
||||
@@ -20,6 +21,7 @@
|
||||
"list": [
|
||||
{ "pagePath": "pages/index/index", "text": "训练" },
|
||||
{ "pagePath": "pages/records/records", "text": "记录" },
|
||||
{ "pagePath": "pages/leaderboard/leaderboard", "text": "排行" },
|
||||
{ "pagePath": "pages/settings/settings", "text": "设置" }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ page {
|
||||
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
font-size: 28rpx;
|
||||
color: var(--text);
|
||||
background-color: var(--bg);
|
||||
background-color: var(--bg) !important;
|
||||
}
|
||||
|
||||
.container {
|
||||
@@ -20,79 +20,38 @@ page {
|
||||
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;
|
||||
/* Defensive sizing for all SVG icons rendered as <image>.
|
||||
* WeChat ignores CSS width/height on <image> when src is a data URI
|
||||
* unless the source SVG has explicit width/height (which it does now,
|
||||
* via utils/icons.js). This extra safety belt ensures even unstyled
|
||||
* icons can't blow out their containers. */
|
||||
image {
|
||||
max-width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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; }
|
||||
/* ---- shared animations (used across pages) ---- */
|
||||
|
||||
@keyframes cardIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(40rpx);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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); }
|
||||
@@ -106,45 +65,21 @@ page {
|
||||
70% { transform: translateY(-6rpx); }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
@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); }
|
||||
}
|
||||
|
||||
/* ---- shared section title ---- */
|
||||
.section-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
/* ---- shared modal overlay ---- */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-overlay-bottom {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-overlay-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ---- shared danger button ---- */
|
||||
.btn-danger {
|
||||
background: #fff;
|
||||
color: #E53935;
|
||||
border: 2rpx solid rgba(229, 57, 53, 0.3);
|
||||
border-radius: 48rpx;
|
||||
font-size: 28rpx;
|
||||
padding: 20rpx 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-danger::after { border: none; }
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,103 @@
|
||||
const cloud = require('wx-server-sdk')
|
||||
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
|
||||
const db = cloud.database()
|
||||
const _ = db.command
|
||||
|
||||
const COLLECTION = 'plank_data'
|
||||
const PAGE_SIZE = 100
|
||||
const MAX_RANK = 100
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
|
||||
exports.main = async (event) => {
|
||||
const { period } = event
|
||||
if (!period) return { err: 'missing period' }
|
||||
|
||||
const now = new Date()
|
||||
const today = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
||||
const thisMonth = today.substring(0, 7)
|
||||
const thisYear = String(now.getFullYear())
|
||||
const myOpenid = cloud.getWXContext().OPENID
|
||||
|
||||
let prefix, exact
|
||||
if (period === 'day') { exact = today; prefix = null }
|
||||
else if (period === 'month') { prefix = thisMonth; exact = null }
|
||||
else if (period === 'year') { prefix = thisYear; exact = null }
|
||||
else return { err: 'invalid period' }
|
||||
|
||||
const userMap = new Map()
|
||||
let skip = 0
|
||||
|
||||
while (true) {
|
||||
const res = await db.collection(COLLECTION).skip(skip).limit(PAGE_SIZE).get()
|
||||
if (!res.data || res.data.length === 0) break
|
||||
|
||||
for (const doc of res.data) {
|
||||
const records = doc.records || {}
|
||||
let duration = 0
|
||||
let sessions = 0
|
||||
|
||||
for (const monthKey of Object.keys(records)) {
|
||||
if (prefix && !monthKey.startsWith(prefix)) continue
|
||||
for (const r of records[monthKey]) {
|
||||
if (period === 'day') {
|
||||
if (r.date === exact) { duration += r.duration; sessions++ }
|
||||
} else {
|
||||
if (r.date.startsWith(prefix)) { duration += r.duration; sessions++ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (duration > 0) {
|
||||
const openid = doc._openid || 'unknown'
|
||||
const entry = userMap.get(openid)
|
||||
if (entry) {
|
||||
entry.duration += duration
|
||||
entry.sessions += sessions
|
||||
} else {
|
||||
userMap.set(openid, { openid, duration, sessions })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (res.data.length < PAGE_SIZE) break
|
||||
skip += PAGE_SIZE
|
||||
}
|
||||
|
||||
// Full sorted list (for ranking)
|
||||
const allSorted = Array.from(userMap.values())
|
||||
.sort((a, b) => b.duration - a.duration)
|
||||
|
||||
// My entry always included, even if outside top N
|
||||
const myEntry = userMap.get(myOpenid)
|
||||
const myRank = myEntry ? allSorted.findIndex(e => e.openid === myOpenid) + 1 : 0
|
||||
|
||||
// Top N for the board
|
||||
const ranked = allSorted.slice(0, MAX_RANK).map((item, i) => ({
|
||||
rank: i + 1,
|
||||
openid: item.openid,
|
||||
name: maskOpenid(item.openid),
|
||||
duration: item.duration,
|
||||
sessions: item.sessions
|
||||
}))
|
||||
|
||||
return {
|
||||
period,
|
||||
ranked,
|
||||
myOpenid,
|
||||
myEntry: myEntry ? {
|
||||
rank: myRank,
|
||||
openid: myEntry.openid,
|
||||
name: maskOpenid(myEntry.openid),
|
||||
duration: myEntry.duration,
|
||||
sessions: myEntry.sessions
|
||||
} : null,
|
||||
updatedAt: now.toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
function maskOpenid(openid) {
|
||||
if (!openid || openid === 'unknown') return '未知用户'
|
||||
if (openid.length <= 4) return '****' + openid
|
||||
return '****' + openid.slice(-4)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "leaderboard",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"wx-server-sdk": "latest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Cloud function: synthesize the 4 fixed training-voice prompts.
|
||||
//
|
||||
// We do NOT cache on the server. Two reasons:
|
||||
// 1. Caching via cloud storage requires self-constructing a fileID of
|
||||
// the form `cloud://<full-env-id>/...`, but `getWXContext().ENV`
|
||||
// only returns the simple env id. Mismatched → unopenable fileID.
|
||||
// 2. Caching via the cloud database requires `cloud.database()` at
|
||||
// top level, which on this WeChat cloud runtime crashes the SCF
|
||||
// framework's `writeRuntimeFile` step (it can't toString() the
|
||||
// error object the SDK throws).
|
||||
//
|
||||
// The client (`utils/voice.js`) caches audioUrls in memory per session,
|
||||
// so each of the 4 prompts is synthesized at most once per app launch.
|
||||
// Re-synthesizing on app restart is fine — 800万 chars/month free tier
|
||||
// covers 4 × ~30 chars × thousands of restarts per month.
|
||||
//
|
||||
// Configuration (云开发控制台 → 云函数 → tts → 函数配置 → 环境变量):
|
||||
// TTS_SECRET_ID — Tencent Cloud API key id
|
||||
// TTS_SECRET_KEY — Tencent Cloud API key
|
||||
// TTS_REGION — (optional) defaults to ap-guangzhou
|
||||
//
|
||||
// NOTE: env var names cannot start with SCF_ / QCLOUD_ / TENCENTCLOUD_ —
|
||||
// those prefixes are reserved by Tencent Cloud SCF. Use the TTS_ prefix.
|
||||
|
||||
const cloud = require('wx-server-sdk')
|
||||
cloud.init()
|
||||
|
||||
// Polished, fixed prompts.
|
||||
const PROMPTS = {
|
||||
halfway: '已完成一半啦,坚持就是胜利!',
|
||||
last30: '最后30秒,保持呼吸,稳住姿势!',
|
||||
last10: '最后10秒,再加把劲!',
|
||||
complete: '太棒了!今天的目标已完成,继续加油!'
|
||||
}
|
||||
|
||||
const CACHE_DIR = 'tts-cache'
|
||||
|
||||
const _synthesize = async (text, promptKey) => {
|
||||
let TtsClient
|
||||
try {
|
||||
TtsClient = require('tencentcloud-sdk-nodejs').tts.v20190823.Client
|
||||
} catch (e) {
|
||||
throw new Error('tencentcloud-sdk-nodejs not installed. Run `npm i tencentcloud-sdk-nodejs` in this cloud function directory.')
|
||||
}
|
||||
|
||||
const secretId = process.env.TTS_SECRET_ID
|
||||
const secretKey = process.env.TTS_SECRET_KEY
|
||||
if (!secretId || !secretKey) {
|
||||
throw new Error('TTS_SECRET_ID / TTS_SECRET_KEY not configured.')
|
||||
}
|
||||
|
||||
const client = new TtsClient({
|
||||
credential: { secretId, secretKey },
|
||||
region: process.env.TTS_REGION || 'ap-guangzhou'
|
||||
})
|
||||
|
||||
const res = await client.TextToVoice({
|
||||
Text: text,
|
||||
SessionId: `${promptKey}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
||||
VoiceType: 101001, // 智瑜,温柔女声
|
||||
Codec: 'mp3',
|
||||
SampleRate: 16000,
|
||||
Speed: 0,
|
||||
Volume: 5
|
||||
})
|
||||
return Buffer.from(res.Audio, 'base64')
|
||||
}
|
||||
|
||||
const _upload = async (key, buffer) => {
|
||||
const cloudPath = `${CACHE_DIR}/${key}.mp3`
|
||||
// uploadFile returns the canonical fileID (e.g. "cloud://env.xxx/...").
|
||||
// We must use THAT — not the relative cloudPath we passed in — when
|
||||
// calling getTempFileURL. A self-constructed fileID is invalid.
|
||||
const res = await cloud.uploadFile({ cloudPath, fileContent: buffer })
|
||||
return res.fileID
|
||||
}
|
||||
|
||||
exports.main = async (event) => {
|
||||
const { promptKey } = event || {}
|
||||
const text = PROMPTS[promptKey]
|
||||
if (!text) {
|
||||
return { success: false, error: `Unknown prompt key: ${promptKey}` }
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await _synthesize(text, promptKey)
|
||||
const newFileID = await _upload(promptKey, buffer)
|
||||
const urlRes = await cloud.getTempFileURL({ fileList: [newFileID] })
|
||||
const url = urlRes.fileList[0].tempFileURL
|
||||
if (!url) {
|
||||
return { success: false, error: 'getTempFileURL returned empty URL', text }
|
||||
}
|
||||
return { success: true, audioUrl: url, text }
|
||||
} catch (e) {
|
||||
return { success: false, error: e.message, text }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "tts",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"wx-server-sdk": "latest",
|
||||
"tencentcloud-sdk-nodejs": "^4.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,39 @@
|
||||
const util = require('../../utils/util')
|
||||
|
||||
/**
|
||||
* Generate 4 heat color shades from a primary color.
|
||||
* Returns colors from lightest to darkest.
|
||||
*/
|
||||
const generateHeatColors = (hex) => {
|
||||
if (!hex || hex.charAt(0) !== '#') {
|
||||
return ['#FFE0D0', '#FFB088', '#FF6B35', '#E05520']
|
||||
}
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
|
||||
return [
|
||||
`rgba(${r},${g},${b},0.15)`, // lightest
|
||||
`rgba(${r},${g},${b},0.35)`, // light
|
||||
`rgba(${r},${g},${b},0.65)`, // medium
|
||||
`rgba(${r},${g},${b},0.95)` // darkest
|
||||
]
|
||||
}
|
||||
|
||||
const getHeatColor = (duration, colors) => {
|
||||
if (!duration || duration <= 0) return 'transparent'
|
||||
if (duration < 30) return colors[0]
|
||||
if (duration < 60) return colors[1]
|
||||
if (duration < 120) return colors[2]
|
||||
return colors[3]
|
||||
}
|
||||
|
||||
Component({
|
||||
properties: {
|
||||
year: { type: Number, value: new Date().getFullYear() },
|
||||
month: { type: Number, value: new Date().getMonth() + 1 },
|
||||
records: { type: Array, value: [] }
|
||||
records: { type: Array, value: [] },
|
||||
primaryColor: { type: String, value: '#FF6B35' }
|
||||
},
|
||||
|
||||
data: {
|
||||
@@ -12,20 +41,20 @@ Component({
|
||||
},
|
||||
|
||||
observers: {
|
||||
'year, month, records'(year, month, records) {
|
||||
this._compute(year, month, records)
|
||||
'year, month, records, primaryColor'(year, month, records, primaryColor) {
|
||||
this._compute(year, month, records, primaryColor)
|
||||
}
|
||||
},
|
||||
|
||||
lifetimes: {
|
||||
ready() {
|
||||
const { year, month, records } = this.properties
|
||||
this._compute(year, month, records)
|
||||
const { year, month, records, primaryColor } = this.properties
|
||||
this._compute(year, month, records, primaryColor)
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
_compute(year, month, records) {
|
||||
_compute(year, month, records, primaryColor) {
|
||||
const weeks = util.getMonthCalendar(year, month)
|
||||
const recordMap = {}
|
||||
if (records && records.length) {
|
||||
@@ -35,18 +64,13 @@ Component({
|
||||
})
|
||||
}
|
||||
|
||||
const heatColors = generateHeatColors(primaryColor || '#FF6B35')
|
||||
|
||||
const data = weeks.map((week) =>
|
||||
week.map((day) => {
|
||||
if (!day) return null
|
||||
const duration = recordMap[day] || 0
|
||||
let 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, duration, color }
|
||||
return { day, duration, color: getHeatColor(duration, heatColors) }
|
||||
})
|
||||
)
|
||||
|
||||
@@ -64,15 +88,6 @@ Component({
|
||||
day,
|
||||
duration: cell.duration
|
||||
})
|
||||
},
|
||||
|
||||
getHeatColor(cell) {
|
||||
if (!cell || cell.duration <= 0) return 'transparent'
|
||||
const d = cell.duration
|
||||
if (d < 30) return '#FFE0D0'
|
||||
if (d < 60) return '#FFB088'
|
||||
if (d < 120) return '#FF6B35'
|
||||
return '#E05520'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -21,10 +21,10 @@
|
||||
</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>
|
||||
<view class="legend-block" style="background:{{primaryColor}}; opacity: 0.15;"></view>
|
||||
<view class="legend-block" style="background:{{primaryColor}}; opacity: 0.35;"></view>
|
||||
<view class="legend-block" style="background:{{primaryColor}}; opacity: 0.65;"></view>
|
||||
<view class="legend-block" style="background:{{primaryColor}}; opacity: 0.95;"></view>
|
||||
<text class="legend-label">多</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
Component({
|
||||
properties: {
|
||||
text: { type: String, value: '' },
|
||||
variant: { type: String, value: 'primary' }, // primary | outline | danger | ghost
|
||||
size: { type: String, value: 'md' }, // sm | md | lg | xl
|
||||
block: { type: Boolean, value: false },
|
||||
disabled: { type: Boolean, value: false },
|
||||
iconSrc: { type: String, value: '' },
|
||||
customStyle: { type: String, value: '' }
|
||||
},
|
||||
methods: {
|
||||
onTap() {
|
||||
this.triggerEvent('tap')
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<button
|
||||
class="ui-btn ui-btn--{{variant}} ui-btn--{{size}} {{block ? 'ui-btn--block' : ''}} {{disabled ? 'is-disabled' : ''}}"
|
||||
style="{{customStyle}}"
|
||||
bindtap="onTap"
|
||||
disabled="{{disabled}}"
|
||||
hover-class="none"
|
||||
>
|
||||
<image
|
||||
wx:if="{{iconSrc}}"
|
||||
class="ui-btn__icon"
|
||||
src="{{iconSrc}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<text class="ui-btn__text">{{text}}</text>
|
||||
</button>
|
||||
@@ -0,0 +1,92 @@
|
||||
.ui-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
border: none;
|
||||
padding: 0 32rpx;
|
||||
font-weight: 600;
|
||||
border-radius: 48rpx;
|
||||
line-height: 1;
|
||||
box-sizing: border-box;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.ui-btn::after { border: none; }
|
||||
|
||||
.ui-btn:active:not(.is-disabled) {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.ui-btn--block {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
.ui-btn--sm { height: 64rpx; font-size: 26rpx; padding: 0 24rpx; }
|
||||
.ui-btn--md { height: 80rpx; font-size: 30rpx; }
|
||||
.ui-btn--lg { height: 96rpx; font-size: 34rpx; padding: 0 40rpx; }
|
||||
.ui-btn--xl { height: 108rpx; font-size: 36rpx; padding: 0 48rpx; }
|
||||
|
||||
/* Variants */
|
||||
.ui-btn--primary {
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-light));
|
||||
color: #FFFFFF;
|
||||
box-shadow: 0 8rpx 24rpx rgba(var(--primary-rgb), 0.3);
|
||||
}
|
||||
|
||||
.ui-btn--primary:active:not(.is-disabled) {
|
||||
box-shadow: 0 4rpx 12rpx rgba(var(--primary-rgb), 0.2);
|
||||
}
|
||||
|
||||
.ui-btn--outline {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 2rpx solid rgba(var(--primary-rgb), 0.3);
|
||||
}
|
||||
|
||||
.ui-btn--outline:active:not(.is-disabled) {
|
||||
background: var(--primary-bg);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.ui-btn--danger {
|
||||
background: #FFFFFF;
|
||||
color: #E53935;
|
||||
border: 2rpx solid rgba(229, 57, 53, 0.3);
|
||||
}
|
||||
|
||||
.ui-btn--danger:active:not(.is-disabled) {
|
||||
background: rgba(229, 57, 53, 0.05);
|
||||
border-color: #E53935;
|
||||
}
|
||||
|
||||
.ui-btn--ghost {
|
||||
/* Use rgba on the theme's RGB triplet instead of --primary-bg (~5% alpha)
|
||||
so the soft secondary reads as colored rather than near-white. */
|
||||
background: rgba(var(--primary-rgb), 0.14);
|
||||
color: var(--primary);
|
||||
border: 2rpx solid rgba(var(--primary-rgb), 0.22);
|
||||
}
|
||||
|
||||
.ui-btn--ghost:active:not(.is-disabled) {
|
||||
background: rgba(var(--primary-rgb), 0.22);
|
||||
border-color: rgba(var(--primary-rgb), 0.35);
|
||||
}
|
||||
|
||||
.ui-btn.is-disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ui-btn__icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ui-btn__text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
Component({
|
||||
options: { multipleSlots: true },
|
||||
properties: {
|
||||
variant: { type: String, value: '' }, // '' | 'soft' | 'in'
|
||||
padded: { type: Boolean, value: true },
|
||||
customStyle: { type: String, value: '' }
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<view class="ui-card {{variant}} {{padded ? '' : 'ui-card--flush'}}" style="{{customStyle}}">
|
||||
<slot></slot>
|
||||
</view>
|
||||
@@ -0,0 +1,34 @@
|
||||
.ui-card {
|
||||
background: var(--card-bg, #FFFFFF);
|
||||
border-radius: 24rpx;
|
||||
padding: 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.06);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ui-card--flush {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Soft variant: subtle background tint, no shadow */
|
||||
.ui-card.soft {
|
||||
background: rgba(var(--primary-rgb, 255, 107, 53), 0.04);
|
||||
box-shadow: none;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
/* Staggered entrance animation */
|
||||
.ui-card.in {
|
||||
animation: uiCardIn 0.45s ease both;
|
||||
}
|
||||
|
||||
.ui-card.in:nth-child(1) { animation-delay: 0.05s; }
|
||||
.ui-card.in:nth-child(2) { animation-delay: 0.15s; }
|
||||
.ui-card.in:nth-child(3) { animation-delay: 0.25s; }
|
||||
.ui-card.in:nth-child(4) { animation-delay: 0.35s; }
|
||||
|
||||
@keyframes uiCardIn {
|
||||
from { opacity: 0; transform: translateY(40rpx); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
Component({
|
||||
options: { multipleSlots: true },
|
||||
properties: {
|
||||
visible: { type: Boolean, value: false },
|
||||
position: { type: String, value: 'bottom' }, // 'bottom' | 'center'
|
||||
showHandle: { type: Boolean, value: true },
|
||||
closeOnOverlay: { type: Boolean, value: true },
|
||||
customStyle: { type: String, value: '' }
|
||||
},
|
||||
methods: {
|
||||
onClose() {
|
||||
if (!this.data.closeOnOverlay) return
|
||||
this.triggerEvent('close')
|
||||
},
|
||||
onNoop() { /* swallow bubble for inner touches */ }
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<view wx:if="{{visible}}" class="ui-modal ui-modal--{{position}}" catchtouchmove="onNoop">
|
||||
<view class="ui-modal__overlay" bindtap="onClose"></view>
|
||||
<view class="ui-modal__body" catchtap="onNoop" style="{{customStyle}}">
|
||||
<view wx:if="{{showHandle && position === 'bottom'}}" class="ui-modal__handle"></view>
|
||||
<slot></slot>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,61 @@
|
||||
.ui-modal {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.ui-modal--bottom { display: flex; align-items: flex-end; justify-content: center; }
|
||||
.ui-modal--center { display: flex; align-items: center; justify-content: center; }
|
||||
|
||||
.ui-modal__overlay {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
animation: uiModalFade 0.25s ease;
|
||||
}
|
||||
|
||||
.ui-modal__body {
|
||||
position: relative;
|
||||
background: #FFFFFF;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ui-modal--bottom .ui-modal__body {
|
||||
width: 100%;
|
||||
border-radius: 32rpx 32rpx 0 0;
|
||||
padding: 28rpx 32rpx 40rpx;
|
||||
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||
animation: uiModalSlideUp 0.3s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
.ui-modal--center .ui-modal__body {
|
||||
width: 560rpx;
|
||||
border-radius: 28rpx;
|
||||
padding: 48rpx 40rpx 36rpx;
|
||||
animation: uiModalPop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ui-modal__handle {
|
||||
width: 64rpx;
|
||||
height: 8rpx;
|
||||
background: #DDD;
|
||||
border-radius: 4rpx;
|
||||
margin: 0 auto 24rpx;
|
||||
}
|
||||
|
||||
@keyframes uiModalFade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes uiModalSlideUp {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes uiModalPop {
|
||||
from { transform: scale(0); opacity: 0; }
|
||||
60% { transform: scale(1.05); }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
+53
-6
@@ -1,22 +1,70 @@
|
||||
const themeMod = require('../utils/theme')
|
||||
|
||||
// Resolve the active theme synchronously at module load so the first paint
|
||||
// already has the correct CSS variables — avoids a flash of the default theme.
|
||||
const _initialTheme = themeMod.getCurrentTheme()
|
||||
const _initialThemeStyle = themeMod.getThemeStyle(_initialTheme)
|
||||
|
||||
// SVG icons as data URIs. We use two variants per icon:
|
||||
// - *Fill*: solid filled (active state, theme color baked in at module load)
|
||||
// - *Line*: outlined (inactive state, neutral gray)
|
||||
// Themes are 5 fixed colors so we can bake the active color in at module init.
|
||||
// If the user changes theme, the component re-renders via pageLifetimes.show.
|
||||
|
||||
// Solid (active) color from current theme
|
||||
const _activeColor = _initialTheme.primary
|
||||
// Inactive color
|
||||
const _inactiveColor = '#999999'
|
||||
|
||||
const _svg = (path, fill) =>
|
||||
'data:image/svg+xml,' + encodeURIComponent(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="' + fill + '">' +
|
||||
path + '</svg>'
|
||||
)
|
||||
|
||||
// 24x24 Material-style paths
|
||||
const PATH_HOME = '<path d="M12 3l9 8h-3v9h-5v-6h-2v6H6v-9H3l9-8z"/>'
|
||||
const PATH_HOME_LINE = '<path d="M12 5.2L5.5 11H7v8h2v-6h6v6h2v-8h1.5L12 5.2M12 3l9 8h-3v9h-5v-6h-2v6H6v-9H3l9-8z" fill-rule="evenodd"/>'
|
||||
const PATH_CALENDAR = '<path d="M19 4h-2V2h-2v2H9V2H7v2H5C3.9 4 3 4.9 3 6v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V10h14v10zm0-12H5V6h14v2z"/><path d="M7 12h5v5H7z"/>'
|
||||
const PATH_CALENDAR_LINE = '<path d="M19 4h-2V2h-2v2H9V2H7v2H5C3.9 4 3 4.9 3 6v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V10h14v10zm0-12H5V6h14v2z" fill-rule="evenodd"/>'
|
||||
const PATH_RANK = '<path d="M7.5 21H2V9h5.5v12zm7.25-14h-5.5v14h5.5V7zM22 11h-5.5v10H22V11z"/>'
|
||||
const PATH_RANK_LINE = '<path d="M7.5 21H2V9h5.5v12zm7.25-14h-5.5v14h5.5V7zM22 11h-5.5v10H22V11z" fill-rule="evenodd"/>'
|
||||
const PATH_SETTINGS = '<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/>'
|
||||
const PATH_SETTINGS_LINE = '<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" fill-rule="evenodd"/>'
|
||||
|
||||
const _buildIcons = (activeHex) => {
|
||||
const a = activeHex
|
||||
return {
|
||||
home: _svg(PATH_HOME_LINE, _inactiveColor),
|
||||
homeFill: _svg(PATH_HOME, a),
|
||||
calendar: _svg(PATH_CALENDAR_LINE, _inactiveColor),
|
||||
calendarFill: _svg(PATH_CALENDAR, a),
|
||||
rank: _svg(PATH_RANK_LINE, _inactiveColor),
|
||||
rankFill: _svg(PATH_RANK, a),
|
||||
settings: _svg(PATH_SETTINGS_LINE, _inactiveColor),
|
||||
settingsFill: _svg(PATH_SETTINGS, a)
|
||||
}
|
||||
}
|
||||
|
||||
Component({
|
||||
data: {
|
||||
selected: 0,
|
||||
themeStyle: themeMod.BASE_VARS
|
||||
theme: _initialTheme,
|
||||
themeStyle: _initialThemeStyle,
|
||||
svgIcons: _buildIcons(_initialTheme.primary)
|
||||
},
|
||||
|
||||
lifetimes: {
|
||||
attached() {
|
||||
const theme = themeMod.getCurrentTheme()
|
||||
this.setData({ themeStyle: themeMod.getThemeStyle(theme) })
|
||||
themeMod.applyThemeToPage(this)
|
||||
}
|
||||
},
|
||||
|
||||
pageLifetimes: {
|
||||
show() {
|
||||
themeMod.applyThemeToPage(this)
|
||||
const theme = themeMod.getCurrentTheme()
|
||||
this.setData({ themeStyle: themeMod.getThemeStyle(theme) })
|
||||
this.setData({ svgIcons: _buildIcons(theme.primary) })
|
||||
}
|
||||
},
|
||||
|
||||
@@ -26,11 +74,10 @@ Component({
|
||||
const pages = [
|
||||
'/pages/index/index',
|
||||
'/pages/records/records',
|
||||
'/pages/leaderboard/leaderboard',
|
||||
'/pages/settings/settings'
|
||||
]
|
||||
|
||||
if (index === this.data.selected) return
|
||||
|
||||
wx.switchTab({ url: pages[index] })
|
||||
}
|
||||
}
|
||||
|
||||
+11
-18
@@ -4,12 +4,7 @@
|
||||
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>
|
||||
<view class="tab-icon" style="background-image: url({{selected === 0 ? svgIcons.homeFill : svgIcons.home}})"></view>
|
||||
<text class="tab-text">训练</text>
|
||||
</view>
|
||||
|
||||
@@ -18,13 +13,7 @@
|
||||
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>
|
||||
<view class="tab-icon" style="background-image: url({{selected === 1 ? svgIcons.calendarFill : svgIcons.calendar}})"></view>
|
||||
<text class="tab-text">记录</text>
|
||||
</view>
|
||||
|
||||
@@ -33,12 +22,16 @@
|
||||
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 class="tab-icon" style="background-image: url({{selected === 2 ? svgIcons.rankFill : svgIcons.rank}})"></view>
|
||||
<text class="tab-text">排行</text>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="tab-item {{selected === 3 ? 'active' : ''}}"
|
||||
data-index="3"
|
||||
bindtap="switchTab"
|
||||
>
|
||||
<view class="tab-icon" style="background-image: url({{selected === 3 ? svgIcons.settingsFill : svgIcons.settings}})"></view>
|
||||
<text class="tab-text">设置</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
+20
-112
@@ -3,7 +3,7 @@
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 110rpx;
|
||||
height: calc(110rpx + env(safe-area-inset-bottom));
|
||||
background: #FFFFFF;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -11,6 +11,8 @@
|
||||
border-top: 1rpx solid rgba(0, 0, 0, 0.06);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
z-index: 999;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
@@ -18,129 +20,35 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6rpx 0;
|
||||
padding: 4rpx 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tab-item:active {
|
||||
transform: scale(0.9);
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
margin-bottom: 4rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
font-size: 24rpx;
|
||||
font-size: 22rpx;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 8rpx;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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 var(--text-secondary);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.tab-item.active .home-roof {
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.home-body {
|
||||
width: 22rpx;
|
||||
height: 20rpx;
|
||||
background: var(--text-secondary);
|
||||
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: var(--text-secondary);
|
||||
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 var(--text-secondary);
|
||||
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: var(--text-secondary);
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 15rpx;
|
||||
left: 15rpx;
|
||||
}
|
||||
|
||||
.tab-item.active .gear-center {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
+26
-9
@@ -2,6 +2,9 @@ const storage = require('../../utils/storage')
|
||||
const planMod = require('../../utils/plan')
|
||||
const util = require('../../utils/util')
|
||||
const themeMod = require('../../utils/theme')
|
||||
const iconsMod = require('../../utils/icons')
|
||||
|
||||
const MAX_CUSTOM_DURATION = 3600 // 1 hour max
|
||||
|
||||
Page({
|
||||
data: {
|
||||
@@ -19,12 +22,13 @@ Page({
|
||||
showPicker: false,
|
||||
presets: [30, 60, 90, 120, 180, 300],
|
||||
customDuration: 60,
|
||||
customInput: ''
|
||||
customInput: '',
|
||||
icons: iconsMod.build()
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
themeMod.applyThemeToPage(this)
|
||||
this.refresh()
|
||||
this._firstShow = true
|
||||
},
|
||||
|
||||
onShow() {
|
||||
@@ -33,6 +37,12 @@ Page({
|
||||
const tb = this.getTabBar()
|
||||
if (tb) tb.setData({ selected: 0 })
|
||||
} catch (e) {}
|
||||
// Skip the first onShow to avoid double-refresh; subsequent tab switches still refresh.
|
||||
if (this._firstShow) {
|
||||
this._firstShow = false
|
||||
this.refresh()
|
||||
return
|
||||
}
|
||||
this.refresh()
|
||||
},
|
||||
|
||||
@@ -43,15 +53,19 @@ Page({
|
||||
|
||||
refresh() {
|
||||
const settings = storage.getSettings()
|
||||
const streak = storage.getStreak()
|
||||
const streak = storage.validateStreak()
|
||||
const todayRecord = storage.getTodayRecord()
|
||||
|
||||
const allRecords = Object.values(storage.getRecords()).flat()
|
||||
const plan = planMod.getPlan(settings.planId)
|
||||
const planDay = planMod.getPlanDay(settings.planId, allRecords)
|
||||
const customPlans = storage.getCustomPlans()
|
||||
const plan = planMod.getPlan(settings.planId, customPlans)
|
||||
const planDay = planMod.getPlanDay(settings.planId, allRecords, settings.planStartDate)
|
||||
const target = planMod.getTodayTarget(settings.planId, Math.min(planDay, plan.totalDays))
|
||||
|
||||
const theme = themeMod.getCurrentTheme()
|
||||
this.setData({
|
||||
theme,
|
||||
icons: iconsMod.build(theme),
|
||||
todayTarget: target,
|
||||
todayDone: todayRecord && todayRecord.duration >= target,
|
||||
todayDuration: todayRecord ? todayRecord.duration : 0,
|
||||
@@ -82,7 +96,8 @@ Page({
|
||||
},
|
||||
|
||||
onCustomInput(e) {
|
||||
const val = parseInt(e.detail.value) || 0
|
||||
let val = parseInt(e.detail.value) || 0
|
||||
if (val > MAX_CUSTOM_DURATION) val = MAX_CUSTOM_DURATION
|
||||
this.setData({ customInput: e.detail.value, customDuration: val })
|
||||
},
|
||||
|
||||
@@ -92,9 +107,11 @@ Page({
|
||||
wx.showToast({ title: '请输入有效时长', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (dur > MAX_CUSTOM_DURATION) {
|
||||
wx.showToast({ title: '最大时长为1小时', icon: 'none' })
|
||||
return
|
||||
}
|
||||
this.setData({ showPicker: false })
|
||||
wx.navigateTo({ url: `/pages/timer/timer?free=${dur}` })
|
||||
},
|
||||
|
||||
noop() {}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"usingComponents": {},
|
||||
"usingComponents": {
|
||||
"ui-card": "/components/ui-card/ui-card",
|
||||
"ui-btn": "/components/ui-btn/ui-btn",
|
||||
"ui-modal": "/components/ui-modal/ui-modal"
|
||||
},
|
||||
"navigationBarTitleText": "平板支撑训练"
|
||||
}
|
||||
|
||||
+46
-26
@@ -3,23 +3,24 @@
|
||||
<!-- 顶部问候 -->
|
||||
<view class="greeting-row">
|
||||
<view class="greeting-text">
|
||||
<text class="greeting-emoji">{{todayDone ? '💪' : '👋'}}</text>
|
||||
<image class="greeting-icon" src="{{todayDone ? icons.successFill : icons.emojiFill}}" mode="aspectFit"></image>
|
||||
<text class="greeting-title">{{todayDone ? '太棒了!' : '准备好了吗?'}}</text>
|
||||
</view>
|
||||
<text class="greeting-sub">{{todayDone ? '继续保持这个势头' : '今天的平板支撑等着你'}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 连续打卡卡片 -->
|
||||
<view class="header-card card">
|
||||
<ui-card variant="in">
|
||||
<view class="header-card">
|
||||
<view class="streak-section">
|
||||
<text class="streak-icon">🔥</text>
|
||||
<image class="streak-icon" src="{{icons.hotFill}}" mode="aspectFit"></image>
|
||||
<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>
|
||||
<image class="plan-badge-icon" src="{{icons.formFill}}" mode="aspectFit"></image>
|
||||
<text class="plan-badge-text">{{planName}}</text>
|
||||
</view>
|
||||
<view class="progress-bar-wrap">
|
||||
@@ -32,11 +33,13 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</ui-card>
|
||||
|
||||
<!-- 今日目标卡片 -->
|
||||
<view class="target-section card">
|
||||
<ui-card variant="in">
|
||||
<view class="target-section">
|
||||
<view class="target-header">
|
||||
<text class="target-icon">🎯</text>
|
||||
<image class="section-icon" src="{{icons.targetFill}}" mode="aspectFit"></image>
|
||||
<text class="section-title">今日目标</text>
|
||||
</view>
|
||||
<view class="target-display">
|
||||
@@ -48,40 +51,54 @@
|
||||
<view class="today-status">
|
||||
<block wx:if="{{todayDone}}">
|
||||
<view class="done-badge">
|
||||
<text class="done-emoji">✨</text>
|
||||
<image class="done-icon" src="{{icons.successFill}}" mode="aspectFit"></image>
|
||||
<text class="done-text">今日已完成 {{todayDuration}} 秒</text>
|
||||
</view>
|
||||
</block>
|
||||
<block wx:else>
|
||||
<text class="pending-text">⏳ 还未开始训练</text>
|
||||
<image class="pending-icon" src="{{icons.time}}" mode="aspectFit"></image>
|
||||
<text class="pending-text">还未开始训练</text>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
</ui-card>
|
||||
|
||||
<!-- 操作按钮区 -->
|
||||
<view class="action-buttons">
|
||||
<button class="start-btn btn-primary" bindtap="onStartTrain">
|
||||
<text class="btn-icon">{{todayDone ? '🔄' : '▶'}}</text>
|
||||
<text>{{todayDone ? '再次训练' : '开始训练'}}</text>
|
||||
</button>
|
||||
<ui-btn
|
||||
variant="primary"
|
||||
size="xl"
|
||||
block
|
||||
text="{{todayDone ? '再次训练' : '开始训练'}}"
|
||||
icon-src="{{icons.playFill}}"
|
||||
bindtap="onStartTrain"
|
||||
></ui-btn>
|
||||
|
||||
<button class="free-btn" bindtap="onFreeTrain">
|
||||
<text class="free-icon">⏱</text>
|
||||
<text>自由训练 · 自定义时长</text>
|
||||
</button>
|
||||
<ui-btn
|
||||
variant="ghost"
|
||||
size="md"
|
||||
block
|
||||
text="自由训练 · 自定义时长"
|
||||
icon-src="{{icons.timeFill}}"
|
||||
bindtap="onFreeTrain"
|
||||
></ui-btn>
|
||||
</view>
|
||||
|
||||
<!-- 小提示 -->
|
||||
<ui-card variant="soft" padded="{{false}}">
|
||||
<view class="tip-card">
|
||||
<text class="tip-icon">💡</text>
|
||||
<image class="tip-icon" src="{{icons.lightFill}}" mode="aspectFit"></image>
|
||||
<text class="tip-text">保持身体成一条直线,核心收紧,均匀呼吸</text>
|
||||
</view>
|
||||
</ui-card>
|
||||
|
||||
<!-- 时间选择弹窗 -->
|
||||
<view class="modal-overlay modal-overlay-bottom" wx:if="{{showPicker}}" bindtap="onClosePicker">
|
||||
<view class="picker-modal" catchtap="noop" catchtouchmove="noop">
|
||||
<view class="picker-handle"></view>
|
||||
<text class="picker-title">⏱ 选择训练时长</text>
|
||||
<ui-modal
|
||||
visible="{{showPicker}}"
|
||||
position="bottom"
|
||||
bind:close="onClosePicker"
|
||||
>
|
||||
<text class="picker-title">选择训练时长</text>
|
||||
<view class="preset-grid">
|
||||
<view
|
||||
class="preset-item {{customDuration === item ? 'selected' : ''}}"
|
||||
@@ -105,10 +122,13 @@
|
||||
/>
|
||||
<text class="custom-label">秒</text>
|
||||
</view>
|
||||
<button class="picker-confirm btn-primary" bindtap="onConfirmFree">
|
||||
开始自由训练
|
||||
</button>
|
||||
<ui-btn
|
||||
variant="primary"
|
||||
size="lg"
|
||||
block
|
||||
text="开始自由训练"
|
||||
bindtap="onConfirmFree"
|
||||
></ui-btn>
|
||||
<text class="picker-cancel" bindtap="onClosePicker">取消</text>
|
||||
</view>
|
||||
</view>
|
||||
</ui-modal>
|
||||
</view>
|
||||
|
||||
+38
-95
@@ -10,10 +10,10 @@
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.greeting-emoji {
|
||||
font-size: 40rpx;
|
||||
.greeting-icon {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
margin-right: 12rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.greeting-title {
|
||||
@@ -26,7 +26,7 @@
|
||||
.greeting-sub {
|
||||
font-size: 26rpx;
|
||||
color: var(--text-secondary);
|
||||
margin-left: 56rpx;
|
||||
margin-left: 60rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
.header-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 36rpx 32rpx;
|
||||
padding: 4rpx 0;
|
||||
}
|
||||
|
||||
.streak-section {
|
||||
@@ -45,9 +45,9 @@
|
||||
}
|
||||
|
||||
.streak-icon {
|
||||
font-size: 40rpx;
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
margin-bottom: 4rpx;
|
||||
line-height: 1;
|
||||
animation: float 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -85,9 +85,10 @@
|
||||
}
|
||||
|
||||
.plan-badge-icon {
|
||||
font-size: 28rpx;
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
color: var(--primary);
|
||||
margin-right: 8rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.plan-badge-text {
|
||||
@@ -139,7 +140,6 @@
|
||||
/* ---- target card ---- */
|
||||
.target-section {
|
||||
text-align: center;
|
||||
padding: 36rpx 32rpx;
|
||||
}
|
||||
|
||||
.target-header {
|
||||
@@ -153,10 +153,10 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.target-icon {
|
||||
font-size: 28rpx;
|
||||
.section-icon {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
margin-right: 8rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.target-display {
|
||||
@@ -193,6 +193,10 @@
|
||||
|
||||
.today-status {
|
||||
margin-top: 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.done-badge {
|
||||
@@ -203,10 +207,10 @@
|
||||
border-radius: 24rpx;
|
||||
}
|
||||
|
||||
.done-emoji {
|
||||
font-size: 28rpx;
|
||||
.done-icon {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
margin-right: 6rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.done-text {
|
||||
@@ -215,6 +219,12 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.pending-icon {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
margin-right: 6rpx;
|
||||
}
|
||||
|
||||
.pending-text {
|
||||
font-size: 26rpx;
|
||||
color: #CCC;
|
||||
@@ -223,51 +233,10 @@
|
||||
|
||||
/* ---- action buttons ---- */
|
||||
.action-buttons {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.start-btn {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10rpx;
|
||||
padding: 24rpx 0;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 28rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.free-btn {
|
||||
width: 100%;
|
||||
margin-top: 20rpx;
|
||||
background: transparent;
|
||||
border: 2rpx solid rgba(var(--primary-rgb), 0.3);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 48rpx;
|
||||
font-size: 28rpx;
|
||||
padding: 20rpx 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.free-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.free-btn:active {
|
||||
background: var(--primary-bg);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.free-icon {
|
||||
font-size: 28rpx;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
margin: 8rpx 0 8rpx;
|
||||
}
|
||||
|
||||
/* ---- tip card ---- */
|
||||
@@ -275,17 +244,16 @@
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 24rpx 32rpx;
|
||||
margin-top: 32rpx;
|
||||
background: rgba(var(--primary-rgb), 0.04);
|
||||
background: transparent;
|
||||
border-radius: 20rpx;
|
||||
animation: cardIn 0.45s 0.4s ease both;
|
||||
}
|
||||
|
||||
.tip-icon {
|
||||
font-size: 28rpx;
|
||||
margin-right: 10rpx;
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
margin-right: 12rpx;
|
||||
flex-shrink: 0;
|
||||
line-height: 1.6;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.tip-text {
|
||||
@@ -294,29 +262,7 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ---- picker modal ---- */
|
||||
.picker-modal {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-radius: 32rpx 32rpx 0 0;
|
||||
padding: 28rpx 32rpx 40rpx;
|
||||
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||
animation: slideUp 0.3s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
.picker-handle {
|
||||
width: 64rpx;
|
||||
height: 8rpx;
|
||||
background: #DDD;
|
||||
border-radius: 4rpx;
|
||||
margin: 0 auto 24rpx;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ---- picker ---- */
|
||||
.picker-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
@@ -341,6 +287,7 @@
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.preset-item:active {
|
||||
@@ -395,15 +342,11 @@
|
||||
margin: 0 16rpx;
|
||||
}
|
||||
|
||||
.picker-confirm {
|
||||
width: 100%;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.picker-cancel {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 28rpx;
|
||||
color: var(--text-secondary);
|
||||
padding: 12rpx 0;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
const themeMod = require('../../utils/theme')
|
||||
const iconsMod = require('../../utils/icons')
|
||||
const util = require('../../utils/util')
|
||||
const storage = require('../../utils/storage')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
theme: { primary: '#FF6B35', primaryLight: '#FF8C5A', primaryBg: '#FFF3ED', primaryRgb: '255,107,53' },
|
||||
themeStyle: themeMod.BASE_VARS,
|
||||
periods: [
|
||||
{ key: 'day', label: '日榜' },
|
||||
{ key: 'month', label: '月榜' },
|
||||
{ key: 'year', label: '年榜' }
|
||||
],
|
||||
activePeriod: 'day',
|
||||
rankedList: [],
|
||||
myEntry: null,
|
||||
loading: false,
|
||||
empty: false,
|
||||
icons: iconsMod.build()
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
themeMod.applyThemeToPage(this)
|
||||
this._firstShow = true
|
||||
},
|
||||
|
||||
onShow() {
|
||||
themeMod.applyThemeToPage(this)
|
||||
const theme = themeMod.getCurrentTheme()
|
||||
this.setData({ theme, icons: iconsMod.build(theme) })
|
||||
try {
|
||||
const tb = this.getTabBar()
|
||||
if (tb) tb.setData({ selected: 2 })
|
||||
} catch (e) {}
|
||||
if (this._firstShow) {
|
||||
this._firstShow = false
|
||||
this.fetchRank()
|
||||
return
|
||||
}
|
||||
this.fetchRank()
|
||||
},
|
||||
|
||||
onPullDownRefresh() {
|
||||
this.fetchRank(() => wx.stopPullDownRefresh())
|
||||
},
|
||||
|
||||
switchPeriod(e) {
|
||||
const period = e.currentTarget.dataset.period
|
||||
if (period === this.data.activePeriod) return
|
||||
this.setData({ activePeriod: period, rankedList: [], myEntry: null })
|
||||
this.fetchRank()
|
||||
},
|
||||
|
||||
fetchRank(cb) {
|
||||
this.setData({ loading: true, empty: false })
|
||||
wx.cloud.callFunction({
|
||||
name: 'leaderboard',
|
||||
data: { period: this.data.activePeriod }
|
||||
}).then(res => {
|
||||
this._applyResult(res.result || {})
|
||||
if (cb) cb()
|
||||
}).catch(() => {
|
||||
// Cloud function not deployed — fall back to local data
|
||||
this._applyLocal()
|
||||
if (cb) cb()
|
||||
})
|
||||
},
|
||||
|
||||
_applyResult(result) {
|
||||
const list = (result.ranked || []).map(item => ({
|
||||
...item,
|
||||
durationText: util.formatDuration(item.duration)
|
||||
}))
|
||||
const myEntry = result.myEntry ? {
|
||||
...result.myEntry,
|
||||
durationText: util.formatDuration(result.myEntry.duration),
|
||||
isMe: true
|
||||
} : null
|
||||
this.setData({
|
||||
rankedList: list,
|
||||
myEntry,
|
||||
loading: false,
|
||||
empty: list.length === 0 && !myEntry
|
||||
})
|
||||
},
|
||||
|
||||
/** Local fallback: use local storage when cloud function isn't deployed */
|
||||
_applyLocal() {
|
||||
const period = this.data.activePeriod
|
||||
const allRecords = Object.values(storage.getRecords()).flat()
|
||||
const today = storage.getToday()
|
||||
const thisMonth = today.substring(0, 7)
|
||||
const thisYear = String(new Date().getFullYear())
|
||||
|
||||
let duration = 0
|
||||
let sessions = 0
|
||||
allRecords.forEach(r => {
|
||||
if (period === 'day' && r.date === today) { duration += r.duration; sessions++ }
|
||||
if (period === 'month' && r.date.startsWith(thisMonth)) { duration += r.duration; sessions++ }
|
||||
if (period === 'year' && r.date.startsWith(thisYear)) { duration += r.duration; sessions++ }
|
||||
})
|
||||
|
||||
if (duration > 0) {
|
||||
const entry = {
|
||||
rank: 1, openid: 'local', name: '我', duration, durationText: util.formatDuration(duration), sessions
|
||||
}
|
||||
this.setData({ rankedList: [entry], myEntry: { ...entry, isMe: true }, loading: false, empty: false })
|
||||
} else {
|
||||
this.setData({ loading: false, empty: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"navigationBarTitleText": "排行榜",
|
||||
"enablePullDownRefresh": true,
|
||||
"backgroundColor": "#F5F5F5",
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<view class="container" style="{{themeStyle}}">
|
||||
<!-- Period tabs -->
|
||||
<view class="period-tabs">
|
||||
<view
|
||||
wx:for="{{periods}}"
|
||||
wx:key="key"
|
||||
class="period-tab {{activePeriod === item.key ? 'active' : ''}}"
|
||||
data-period="{{item.key}}"
|
||||
bindtap="switchPeriod"
|
||||
>{{item.label}}</view>
|
||||
</view>
|
||||
|
||||
<!-- Loading -->
|
||||
<view class="status-wrap" wx:if="{{loading}}">
|
||||
<text class="status-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<!-- Empty -->
|
||||
<view class="status-wrap" wx:elif="{{empty}}">
|
||||
<image class="empty-icon" src="{{icons.peopleFill}}" mode="aspectFit"></image>
|
||||
<text class="empty-text">暂无排行数据</text>
|
||||
<text class="empty-sub">快去训练吧!</text>
|
||||
</view>
|
||||
|
||||
<!-- Rank list -->
|
||||
<view class="rank-list" wx:else>
|
||||
<view
|
||||
wx:for="{{rankedList}}"
|
||||
wx:key="openid"
|
||||
class="rank-item {{item.openid === myEntry.openid ? 'is-me' : ''}}"
|
||||
>
|
||||
<view class="rank-num {{item.rank === 1 ? 'gold' : item.rank === 2 ? 'silver' : item.rank === 3 ? 'bronze' : ''}}">
|
||||
<text wx:if="{{item.rank === 1}}">🥇</text>
|
||||
<text wx:elif="{{item.rank === 2}}">🥈</text>
|
||||
<text wx:elif="{{item.rank === 3}}">🥉</text>
|
||||
<text wx:else>{{item.rank}}</text>
|
||||
</view>
|
||||
<image class="rank-avatar" src="{{icons.peopleFill}}" mode="aspectFit"></image>
|
||||
<view class="rank-info">
|
||||
<text class="rank-name">{{item.name}}</text>
|
||||
<text class="rank-sessions">{{item.sessions}}次</text>
|
||||
</view>
|
||||
<text class="rank-dur">{{item.durationText}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- My entry pinned at bottom (if not already in list) -->
|
||||
<view class="my-bar" wx:if="{{myEntry && myEntry.rank > rankedList.length}}">
|
||||
<view class="rank-num">{{myEntry.rank}}</view>
|
||||
<image class="rank-avatar" src="{{icons.peopleFill}}" mode="aspectFit"></image>
|
||||
<view class="rank-info">
|
||||
<text class="rank-name">我 ({{myEntry.name}})</text>
|
||||
<text class="rank-sessions">{{myEntry.sessions}}次</text>
|
||||
</view>
|
||||
<text class="rank-dur">{{myEntry.durationText}}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,176 @@
|
||||
.container {
|
||||
padding: 0;
|
||||
padding-bottom: calc(134rpx + env(safe-area-inset-bottom));
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Period tabs */
|
||||
.period-tabs {
|
||||
display: flex;
|
||||
background: #FFFFFF;
|
||||
padding: 0 32rpx;
|
||||
border-bottom: 1rpx solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.period-tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 24rpx 0 20rpx;
|
||||
font-size: 30rpx;
|
||||
color: var(--text-secondary);
|
||||
position: relative;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.period-tab.active {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.period-tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 40rpx;
|
||||
height: 6rpx;
|
||||
border-radius: 3rpx;
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
/* Status */
|
||||
.status-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 120rpx;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 28rpx;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
opacity: 0.3;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 30rpx;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.empty-sub {
|
||||
font-size: 26rpx;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Rank list */
|
||||
.rank-list {
|
||||
padding: 16rpx 24rpx;
|
||||
}
|
||||
|
||||
.rank-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--card-bg);
|
||||
border-radius: 16rpx;
|
||||
padding: 20rpx 24rpx;
|
||||
margin-bottom: 12rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.rank-item.is-me {
|
||||
background: var(--primary-bg);
|
||||
border: 2rpx solid var(--primary);
|
||||
}
|
||||
|
||||
/* Rank number */
|
||||
.rank-num {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 50%;
|
||||
background: #F5F5F5;
|
||||
flex-shrink: 0;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.rank-num.gold { background: #FFF7E6; color: #FA8C16; font-size: 36rpx; }
|
||||
.rank-num.silver { background: #F0F0F0; color: #8C8C8C; font-size: 36rpx; }
|
||||
.rank-num.bronze { background: #FFF1E6; color: #D46B08; font-size: 36rpx; }
|
||||
|
||||
.rank-avatar {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
background: #F0F0F0;
|
||||
flex-shrink: 0;
|
||||
margin-right: 16rpx;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.rank-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rank-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rank-sessions {
|
||||
font-size: 22rpx;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.rank-dur {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
flex-shrink: 0;
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
|
||||
/* My rank bar (pinned) */
|
||||
.my-bar {
|
||||
position: fixed;
|
||||
bottom: calc(110rpx + env(safe-area-inset-bottom));
|
||||
left: 24rpx;
|
||||
right: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--primary-bg);
|
||||
border: 2rpx solid var(--primary);
|
||||
border-radius: 16rpx;
|
||||
padding: 16rpx 24rpx;
|
||||
z-index: 10;
|
||||
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.my-bar .rank-name {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
const storage = require('../../utils/storage')
|
||||
const util = require('../../utils/util')
|
||||
const themeMod = require('../../utils/theme')
|
||||
const iconsMod = require('../../utils/icons')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
@@ -16,12 +17,13 @@ Page({
|
||||
monthRecords: [],
|
||||
historyList: [],
|
||||
showDayDetail: false,
|
||||
dayDetail: {}
|
||||
dayDetail: {},
|
||||
icons: iconsMod.build()
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
themeMod.applyThemeToPage(this)
|
||||
this.refresh()
|
||||
this._firstShow = true
|
||||
},
|
||||
|
||||
onShow() {
|
||||
@@ -30,6 +32,11 @@ Page({
|
||||
const tb = this.getTabBar()
|
||||
if (tb) tb.setData({ selected: 1 })
|
||||
} catch (e) {}
|
||||
if (this._firstShow) {
|
||||
this._firstShow = false
|
||||
this.refresh()
|
||||
return
|
||||
}
|
||||
this.refresh()
|
||||
},
|
||||
|
||||
@@ -39,21 +46,35 @@ Page({
|
||||
},
|
||||
|
||||
refresh() {
|
||||
storage.validateStreak()
|
||||
const stats = storage.getTotalStats()
|
||||
const monthKey = `${this.data.currentYear}-${String(this.data.currentMonth).padStart(2, '0')}`
|
||||
const records = storage.getRecordsByMonth(monthKey)
|
||||
|
||||
const allRecords = Object.values(storage.getRecords()).flat()
|
||||
allRecords.sort((a, b) => b.date.localeCompare(a.date))
|
||||
// Each month is already sorted newest-first in storage. To get top 30 most recent,
|
||||
// walk months in reverse chrono order and concat — O(months*30) instead of O(n log n).
|
||||
const byMonth = storage.getRecords()
|
||||
const monthKeys = Object.keys(byMonth).sort().reverse()
|
||||
const historyList = []
|
||||
for (const mk of monthKeys) {
|
||||
for (const r of byMonth[mk]) {
|
||||
historyList.push(r)
|
||||
if (historyList.length >= 30) break
|
||||
}
|
||||
if (historyList.length >= 30) break
|
||||
}
|
||||
|
||||
const theme = themeMod.getCurrentTheme()
|
||||
this.setData({
|
||||
theme,
|
||||
icons: iconsMod.build(theme),
|
||||
totalDuration: stats.totalDuration,
|
||||
totalDurationText: util.formatDuration(stats.totalDuration),
|
||||
totalSessions: stats.totalSessions,
|
||||
maxDuration: stats.maxDuration,
|
||||
maxDurationText: util.formatDuration(stats.maxDuration),
|
||||
monthRecords: records,
|
||||
historyList: allRecords.slice(0, 30)
|
||||
historyList
|
||||
})
|
||||
},
|
||||
|
||||
@@ -84,12 +105,10 @@ Page({
|
||||
onDayTap(e) {
|
||||
const { year, month, day } = e.detail
|
||||
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
|
||||
const allRecords = Object.values(storage.getRecords()).flat()
|
||||
const sessions = allRecords.filter(r => r.date === dateStr)
|
||||
const totalDur = sessions.reduce((sum, r) => sum + r.duration, 0)
|
||||
const calories = Math.round(totalDur * 0.068)
|
||||
|
||||
this.setData({
|
||||
showDayDetail: true,
|
||||
dayDetail: {
|
||||
@@ -106,10 +125,31 @@ Page({
|
||||
this.setData({ showDayDetail: false })
|
||||
},
|
||||
|
||||
noop() {},
|
||||
onLongPressDelete(e) {
|
||||
const id = e.currentTarget.dataset.id
|
||||
if (!id && id !== 0) {
|
||||
wx.showToast({ title: '记录数据异常', icon: 'none', duration: 1200 })
|
||||
return
|
||||
}
|
||||
wx.showModal({
|
||||
title: '删除记录',
|
||||
content: '确定要删除这条训练记录吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
storage.deleteRecord(id)
|
||||
this.refresh()
|
||||
wx.showToast({ title: '已删除', icon: 'none', duration: 1200 })
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
onDeleteRecord(e) {
|
||||
const id = e.currentTarget.dataset.id
|
||||
if (!id && id !== 0) {
|
||||
wx.showToast({ title: '记录数据异常', icon: 'none', duration: 1200 })
|
||||
return
|
||||
}
|
||||
wx.showModal({
|
||||
title: '删除记录',
|
||||
content: '确定要删除这条训练记录吗?',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"calendar-heatmap": "../../components/calendar-heatmap/calendar-heatmap"
|
||||
"calendar-heatmap": "/components/calendar-heatmap/calendar-heatmap",
|
||||
"ui-card": "/components/ui-card/ui-card",
|
||||
"ui-modal": "/components/ui-modal/ui-modal"
|
||||
},
|
||||
"navigationBarTitleText": "训练记录"
|
||||
}
|
||||
|
||||
+41
-21
@@ -1,85 +1,106 @@
|
||||
<view class="container" style="{{themeStyle}}">
|
||||
<!-- 统计卡片 -->
|
||||
<view class="stats card">
|
||||
<ui-card variant="in">
|
||||
<view class="stats">
|
||||
<view class="stat-item">
|
||||
<text class="stat-emoji">📊</text>
|
||||
<image class="stat-icon" src="{{icons.rankFill}}" mode="aspectFit"></image>
|
||||
<text class="stat-num">{{totalSessions}}次</text>
|
||||
<text class="stat-label">训练次数</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-emoji">⏱</text>
|
||||
<image class="stat-icon" src="{{icons.timeFill}}" mode="aspectFit"></image>
|
||||
<text class="stat-num">{{totalDurationText}}</text>
|
||||
<text class="stat-label">累计时长</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-emoji">🏆</text>
|
||||
<image class="stat-icon" src="{{icons.crownFill}}" mode="aspectFit"></image>
|
||||
<text class="stat-num">{{maxDurationText}}</text>
|
||||
<text class="stat-label">最长记录</text>
|
||||
</view>
|
||||
</view>
|
||||
</ui-card>
|
||||
|
||||
<!-- 日历热力图 -->
|
||||
<view class="calendar-section card">
|
||||
<ui-card variant="in">
|
||||
<view class="calendar-section">
|
||||
<view class="month-picker">
|
||||
<view class="month-arrow" bindtap="onPrevMonth">‹</view>
|
||||
<view class="month-arrow" bindtap="onPrevMonth">
|
||||
<image class="month-arrow-img" src="{{icons.back}}" mode="aspectFit"></image>
|
||||
</view>
|
||||
<view class="month-title-wrap">
|
||||
<text class="month-emoji">🗓</text>
|
||||
<image class="month-icon" src="{{icons.calendarFill}}" mode="aspectFit"></image>
|
||||
<text class="month-title">{{currentYear}}年 {{currentMonth}}月</text>
|
||||
</view>
|
||||
<view class="month-arrow" bindtap="onNextMonth">›</view>
|
||||
<view class="month-arrow" bindtap="onNextMonth">
|
||||
<image class="month-arrow-img" src="{{icons.right}}" mode="aspectFit"></image>
|
||||
</view>
|
||||
</view>
|
||||
<calendar-heatmap
|
||||
year="{{currentYear}}"
|
||||
month="{{currentMonth}}"
|
||||
records="{{monthRecords}}"
|
||||
primaryColor="{{theme.primary}}"
|
||||
binddaytap="onDayTap"
|
||||
></calendar-heatmap>
|
||||
</view>
|
||||
</ui-card>
|
||||
|
||||
<!-- 历史记录 -->
|
||||
<view class="history-section card">
|
||||
<ui-card variant="in">
|
||||
<view class="history-section">
|
||||
<view class="history-header">
|
||||
<text class="section-title">📋 最近记录</text>
|
||||
<view class="history-title-wrap">
|
||||
<image class="history-title-icon" src="{{icons.formFill}}" mode="aspectFit"></image>
|
||||
<text class="section-title">最近记录</text>
|
||||
</view>
|
||||
<text class="history-hint" wx:if="{{historyList.length > 0}}">长按可删除</text>
|
||||
</view>
|
||||
<view wx:if="{{historyList.length === 0}}" class="empty-state">
|
||||
<text class="empty-emoji">🏃</text>
|
||||
<image class="empty-icon" src="{{icons.peopleFill}}" mode="aspectFit"></image>
|
||||
<text class="empty-text">还没有训练记录</text>
|
||||
<text class="empty-sub">开始你的第一次平板支撑吧</text>
|
||||
</view>
|
||||
<view class="history-list">
|
||||
<view class="history-item" wx:for="{{historyList}}" wx:key="id" data-id="{{item.id}}" bindlongpress="onDeleteRecord">
|
||||
<text class="history-icon">
|
||||
{{item.duration >= 120 ? '🔥' : item.duration >= 60 ? '💪' : '✅'}}
|
||||
</text>
|
||||
<view class="history-item" wx:for="{{historyList}}" wx:key="id" data-id="{{item.id}}" bindlongpress="onLongPressDelete">
|
||||
<image class="history-item-icon"
|
||||
src="{{item.duration >= 120 ? icons.hotFill : item.duration >= 60 ? icons.likeFill : icons.roundCheckFill}}"
|
||||
mode="aspectFit"
|
||||
></image>
|
||||
<view class="history-left">
|
||||
<text class="history-date">{{item.date}}</text>
|
||||
<text class="history-plan">第 {{item.day}} 天</text>
|
||||
<text class="history-plan">{{item.day === 0 ? '自由训练' : '第 ' + item.day + ' 天'}}</text>
|
||||
</view>
|
||||
<view class="history-right">
|
||||
<text class="history-duration">{{item.duration}}s</text>
|
||||
<view class="history-delete-btn" catch:tap="onDeleteRecord" data-id="{{item.id}}">
|
||||
<image class="history-delete-icon" src="{{icons.deleteActive}}" mode="aspectFit"></image>
|
||||
<text class="history-delete-text">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</ui-card>
|
||||
|
||||
<!-- 日期详情弹窗 -->
|
||||
<view class="modal-overlay modal-overlay-center" wx:if="{{showDayDetail}}" bindtap="onCloseDayDetail">
|
||||
<view class="day-detail-modal" catchtap="noop">
|
||||
<ui-modal visible="{{showDayDetail}}" position="center" bind:close="onCloseDayDetail">
|
||||
<view class="detail-date-row">
|
||||
<text class="detail-emoji">📅</text>
|
||||
<image class="detail-icon" src="{{icons.calendarFill}}" mode="aspectFit"></image>
|
||||
<text class="detail-date">{{dayDetail.date}}</text>
|
||||
</view>
|
||||
<view class="detail-grid">
|
||||
<view class="detail-item">
|
||||
<image class="detail-item-icon" src="{{icons.rankFill}}" mode="aspectFit"></image>
|
||||
<text class="detail-num">{{dayDetail.sessions}}次</text>
|
||||
<text class="detail-label">训练次数</text>
|
||||
</view>
|
||||
<view class="detail-item">
|
||||
<image class="detail-item-icon" src="{{icons.timeFill}}" mode="aspectFit"></image>
|
||||
<text class="detail-num">{{dayDetail.durationText}}</text>
|
||||
<text class="detail-label">总时长</text>
|
||||
</view>
|
||||
<view class="detail-item">
|
||||
<image class="detail-item-icon" src="{{icons.hotFill}}" mode="aspectFit"></image>
|
||||
<text class="detail-num">{{dayDetail.calories}}</text>
|
||||
<text class="detail-label">约消耗 (千卡)</text>
|
||||
</view>
|
||||
@@ -87,6 +108,5 @@
|
||||
<view class="detail-close" bindtap="onCloseDayDetail">
|
||||
<text>关闭</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</ui-modal>
|
||||
</view>
|
||||
|
||||
+89
-39
@@ -2,7 +2,7 @@
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
text-align: center;
|
||||
padding: 32rpx 16rpx;
|
||||
padding: 8rpx 0;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
@@ -12,13 +12,10 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-emoji {
|
||||
font-size: 28rpx;
|
||||
width: 56rpx;
|
||||
.stat-icon {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
line-height: 40rpx;
|
||||
text-align: center;
|
||||
margin-bottom: 4rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.stat-num {
|
||||
@@ -38,7 +35,7 @@
|
||||
|
||||
/* ---- calendar ---- */
|
||||
.calendar-section {
|
||||
padding: 24rpx 24rpx 28rpx;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.month-picker {
|
||||
@@ -54,8 +51,9 @@
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.month-emoji {
|
||||
font-size: 28rpx;
|
||||
.month-icon {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
}
|
||||
|
||||
.month-title {
|
||||
@@ -65,18 +63,27 @@
|
||||
}
|
||||
|
||||
.month-arrow {
|
||||
font-size: 44rpx;
|
||||
color: var(--primary);
|
||||
padding: 0 20rpx;
|
||||
font-weight: 300;
|
||||
transition: opacity 0.15s;
|
||||
padding: 16rpx 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.month-arrow:active {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.month-arrow-img {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* ---- history ---- */
|
||||
.history-section {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.history-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -84,6 +91,17 @@
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.history-title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.history-title-icon {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
}
|
||||
|
||||
.history-header .section-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -100,8 +118,9 @@
|
||||
padding: 60rpx 0;
|
||||
}
|
||||
|
||||
.empty-emoji {
|
||||
font-size: 64rpx;
|
||||
.empty-icon {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
margin-bottom: 16rpx;
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
@@ -129,8 +148,13 @@
|
||||
animation: cardIn 0.35s ease both;
|
||||
}
|
||||
|
||||
.history-item:last-child {
|
||||
border-bottom: none;
|
||||
.history-item:last-child { border-bottom: none; }
|
||||
|
||||
.history-item-icon {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
margin-right: 16rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.history-left {
|
||||
@@ -153,35 +177,62 @@
|
||||
.history-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.history-duration {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
margin-right: 4rpx;
|
||||
}
|
||||
|
||||
.history-delete-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6rpx;
|
||||
padding: 0 18rpx;
|
||||
height: 56rpx;
|
||||
background: rgba(229, 57, 53, 0.1);
|
||||
border: 2rpx solid rgba(229, 57, 53, 0.2);
|
||||
border-radius: 28rpx;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s ease, background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.history-delete-btn:active {
|
||||
transform: scale(0.92);
|
||||
background: rgba(229, 57, 53, 0.2);
|
||||
border-color: rgba(229, 57, 53, 0.4);
|
||||
}
|
||||
|
||||
.history-delete-icon {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.history-delete-text {
|
||||
font-size: 24rpx;
|
||||
color: #E53935;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ---- day detail popup ---- */
|
||||
.day-detail-modal {
|
||||
width: 560rpx;
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
padding: 48rpx 40rpx 36rpx;
|
||||
animation: popIn 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.detail-date-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 36rpx;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.detail-emoji {
|
||||
font-size: 32rpx;
|
||||
margin-right: 10rpx;
|
||||
line-height: 1;
|
||||
.detail-icon {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
}
|
||||
|
||||
.detail-date {
|
||||
@@ -202,6 +253,12 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-item-icon {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.detail-num {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
@@ -220,10 +277,3 @@
|
||||
color: var(--text-secondary);
|
||||
border-top: 1rpx solid var(--border);
|
||||
}
|
||||
|
||||
.history-icon {
|
||||
font-size: 28rpx;
|
||||
margin-right: 16rpx;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
+257
-34
@@ -1,5 +1,44 @@
|
||||
const storage = require('../../utils/storage')
|
||||
const themeMod = require('../../utils/theme')
|
||||
const planMod = require('../../utils/plan')
|
||||
const iconsMod = require('../../utils/icons')
|
||||
const cloud = require('../../utils/cloud')
|
||||
|
||||
/**
|
||||
* Build the simplified plans list shown in the settings UI by overlaying
|
||||
* user-customized plans on top of the 3 presets. Each item carries
|
||||
* everything the editor needs to repopulate its form.
|
||||
*/
|
||||
const buildPlansList = (customPlans) => {
|
||||
const presets = planMod.plans // { beginner, intermediate, advanced }
|
||||
const custom = customPlans || {}
|
||||
const order = ['beginner', 'intermediate', 'advanced']
|
||||
return order.map((id) => {
|
||||
const p = custom[id] || presets[id]
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
desc: p.description,
|
||||
isCustom: !!custom[id],
|
||||
totalDays: p.totalDays,
|
||||
startTarget: p.startTarget,
|
||||
increment: p.increment,
|
||||
cycleDays: p.cycleDays
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const MAX_DAYS = 365
|
||||
const MIN_DAYS = 1
|
||||
const MAX_TARGET = 3600
|
||||
const MIN_TARGET = 5
|
||||
const MAX_INCREMENT = 300
|
||||
const MAX_CYCLE = 30
|
||||
|
||||
const _toInt = (v, fallback) => {
|
||||
const n = parseInt(v, 10)
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
@@ -7,16 +46,17 @@ Page({
|
||||
themeStyle: themeMod.BASE_VARS,
|
||||
themes: themeMod.THEMES,
|
||||
currentThemeId: 'orange',
|
||||
plans: [
|
||||
{ id: 'beginner', name: '初级', desc: '7天计划 · 30秒起步', days: 7 },
|
||||
{ id: 'intermediate', name: '中级', desc: '14天计划 · 60秒起步', days: 14 },
|
||||
{ id: 'advanced', name: '高级', desc: '30天计划 · 90秒起步', days: 30 }
|
||||
],
|
||||
plans: [],
|
||||
currentPlanId: 'beginner',
|
||||
dailyReminder: true,
|
||||
reminderTime: '08:00',
|
||||
voiceGuide: true,
|
||||
vibrate: true
|
||||
vibrate: true,
|
||||
icons: iconsMod.build(),
|
||||
// Editor state
|
||||
showPlanEditor: false,
|
||||
editingPlanId: '',
|
||||
editingIsCustom: false,
|
||||
editingDraft: null, // { name, totalDays, startTarget, increment, cycleDays }
|
||||
editorPreview: [] // [{ day, target }, ...]
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
@@ -25,58 +65,59 @@ Page({
|
||||
themeMod.applyThemeToPage(this)
|
||||
this.setData({
|
||||
currentPlanId: s.planId,
|
||||
dailyReminder: s.dailyReminder,
|
||||
reminderTime: s.reminderTime,
|
||||
voiceGuide: s.voiceGuide,
|
||||
vibrate: s.vibrate,
|
||||
currentThemeId: theme.id
|
||||
currentThemeId: theme.id,
|
||||
theme,
|
||||
icons: iconsMod.build(theme),
|
||||
plans: buildPlansList(storage.getCustomPlans())
|
||||
})
|
||||
},
|
||||
|
||||
onShow() {
|
||||
try {
|
||||
const tb = this.getTabBar()
|
||||
if (tb) tb.setData({ selected: 2 })
|
||||
if (tb) tb.setData({ selected: 3 })
|
||||
} catch (e) {}
|
||||
themeMod.applyThemeToPage(this)
|
||||
// sync highlighted theme in case it was changed elsewhere
|
||||
this.setData({ currentThemeId: themeMod.getCurrentTheme().id })
|
||||
const theme = themeMod.getCurrentTheme()
|
||||
this.setData({
|
||||
currentThemeId: theme.id,
|
||||
theme,
|
||||
icons: iconsMod.build(theme),
|
||||
plans: buildPlansList(storage.getCustomPlans())
|
||||
})
|
||||
},
|
||||
|
||||
onSelectTheme(e) {
|
||||
const id = e.currentTarget.dataset.id
|
||||
const theme = themeMod.setTheme(id)
|
||||
this.setData({ currentThemeId: id })
|
||||
|
||||
try {
|
||||
const tb = this.getTabBar()
|
||||
if (tb) tb.setData({ themeStyle: themeMod.getThemeStyle(theme) })
|
||||
} catch (e) {}
|
||||
|
||||
themeMod.applyThemeToPage(this)
|
||||
},
|
||||
|
||||
onSelectPlan(e) {
|
||||
const planId = e.currentTarget.dataset.id
|
||||
const s = storage.getSettings()
|
||||
if (s.planId === planId) return
|
||||
|
||||
wx.showModal({
|
||||
title: '切换训练计划',
|
||||
content: '切换计划会重置训练进度,原计划的历史记录仍会保留在训练记录中。确定继续吗?',
|
||||
confirmText: '确定切换',
|
||||
success: (res) => {
|
||||
if (!res.confirm) return
|
||||
s.planId = planId
|
||||
s.planStartDate = storage.getToday()
|
||||
storage.saveSettings(s)
|
||||
this.setData({ currentPlanId: planId })
|
||||
wx.showToast({ title: '计划已切换', icon: 'success', duration: 1200 })
|
||||
},
|
||||
|
||||
onToggleReminder(e) {
|
||||
const s = storage.getSettings()
|
||||
s.dailyReminder = e.detail.value
|
||||
storage.saveSettings(s)
|
||||
this.setData({ dailyReminder: e.detail.value })
|
||||
},
|
||||
|
||||
onReminderTimeChange(e) {
|
||||
const s = storage.getSettings()
|
||||
s.reminderTime = e.detail.value
|
||||
storage.saveSettings(s)
|
||||
this.setData({ reminderTime: e.detail.value })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
onToggleVoice(e) {
|
||||
@@ -108,14 +149,196 @@ Page({
|
||||
content: '将删除所有训练记录和连续打卡数据,此操作不可恢复。确定继续吗?',
|
||||
confirmText: '确定清除',
|
||||
confirmColor: '#E53935',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
wx.removeStorageSync('training_records')
|
||||
wx.removeStorageSync('current_streak')
|
||||
// also clear cloud data
|
||||
try { require('../../utils/cloud').clearAll() } catch (e) {}
|
||||
wx.removeStorageSync('user_settings')
|
||||
wx.removeStorageSync('app_theme')
|
||||
wx.removeStorageSync('custom_plans')
|
||||
try { await cloud.clearAll() } catch (e) {}
|
||||
wx.setStorageSync('user_settings', {
|
||||
planId: 'beginner',
|
||||
voiceGuide: true,
|
||||
vibrate: true,
|
||||
planStartDate: storage.getToday()
|
||||
})
|
||||
wx.setStorageSync('current_streak', { count: 0, lastDate: '' })
|
||||
themeMod.setTheme('orange')
|
||||
this.setData({
|
||||
currentPlanId: 'beginner',
|
||||
voiceGuide: true,
|
||||
vibrate: true,
|
||||
currentThemeId: 'orange'
|
||||
})
|
||||
themeMod.applyThemeToPage(this)
|
||||
// Reset plans list — custom_plans are gone
|
||||
this.setData({ plans: buildPlansList(storage.getCustomPlans()) })
|
||||
wx.showToast({ title: '已清除', icon: 'success', duration: 1500 })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// -------- Plan editor --------
|
||||
|
||||
onTapEditPlan(e) {
|
||||
// stopPropagation: prevent the row's onSelectPlan from also firing
|
||||
const id = e.currentTarget.dataset.id
|
||||
if (!id) return
|
||||
const plan = planMod.getPlan(id, storage.getCustomPlans())
|
||||
const isCustom = !!storage.getCustomPlans()[id]
|
||||
const draft = {
|
||||
name: plan.name,
|
||||
totalDays: plan.totalDays,
|
||||
startTarget: plan.startTarget,
|
||||
increment: plan.increment,
|
||||
cycleDays: plan.cycleDays
|
||||
}
|
||||
this.setData({
|
||||
showPlanEditor: true,
|
||||
editingPlanId: id,
|
||||
editingIsCustom: isCustom,
|
||||
editingDraft: draft,
|
||||
editorPreview: plan.days
|
||||
})
|
||||
},
|
||||
|
||||
onCloseEditor() {
|
||||
this.setData({ showPlanEditor: false })
|
||||
},
|
||||
|
||||
// Swallow bubble for inner touches (e.g. tapping the sheet body
|
||||
// shouldn't dismiss the modal — only the backdrop should).
|
||||
onNoop() { /* no-op */ },
|
||||
|
||||
_recomputePreview(draft) {
|
||||
return planMod.generatePlanDays({
|
||||
totalDays: draft.totalDays,
|
||||
startTarget: draft.startTarget,
|
||||
increment: draft.increment,
|
||||
cycleDays: draft.cycleDays
|
||||
})
|
||||
},
|
||||
|
||||
onEditName(e) {
|
||||
const draft = { ...this.data.editingDraft, name: e.detail.value }
|
||||
this.setData({ editingDraft: draft })
|
||||
},
|
||||
|
||||
onEditTotalDays(e) {
|
||||
const draft = {
|
||||
...this.data.editingDraft,
|
||||
totalDays: _toInt(e.detail.value, 0)
|
||||
}
|
||||
this.setData({
|
||||
editingDraft: draft,
|
||||
editorPreview: this._recomputePreview(draft)
|
||||
})
|
||||
},
|
||||
|
||||
onEditStartTarget(e) {
|
||||
const draft = {
|
||||
...this.data.editingDraft,
|
||||
startTarget: _toInt(e.detail.value, 0)
|
||||
}
|
||||
this.setData({
|
||||
editingDraft: draft,
|
||||
editorPreview: this._recomputePreview(draft)
|
||||
})
|
||||
},
|
||||
|
||||
onEditIncrement(e) {
|
||||
const draft = {
|
||||
...this.data.editingDraft,
|
||||
increment: _toInt(e.detail.value, 0)
|
||||
}
|
||||
this.setData({
|
||||
editingDraft: draft,
|
||||
editorPreview: this._recomputePreview(draft)
|
||||
})
|
||||
},
|
||||
|
||||
onEditCycleDays(e) {
|
||||
const draft = {
|
||||
...this.data.editingDraft,
|
||||
cycleDays: _toInt(e.detail.value, 0)
|
||||
}
|
||||
this.setData({
|
||||
editingDraft: draft,
|
||||
editorPreview: this._recomputePreview(draft)
|
||||
})
|
||||
},
|
||||
|
||||
_validateDraft(draft) {
|
||||
if (!draft.name || !draft.name.trim()) return '请填写计划名称'
|
||||
if (draft.totalDays < MIN_DAYS || draft.totalDays > MAX_DAYS) {
|
||||
return `总天数需在 ${MIN_DAYS}-${MAX_DAYS} 之间`
|
||||
}
|
||||
if (draft.startTarget < MIN_TARGET || draft.startTarget > MAX_TARGET) {
|
||||
return `起始时长需在 ${MIN_TARGET}-${MAX_TARGET} 秒之间`
|
||||
}
|
||||
if (draft.increment < 0 || draft.increment > MAX_INCREMENT) {
|
||||
return `每次增加需在 0-${MAX_INCREMENT} 秒之间`
|
||||
}
|
||||
if (draft.cycleDays < 1 || draft.cycleDays > MAX_CYCLE) {
|
||||
return `每几天增加需在 1-${MAX_CYCLE} 之间`
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
onSavePlan() {
|
||||
const { editingPlanId, editingDraft } = this.data
|
||||
if (!editingPlanId || !editingDraft) return
|
||||
const err = this._validateDraft(editingDraft)
|
||||
if (err) {
|
||||
wx.showToast({ title: err, icon: 'none', duration: 1800 })
|
||||
return
|
||||
}
|
||||
const planData = {
|
||||
id: editingPlanId,
|
||||
name: editingDraft.name.trim(),
|
||||
totalDays: editingDraft.totalDays,
|
||||
startTarget: editingDraft.startTarget,
|
||||
increment: editingDraft.increment,
|
||||
cycleDays: editingDraft.cycleDays,
|
||||
days: this._recomputePreview(editingDraft),
|
||||
description: `${editingDraft.totalDays}天计划 · ${editingDraft.startTarget}秒起步`
|
||||
}
|
||||
storage.saveCustomPlan(editingPlanId, planData)
|
||||
this.setData({
|
||||
showPlanEditor: false,
|
||||
plans: buildPlansList(storage.getCustomPlans())
|
||||
})
|
||||
wx.showToast({ title: '已保存', icon: 'success', duration: 1200 })
|
||||
},
|
||||
|
||||
onResetPlan() {
|
||||
const { editingPlanId } = this.data
|
||||
if (!editingPlanId) return
|
||||
wx.showModal({
|
||||
title: '恢复默认',
|
||||
content: '将恢复此计划的默认设置,自定义内容会丢失。确定继续吗?',
|
||||
confirmText: '确定恢复',
|
||||
confirmColor: '#E53935',
|
||||
success: (res) => {
|
||||
if (!res.confirm) return
|
||||
storage.resetCustomPlan(editingPlanId)
|
||||
// Reopen editor pointed at the preset version
|
||||
const preset = planMod.getPlan(editingPlanId)
|
||||
const draft = {
|
||||
name: preset.name,
|
||||
totalDays: preset.totalDays,
|
||||
startTarget: preset.startTarget,
|
||||
increment: preset.increment,
|
||||
cycleDays: preset.cycleDays
|
||||
}
|
||||
this.setData({
|
||||
editingIsCustom: false,
|
||||
editingDraft: draft,
|
||||
editorPreview: preset.days,
|
||||
plans: buildPlansList(storage.getCustomPlans())
|
||||
})
|
||||
wx.showToast({ title: '已恢复默认', icon: 'success', duration: 1200 })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"usingComponents": {},
|
||||
"usingComponents": {
|
||||
"ui-card": "/components/ui-card/ui-card",
|
||||
"ui-btn": "/components/ui-btn/ui-btn"
|
||||
},
|
||||
"navigationBarTitleText": "设置"
|
||||
}
|
||||
|
||||
+126
-46
@@ -1,8 +1,8 @@
|
||||
<view class="container" style="{{themeStyle}}">
|
||||
<!-- 配色方案 -->
|
||||
<view class="card">
|
||||
<ui-card variant="in">
|
||||
<view class="section-header">
|
||||
<text class="section-emoji">🎨</text>
|
||||
<image class="section-icon" src="{{icons.skinFill}}" mode="aspectFit"></image>
|
||||
<text class="section-title">配色方案</text>
|
||||
</view>
|
||||
<view class="theme-list">
|
||||
@@ -15,15 +15,15 @@
|
||||
>
|
||||
<view class="theme-color-dot" style="background: {{item.primary}};"></view>
|
||||
<text class="theme-name">{{item.name}}</text>
|
||||
<text class="plan-check" wx:if="{{currentThemeId === item.id}}">✓</text>
|
||||
</view>
|
||||
<image class="theme-check-img" wx:if="{{currentThemeId === item.id}}" src="{{icons.check}}" mode="aspectFit"></image>
|
||||
</view>
|
||||
</view>
|
||||
</ui-card>
|
||||
|
||||
<!-- 训练计划选择 -->
|
||||
<view class="card">
|
||||
<ui-card variant="in">
|
||||
<view class="section-header">
|
||||
<text class="section-emoji">📋</text>
|
||||
<image class="section-icon" src="{{icons.formFill}}" mode="aspectFit"></image>
|
||||
<text class="section-title">训练计划</text>
|
||||
</view>
|
||||
<view class="plan-list">
|
||||
@@ -41,82 +41,68 @@
|
||||
<text class="plan-item-name">{{item.name}}</text>
|
||||
<text class="plan-item-desc">{{item.desc}}</text>
|
||||
</view>
|
||||
<text class="plan-check" wx:if="{{currentPlanId === item.id}}">✓</text>
|
||||
<view class="plan-actions">
|
||||
<view class="plan-edit-btn" catch:tap="onTapEditPlan" data-id="{{item.id}}">
|
||||
<image class="plan-edit-img" src="{{icons.edit}}" mode="aspectFit"></image>
|
||||
</view>
|
||||
<image class="plan-check-img" wx:if="{{currentPlanId === item.id}}" src="{{icons.check}}" mode="aspectFit"></image>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 提醒设置 -->
|
||||
<view class="card">
|
||||
<view class="section-header">
|
||||
<text class="section-emoji">🔔</text>
|
||||
<text class="section-title">提醒</text>
|
||||
</view>
|
||||
<view class="setting-row">
|
||||
<view class="setting-left">
|
||||
<text class="setting-icon">📅</text>
|
||||
<text class="setting-label">每日提醒</text>
|
||||
</view>
|
||||
<switch checked="{{dailyReminder}}" bindchange="onToggleReminder" color="{{theme.primary}}"/>
|
||||
</view>
|
||||
<view class="setting-row" wx:if="{{dailyReminder}}">
|
||||
<view class="setting-left">
|
||||
<text class="setting-icon">🕐</text>
|
||||
<text class="setting-label">提醒时间</text>
|
||||
</view>
|
||||
<picker mode="time" value="{{reminderTime}}" bindchange="onReminderTimeChange">
|
||||
<text class="setting-value">{{reminderTime}}</text>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
</ui-card>
|
||||
|
||||
<!-- 训练偏好 -->
|
||||
<view class="card">
|
||||
<ui-card variant="in">
|
||||
<view class="section-header">
|
||||
<text class="section-emoji">⚙</text>
|
||||
<image class="section-icon" src="{{icons.settingsFill}}" mode="aspectFit"></image>
|
||||
<text class="section-title">训练偏好</text>
|
||||
</view>
|
||||
<view class="setting-row">
|
||||
<view class="setting-left">
|
||||
<text class="setting-icon">🔊</text>
|
||||
<image class="setting-icon" src="{{icons.voiceFill}}" mode="aspectFit"></image>
|
||||
<text class="setting-label">语音引导</text>
|
||||
</view>
|
||||
<switch checked="{{voiceGuide}}" bindchange="onToggleVoice" color="{{theme.primary}}"/>
|
||||
</view>
|
||||
<view class="setting-row">
|
||||
<view class="setting-left">
|
||||
<text class="setting-icon">📳</text>
|
||||
<image class="setting-icon" src="{{icons.notificationFill}}" mode="aspectFit"></image>
|
||||
<text class="setting-label">振动反馈</text>
|
||||
</view>
|
||||
<switch checked="{{vibrate}}" bindchange="onToggleVibrate" color="{{theme.primary}}"/>
|
||||
</view>
|
||||
</view>
|
||||
</ui-card>
|
||||
|
||||
<!-- 数据管理 -->
|
||||
<view class="card">
|
||||
<ui-card variant="in">
|
||||
<view class="section-header">
|
||||
<text class="section-emoji">🗑</text>
|
||||
<image class="section-icon" src="{{icons.deleteActive}}" mode="aspectFit"></image>
|
||||
<text class="section-title">数据管理</text>
|
||||
</view>
|
||||
<button class="btn-danger" bindtap="onClearData">
|
||||
<text>清除所有训练记录</text>
|
||||
</button>
|
||||
</view>
|
||||
<ui-btn
|
||||
variant="danger"
|
||||
size="md"
|
||||
block
|
||||
text="清除所有训练记录"
|
||||
icon-src="{{icons.delete}}"
|
||||
bindtap="onClearData"
|
||||
></ui-btn>
|
||||
</ui-card>
|
||||
|
||||
<!-- 关于 -->
|
||||
<view class="card about-card">
|
||||
<ui-card variant="in">
|
||||
<view class="section-header">
|
||||
<text class="section-emoji">ℹ</text>
|
||||
<image class="section-icon" src="{{icons.infoFill}}" mode="aspectFit"></image>
|
||||
<text class="section-title">关于</text>
|
||||
</view>
|
||||
<view class="about-list">
|
||||
<view class="about-row">
|
||||
<text class="about-label">版本</text>
|
||||
<text class="about-value">v1.2.0</text>
|
||||
<text class="about-value">v1.5</text>
|
||||
</view>
|
||||
<view class="about-row">
|
||||
<text class="about-label">更新日期</text>
|
||||
<text class="about-value">2026-06-04</text>
|
||||
<text class="about-value">2026-06-10</text>
|
||||
</view>
|
||||
<view class="about-row" bindtap="onCopyWechat">
|
||||
<text class="about-label">开发者</text>
|
||||
@@ -126,5 +112,99 @@
|
||||
<view class="about-footer">
|
||||
<text class="about-copy">Made with ❤️ for better fitness</text>
|
||||
</view>
|
||||
</ui-card>
|
||||
|
||||
<!-- 训练计划编辑器:用普通 view 不用 ui-modal,
|
||||
因为 WeChat 自定义组件根元素上 position: fixed 会被吃,导致 body 被推到容器末尾。
|
||||
普通 view 的 position: fixed 在小程序里 100% 生效。 -->
|
||||
<view wx:if="{{showPlanEditor}}" class="plan-editor-mask" catchtouchmove="onNoop">
|
||||
<view class="plan-editor-mask__bg" bindtap="onCloseEditor"></view>
|
||||
<view class="plan-editor-sheet" catchtap="onNoop">
|
||||
<view class="plan-editor-handle"></view>
|
||||
<view class="editor-title">编辑训练计划</view>
|
||||
|
||||
<view class="editor-field">
|
||||
<text class="editor-label">计划名称</text>
|
||||
<input
|
||||
class="editor-input"
|
||||
type="text"
|
||||
value="{{editingDraft.name}}"
|
||||
bindinput="onEditName"
|
||||
placeholder="例如:初级"
|
||||
maxlength="20"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="editor-field">
|
||||
<text class="editor-label">总天数</text>
|
||||
<input
|
||||
class="editor-input"
|
||||
type="number"
|
||||
value="{{editingDraft.totalDays}}"
|
||||
bindinput="onEditTotalDays"
|
||||
placeholder="1-365"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="editor-row">
|
||||
<view class="editor-field-half">
|
||||
<text class="editor-label">起始时长(秒)</text>
|
||||
<input
|
||||
class="editor-input"
|
||||
type="number"
|
||||
value="{{editingDraft.startTarget}}"
|
||||
bindinput="onEditStartTarget"
|
||||
placeholder="5-3600"
|
||||
/>
|
||||
</view>
|
||||
<view class="editor-field-half">
|
||||
<text class="editor-label">每次增加(秒)</text>
|
||||
<input
|
||||
class="editor-input"
|
||||
type="number"
|
||||
value="{{editingDraft.increment}}"
|
||||
bindinput="onEditIncrement"
|
||||
placeholder="0-300"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="editor-field">
|
||||
<text class="editor-label">每几天增加</text>
|
||||
<input
|
||||
class="editor-input"
|
||||
type="number"
|
||||
value="{{editingDraft.cycleDays}}"
|
||||
bindinput="onEditCycleDays"
|
||||
placeholder="1-30"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="editor-preview">
|
||||
<view class="editor-preview-head">
|
||||
<text class="editor-preview-title">预览</text>
|
||||
<text class="editor-preview-meta">共 {{editorPreview.length}} 天</text>
|
||||
</view>
|
||||
<view class="editor-preview-list">
|
||||
<view class="editor-preview-row" wx:for="{{editorPreview}}" wx:key="day">
|
||||
<text class="editor-preview-day">第 {{item.day}} 天</text>
|
||||
<text class="editor-preview-target">{{item.target}} 秒</text>
|
||||
</view>
|
||||
<view class="editor-preview-empty" wx:if="{{editorPreview.length === 0}}">
|
||||
<text>请填写总天数</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="editor-actions">
|
||||
<view class="editor-action-reset" wx:if="{{editingIsCustom}}" bindtap="onResetPlan">
|
||||
<text>恢复默认</text>
|
||||
</view>
|
||||
<view class="editor-action-save" bindtap="onSavePlan">
|
||||
<text>保存</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="editor-cancel" bindtap="onCloseEditor">取消</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
+255
-33
@@ -4,10 +4,10 @@
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.section-emoji {
|
||||
font-size: 28rpx;
|
||||
.section-icon {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
margin-right: 10rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.section-header .section-title {
|
||||
@@ -15,9 +15,7 @@
|
||||
}
|
||||
|
||||
/* ---- theme picker ---- */
|
||||
.theme-list {
|
||||
margin-top: 0;
|
||||
}
|
||||
.theme-list { margin-top: 0; }
|
||||
|
||||
.theme-item {
|
||||
display: flex;
|
||||
@@ -27,9 +25,7 @@
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.theme-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.theme-item:last-child { border-bottom: none; }
|
||||
|
||||
.theme-item.active {
|
||||
background: var(--primary-bg);
|
||||
@@ -64,11 +60,14 @@
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ---- plan list ---- */
|
||||
.plan-list {
|
||||
margin-top: 0;
|
||||
.theme-check-img {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
}
|
||||
|
||||
/* ---- plan list ---- */
|
||||
.plan-list { margin-top: 0; }
|
||||
|
||||
.plan-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -76,9 +75,7 @@
|
||||
border-bottom: 1rpx solid var(--border);
|
||||
}
|
||||
|
||||
.plan-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.plan-item:last-child { border-bottom: none; }
|
||||
|
||||
.plan-item.active {
|
||||
background: var(--primary-bg);
|
||||
@@ -132,10 +129,41 @@
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.plan-check {
|
||||
color: var(--primary);
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
.plan-check-img {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
}
|
||||
|
||||
.plan-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.plan-edit-btn {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.plan-edit-btn:active {
|
||||
background: rgba(var(--primary-rgb), 0.12);
|
||||
}
|
||||
|
||||
.plan-edit-img {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.plan-item.active .plan-edit-img {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* ---- setting rows ---- */
|
||||
@@ -147,9 +175,7 @@
|
||||
border-bottom: 1rpx solid var(--border);
|
||||
}
|
||||
|
||||
.setting-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.setting-row:last-child { border-bottom: none; }
|
||||
|
||||
.setting-left {
|
||||
display: flex;
|
||||
@@ -157,7 +183,8 @@
|
||||
}
|
||||
|
||||
.setting-icon {
|
||||
font-size: 28rpx;
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
margin-right: 12rpx;
|
||||
}
|
||||
|
||||
@@ -172,13 +199,7 @@
|
||||
}
|
||||
|
||||
/* ---- about ---- */
|
||||
.about-card {
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.about-list {
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
.about-list { margin-top: 12rpx; }
|
||||
|
||||
.about-row {
|
||||
display: flex;
|
||||
@@ -188,9 +209,7 @@
|
||||
border-bottom: 1rpx solid var(--border);
|
||||
}
|
||||
|
||||
.about-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.about-row:last-child { border-bottom: none; }
|
||||
|
||||
.about-label {
|
||||
font-size: 26rpx;
|
||||
@@ -218,3 +237,206 @@
|
||||
font-size: 22rpx;
|
||||
color: #CCCCCC;
|
||||
}
|
||||
|
||||
/* ---- plan editor modal ---- */
|
||||
/* 普通 view 实现的底部 sheet,不用 ui-modal 是因为 WeChat 自定义组件根元素
|
||||
上 position: fixed 会被运行时吞掉,导致 body 落到容器末尾(非浮层)。 */
|
||||
.plan-editor-mask {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.plan-editor-mask__bg {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
animation: planEditorFade 0.25s ease;
|
||||
}
|
||||
|
||||
.plan-editor-sheet {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-height: 88vh;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
background: #FFFFFF;
|
||||
border-radius: 32rpx 32rpx 0 0;
|
||||
padding: 12rpx 32rpx 40rpx;
|
||||
/* Sheet is z-index 1000 above the tab bar (z-index 999), but the tab bar
|
||||
still visually covers the bottom of the sheet. Add the tab bar's
|
||||
height (110rpx + safe area) to padding-bottom so "保存/取消" sit
|
||||
clearly above the tab bar. */
|
||||
padding-bottom: calc(150rpx + env(safe-area-inset-bottom));
|
||||
animation: planEditorSlideUp 0.3s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
.plan-editor-handle {
|
||||
width: 64rpx;
|
||||
height: 8rpx;
|
||||
background: #DDD;
|
||||
border-radius: 4rpx;
|
||||
margin: 0 auto 16rpx;
|
||||
}
|
||||
|
||||
@keyframes planEditorFade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes planEditorSlideUp {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
.editor-title {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.editor-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 14rpx;
|
||||
}
|
||||
|
||||
.editor-row {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.editor-row .editor-field-half {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.editor-label {
|
||||
font-size: 22rpx;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.editor-input {
|
||||
height: 68rpx;
|
||||
background: #F5F5F5;
|
||||
border-radius: 10rpx;
|
||||
padding: 0 16rpx;
|
||||
font-size: 26rpx;
|
||||
color: var(--text);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editor-preview {
|
||||
margin-top: 6rpx;
|
||||
background: #FAFAFA;
|
||||
border-radius: 14rpx;
|
||||
padding: 12rpx 16rpx;
|
||||
}
|
||||
|
||||
.editor-preview-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.editor-preview-title {
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.editor-preview-meta {
|
||||
font-size: 20rpx;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.editor-preview-list {
|
||||
/* No fixed max-height: the modal body scrolls, preview expands inline. */
|
||||
min-height: 60rpx;
|
||||
}
|
||||
|
||||
.editor-preview-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8rpx 0;
|
||||
border-bottom: 1rpx solid #EEE;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.editor-preview-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.editor-preview-day {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.editor-preview-target {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.editor-preview-empty {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 22rpx;
|
||||
padding: 16rpx 0;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.editor-action-reset,
|
||||
.editor-action-save {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 40rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.editor-action-reset {
|
||||
background: #FFFFFF;
|
||||
color: var(--text-secondary);
|
||||
border: 2rpx solid var(--border);
|
||||
}
|
||||
|
||||
.editor-action-reset:active {
|
||||
background: #F5F5F5;
|
||||
}
|
||||
|
||||
.editor-action-save {
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-light));
|
||||
color: #FFFFFF;
|
||||
box-shadow: 0 4rpx 12rpx rgba(var(--primary-rgb), 0.25);
|
||||
}
|
||||
|
||||
.editor-action-save:active {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.editor-cancel {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 26rpx;
|
||||
color: var(--text-secondary);
|
||||
padding: 12rpx 0 0;
|
||||
}
|
||||
|
||||
+96
-8
@@ -2,6 +2,8 @@ const Timer = require('../../utils/timer')
|
||||
const storage = require('../../utils/storage')
|
||||
const planMod = require('../../utils/plan')
|
||||
const themeMod = require('../../utils/theme')
|
||||
const iconsMod = require('../../utils/icons')
|
||||
const voice = require('../../utils/voice')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
@@ -16,7 +18,8 @@ Page({
|
||||
overtime: 0,
|
||||
todayTarget: 0,
|
||||
planDay: 1,
|
||||
isFreeMode: false
|
||||
isFreeMode: false,
|
||||
icons: iconsMod.build()
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
@@ -26,22 +29,25 @@ Page({
|
||||
|
||||
onShow() {
|
||||
themeMod.applyThemeToPage(this)
|
||||
const theme = themeMod.getCurrentTheme()
|
||||
this.setData({ theme, icons: iconsMod.build(theme) })
|
||||
},
|
||||
|
||||
_init(options) {
|
||||
|
||||
let target
|
||||
let planDay = 1
|
||||
let isFreeMode = false
|
||||
|
||||
if (options && options.free) {
|
||||
target = parseInt(options.free) || 60
|
||||
const parsed = parseInt(options.free)
|
||||
target = parsed > 0 ? parsed : 60
|
||||
isFreeMode = true
|
||||
} else {
|
||||
const settings = storage.getSettings()
|
||||
const allRecords = Object.values(storage.getRecords()).flat()
|
||||
const plan = planMod.getPlan(settings.planId)
|
||||
planDay = planMod.getPlanDay(settings.planId, allRecords)
|
||||
const customPlans = storage.getCustomPlans()
|
||||
const plan = planMod.getPlan(settings.planId, customPlans)
|
||||
planDay = planMod.getPlanDay(settings.planId, allRecords, settings.planStartDate)
|
||||
target = planMod.getTodayTarget(settings.planId, Math.min(planDay, plan.totalDays))
|
||||
}
|
||||
|
||||
@@ -53,25 +59,101 @@ Page({
|
||||
isFreeMode
|
||||
})
|
||||
|
||||
if (this._timer) {
|
||||
this._timer.stop()
|
||||
this._timer = null
|
||||
}
|
||||
|
||||
// Reset reminder state
|
||||
this._halfwaySignaled = false
|
||||
this._lastMinuteAt = 0
|
||||
this._lastCountdown = 0
|
||||
|
||||
this._timer = new Timer({
|
||||
onTick: (tick) => {
|
||||
this.setData({
|
||||
remaining: tick.remaining > 0 ? tick.remaining : 0,
|
||||
overtime: tick.remaining <= 0 ? tick.elapsed - this.data.duration : 0
|
||||
})
|
||||
this._remind(tick)
|
||||
},
|
||||
onComplete: () => {
|
||||
this.setData({ status: 'completed', isCompleted: true })
|
||||
if (storage.getSettings().vibrate) {
|
||||
const s = storage.getSettings()
|
||||
if (s.vibrate) {
|
||||
try { wx.vibrateLong() } catch (e) {}
|
||||
}
|
||||
// 4th voice prompt: completion
|
||||
if (s.voiceGuide !== false) {
|
||||
voice.play('complete')
|
||||
}
|
||||
wx.showToast({ title: '目标完成! 太棒了!', icon: 'success', duration: 2000 })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Per-tick reminders: vibration + voice prompts at 4 fixed points.
|
||||
* - halfway: once when elapsed reaches half of duration
|
||||
* - 30s: once when remaining hits 30
|
||||
* - 10s: once when remaining hits 10
|
||||
* - complete: fired in onComplete above
|
||||
*
|
||||
* Vibration and voice are independently gated by `vibrate` / `voiceGuide`
|
||||
* settings — user can mute haptics but keep voice (e.g. in office) or vice versa.
|
||||
*/
|
||||
_remind(tick) {
|
||||
const s = storage.getSettings()
|
||||
const { remaining, elapsed, duration } = tick
|
||||
const vibrate = (n, ms) => {
|
||||
if (!s.vibrate) return
|
||||
try {
|
||||
for (let i = 0; i < n; i++) {
|
||||
setTimeout(() => wx.vibrateShort(), i * (ms + 30))
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Each prompt is gated on a minimum duration so short free-mode
|
||||
// trainings don't trigger them at nonsensical times (e.g. firing
|
||||
// "最后30秒" on the first tick of a 30s session because remaining
|
||||
// happens to equal 30 at elapsed=0).
|
||||
//
|
||||
// Thresholds chosen so prompts fire in the right ORDER for any
|
||||
// duration: halfway first, then last30, then last10.
|
||||
|
||||
// 1. Halfway: needs >=10s total so the midpoint is at least 5s in
|
||||
if (duration >= 10 && !this._halfwaySignaled && elapsed >= Math.floor(duration / 2)) {
|
||||
this._halfwaySignaled = true
|
||||
if (s.voiceGuide !== false) voice.play('halfway')
|
||||
vibrate(2, 150)
|
||||
}
|
||||
|
||||
// 2. Last 30 seconds: only if duration > 60s so halfway has already passed
|
||||
if (duration > 60 && remaining === 30 && !this._last30Signaled) {
|
||||
this._last30Signaled = true
|
||||
if (s.voiceGuide !== false) voice.play('last30')
|
||||
}
|
||||
|
||||
// 3. Last 10 seconds: only if duration > 20s so we have at least 10s of training
|
||||
if (duration > 20 && remaining === 10 && !this._last10Signaled) {
|
||||
this._last10Signaled = true
|
||||
if (s.voiceGuide !== false) voice.play('last10')
|
||||
}
|
||||
|
||||
// 4. Countdown buzzes at 5..1: only if duration >= 6s (so we can reach them)
|
||||
if (duration >= 6 && remaining <= 5 && remaining > 0 && remaining !== this._lastCountdown) {
|
||||
this._lastCountdown = remaining
|
||||
vibrate(1, 100)
|
||||
}
|
||||
},
|
||||
|
||||
onUnload() {
|
||||
if (this._timer) this._timer.stop()
|
||||
voice.stop()
|
||||
if (this._timer) {
|
||||
this._timer.stop()
|
||||
this._timer = null
|
||||
}
|
||||
},
|
||||
|
||||
onStart() {
|
||||
@@ -88,6 +170,7 @@ Page({
|
||||
onPause() {
|
||||
if (!this.data.isRunning || this.data.isPaused) return
|
||||
this._timer.pause()
|
||||
voice.stop()
|
||||
this.setData({ isPaused: true, status: 'paused' })
|
||||
},
|
||||
|
||||
@@ -103,6 +186,11 @@ Page({
|
||||
|
||||
finishTraining() {
|
||||
const elapsed = this._timer.stop()
|
||||
if (elapsed < 3) {
|
||||
wx.showToast({ title: '训练时间太短', icon: 'none', duration: 1500 })
|
||||
setTimeout(() => { wx.navigateBack() }, 1500)
|
||||
return
|
||||
}
|
||||
const today = storage.getToday()
|
||||
storage.saveRecord({
|
||||
date: today,
|
||||
@@ -113,6 +201,6 @@ Page({
|
||||
storage.updateStreak(today)
|
||||
|
||||
wx.showToast({ title: `已记录 ${elapsed}秒`, icon: 'none', duration: 1500 })
|
||||
setTimeout(() => { wx.navigateBack() }, 3000)
|
||||
setTimeout(() => { wx.navigateBack() }, 1500)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"progress-ring": "../../components/progress-ring/progress-ring"
|
||||
"progress-ring": "/components/progress-ring/progress-ring",
|
||||
"ui-btn": "/components/ui-btn/ui-btn"
|
||||
},
|
||||
"navigationBarTitleText": "训练中"
|
||||
}
|
||||
|
||||
+33
-27
@@ -30,7 +30,7 @@
|
||||
</view>
|
||||
|
||||
<view class="overtime-badge" wx:if="{{isCompleted && overtime > 0}}">
|
||||
<text class="overtime-icon">🔥</text>
|
||||
<image class="overtime-icon" src="{{icons.hotFill}}" mode="aspectFit"></image>
|
||||
<text>+{{overtime}}s</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -38,58 +38,64 @@
|
||||
<!-- 状态提示 -->
|
||||
<view class="hint-section">
|
||||
<view class="hint-row" wx:if="{{status === 'idle'}}">
|
||||
<text class="hint-emoji">🧘</text>
|
||||
<image class="hint-icon" src="{{icons.targetFill}}" mode="aspectFit"></image>
|
||||
<text class="hint-text">
|
||||
<block wx:if="{{isFreeMode}}">自由训练 · 目标 {{duration}} 秒</block>
|
||||
<block wx:else>今日目标 {{duration}} 秒 · 第 {{planDay}} 天</block>
|
||||
</text>
|
||||
</view>
|
||||
<view class="hint-row" wx:elif="{{status === 'running'}}">
|
||||
<text class="hint-emoji running-pulse">💪</text>
|
||||
<image class="hint-icon running-pulse" src="{{icons.likeFill}}" mode="aspectFit"></image>
|
||||
<text class="hint-text running">保持姿势,核心收紧!</text>
|
||||
</view>
|
||||
<view class="hint-row" wx:elif="{{status === 'paused'}}">
|
||||
<text class="hint-emoji">⏸</text>
|
||||
<image class="hint-icon" src="{{icons.notificationForbidFill}}" mode="aspectFit"></image>
|
||||
<text class="hint-text paused">已暂停</text>
|
||||
</view>
|
||||
<view class="hint-row completed-hint" wx:elif="{{status === 'completed'}}">
|
||||
<text class="hint-emoji completed-bounce">🎉</text>
|
||||
<image class="hint-icon completed-bounce" src="{{icons.roundCheckFill}}" mode="aspectFit"></image>
|
||||
<text class="hint-text completed">目标完成! 继续坚持!</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 控制按钮 -->
|
||||
<view class="controls">
|
||||
<button
|
||||
<ui-btn
|
||||
wx:if="{{!isRunning && !isPaused}}"
|
||||
class="ctrl-btn start-btn"
|
||||
variant="primary"
|
||||
size="xl"
|
||||
block
|
||||
text="开始"
|
||||
icon-src="{{icons.playFill}}"
|
||||
bindtap="onStart"
|
||||
>
|
||||
<text class="ctrl-icon">▶</text>
|
||||
<text>开始</text>
|
||||
</button>
|
||||
></ui-btn>
|
||||
|
||||
<view class="ctrl-row" wx:if="{{isRunning || isPaused}}">
|
||||
<button
|
||||
<ui-btn
|
||||
wx:if="{{!isPaused}}"
|
||||
class="ctrl-btn pause-btn"
|
||||
variant="ghost"
|
||||
size="xl"
|
||||
text="暂停"
|
||||
icon-src="{{icons.notificationForbidFill}}"
|
||||
custom-style="margin-right: 28rpx;"
|
||||
bindtap="onPause"
|
||||
>
|
||||
<text class="ctrl-icon">⏸</text>
|
||||
<text>暂停</text>
|
||||
</button>
|
||||
<button
|
||||
></ui-btn>
|
||||
<ui-btn
|
||||
wx:if="{{isPaused}}"
|
||||
class="ctrl-btn resume-btn"
|
||||
variant="primary"
|
||||
size="xl"
|
||||
text="继续"
|
||||
icon-src="{{icons.playFill}}"
|
||||
custom-style="margin-right: 28rpx;"
|
||||
bindtap="onStart"
|
||||
>
|
||||
<text class="ctrl-icon">▶</text>
|
||||
<text>继续</text>
|
||||
</button>
|
||||
<button class="ctrl-btn stop-btn" bindtap="onStop">
|
||||
<text class="ctrl-icon">⏹</text>
|
||||
<text>结束</text>
|
||||
</button>
|
||||
></ui-btn>
|
||||
<ui-btn
|
||||
variant="outline"
|
||||
size="xl"
|
||||
text="结束"
|
||||
icon-src="{{icons.stop}}"
|
||||
bindtap="onStop"
|
||||
></ui-btn>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
+20
-124
@@ -27,46 +27,14 @@
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.breathe-ring-1 {
|
||||
width: 480rpx;
|
||||
height: 480rpx;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
.breathe-ring-1 { width: 480rpx; height: 480rpx; animation-delay: 0s; }
|
||||
.breathe-ring-2 { width: 400rpx; height: 400rpx; animation-delay: 0.5s; border-color: rgba(var(--primary-rgb), 0.25); border-width: 4rpx; }
|
||||
.breathe-ring-3 { width: 320rpx; height: 320rpx; animation-delay: 1s; border-color: rgba(var(--primary-rgb), 0.35); border-width: 5rpx; }
|
||||
|
||||
.breathe-ring-2 {
|
||||
width: 400rpx;
|
||||
height: 400rpx;
|
||||
animation-delay: 0.5s;
|
||||
border-color: rgba(var(--primary-rgb), 0.25);
|
||||
border-width: 4rpx;
|
||||
}
|
||||
|
||||
.breathe-ring-3 {
|
||||
width: 320rpx;
|
||||
height: 320rpx;
|
||||
animation-delay: 1s;
|
||||
border-color: rgba(var(--primary-rgb), 0.35);
|
||||
border-width: 5rpx;
|
||||
}
|
||||
|
||||
.breathe-ring.fast {
|
||||
animation: breathePulseFast 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.breathe-ring.fast.breathe-ring-1 {
|
||||
animation-delay: 0s;
|
||||
border-color: rgba(var(--primary-rgb), 0.3);
|
||||
}
|
||||
|
||||
.breathe-ring.fast.breathe-ring-2 {
|
||||
animation-delay: 0.25s;
|
||||
border-color: rgba(var(--primary-rgb), 0.45);
|
||||
}
|
||||
|
||||
.breathe-ring.fast.breathe-ring-3 {
|
||||
animation-delay: 0.5s;
|
||||
border-color: rgba(var(--primary-rgb), 0.6);
|
||||
}
|
||||
.breathe-ring.fast { animation: breathePulseFast 1.4s ease-in-out infinite; }
|
||||
.breathe-ring.fast.breathe-ring-1 { animation-delay: 0s; border-color: rgba(var(--primary-rgb), 0.3); }
|
||||
.breathe-ring.fast.breathe-ring-2 { animation-delay: 0.25s; border-color: rgba(var(--primary-rgb), 0.45); }
|
||||
.breathe-ring.fast.breathe-ring-3 { animation-delay: 0.5s; border-color: rgba(var(--primary-rgb), 0.6); }
|
||||
|
||||
/* celebration burst */
|
||||
.celebrate-burst {
|
||||
@@ -91,18 +59,9 @@
|
||||
.s6 { top: -20rpx; left: 40rpx; animation-delay: 0.05s; }
|
||||
|
||||
@keyframes sparkBurst {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(0, 0) scale(0.3);
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
transform: translate(0, -30rpx) scale(1.3);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(0, -80rpx) scale(0.4);
|
||||
}
|
||||
0% { opacity: 0; transform: translate(0, 0) scale(0.3); }
|
||||
40% { opacity: 1; transform: translate(0, -30rpx) scale(1.3); }
|
||||
100% { opacity: 0; transform: translate(0, -80rpx) scale(0.4); }
|
||||
}
|
||||
|
||||
/* overtime badge */
|
||||
@@ -125,8 +84,8 @@
|
||||
}
|
||||
|
||||
.overtime-icon {
|
||||
font-size: 28rpx;
|
||||
line-height: 1;
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
}
|
||||
|
||||
/* ---- hint section ---- */
|
||||
@@ -142,9 +101,9 @@
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.hint-emoji {
|
||||
font-size: 28rpx;
|
||||
line-height: 1;
|
||||
.hint-icon {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
}
|
||||
|
||||
.running-pulse {
|
||||
@@ -161,23 +120,11 @@
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.hint-text.running {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.hint-text.running { color: var(--primary); font-weight: 600; }
|
||||
.hint-text.paused { color: var(--primary-light); }
|
||||
.hint-text.completed { color: var(--success); font-weight: 600; }
|
||||
|
||||
.hint-text.paused {
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.hint-text.completed {
|
||||
color: var(--success);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.completed-hint {
|
||||
animation: popIn 0.5s ease;
|
||||
}
|
||||
.completed-hint { animation: popIn 0.5s ease; }
|
||||
|
||||
/* ---- controls ---- */
|
||||
.controls {
|
||||
@@ -187,59 +134,8 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ctrl-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
width: 300rpx;
|
||||
height: 108rpx;
|
||||
border-radius: 54rpx;
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ctrl-btn:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
.ctrl-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.ctrl-icon {
|
||||
font-size: 32rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.start-btn {
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-light));
|
||||
color: #fff;
|
||||
box-shadow: 0 8rpx 28rpx rgba(var(--primary-rgb), 0.35);
|
||||
}
|
||||
|
||||
.pause-btn {
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary);
|
||||
margin-right: 28rpx;
|
||||
}
|
||||
|
||||
.resume-btn {
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-light));
|
||||
color: #fff;
|
||||
margin-right: 28rpx;
|
||||
box-shadow: 0 8rpx 28rpx rgba(var(--primary-rgb), 0.35);
|
||||
}
|
||||
|
||||
.stop-btn {
|
||||
background: #F5F5F5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ctrl-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
+2
-1
@@ -56,5 +56,6 @@
|
||||
"tabIndent": "insertSpaces",
|
||||
"tabSize": 2
|
||||
},
|
||||
"simulatorPluginLibVersion": {}
|
||||
"simulatorPluginLibVersion": {},
|
||||
"cloudfunctionTemplateRoot": "cloudfunctionTemplate/"
|
||||
}
|
||||
+50
-21
@@ -1,20 +1,22 @@
|
||||
const DB_COLLECTION = 'plank_data'
|
||||
const ENV_ID = 'cloudbase-d1g56kl2q8f4f7d8a'
|
||||
|
||||
let _db = null
|
||||
let _pushTimer = null
|
||||
let _enabled = false
|
||||
let _docId = null // cache doc id after first successful query
|
||||
|
||||
const getDb = () => {
|
||||
if (!_enabled) return null
|
||||
if (!_db) {
|
||||
try { _db = wx.cloud.database() } catch (e) { _enabled = false }
|
||||
try { _db = wx.cloud.database({ env: ENV_ID }) } catch (e) { _enabled = false }
|
||||
}
|
||||
return _db
|
||||
}
|
||||
|
||||
const init = () => {
|
||||
try {
|
||||
wx.cloud.init({ env: 'cloudbase-d1g56kl2q8f4f7d8a', traceUser: true })
|
||||
wx.cloud.init({ env: ENV_ID, traceUser: true })
|
||||
_enabled = true
|
||||
} catch (e) {
|
||||
_enabled = false
|
||||
@@ -25,10 +27,22 @@ const pullAll = async () => {
|
||||
const db = getDb()
|
||||
if (!db) return null
|
||||
try {
|
||||
const res = await db.collection(DB_COLLECTION)
|
||||
.where({ _openid: '{openid}' })
|
||||
.get()
|
||||
return (res && res.data && res.data.length > 0) ? res.data[0] : null
|
||||
// First try cached doc id
|
||||
if (_docId) {
|
||||
try {
|
||||
const res = await db.collection(DB_COLLECTION).doc(_docId).get()
|
||||
if (res && res.data) return res.data
|
||||
} catch (e) {
|
||||
_docId = null // doc deleted or inaccessible
|
||||
}
|
||||
}
|
||||
// Query: rely on security rules to scope to current user
|
||||
const res = await db.collection(DB_COLLECTION).limit(1).get()
|
||||
if (res && res.data && res.data.length > 0) {
|
||||
_docId = res.data[0]._id
|
||||
return res.data[0]
|
||||
}
|
||||
return null
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
@@ -51,20 +65,29 @@ const _doPush = async () => {
|
||||
records: storage.getRecords(),
|
||||
settings: storage.getSettings(),
|
||||
streak: storage.getStreak(),
|
||||
customPlans: storage.getCustomPlans(),
|
||||
themeId: themeMod.getCurrentTheme().id,
|
||||
updatedAt: db.serverDate()
|
||||
}
|
||||
|
||||
// Query then upsert: one doc per user
|
||||
const existing = await db.collection(DB_COLLECTION)
|
||||
.where({ _openid: '{openid}' })
|
||||
.get()
|
||||
// Use cached doc id if available
|
||||
if (_docId) {
|
||||
try {
|
||||
await db.collection(DB_COLLECTION).doc(_docId).update({ data })
|
||||
return
|
||||
} catch (e) {
|
||||
_docId = null // doc may have been deleted
|
||||
}
|
||||
}
|
||||
|
||||
// Query for existing doc (relies on security rules to scope to current user)
|
||||
const existing = await db.collection(DB_COLLECTION).limit(1).get()
|
||||
if (existing && existing.data && existing.data.length > 0) {
|
||||
await db.collection(DB_COLLECTION)
|
||||
.doc(existing.data[0]._id)
|
||||
.update({ data })
|
||||
_docId = existing.data[0]._id
|
||||
await db.collection(DB_COLLECTION).doc(_docId).update({ data })
|
||||
} else {
|
||||
await db.collection(DB_COLLECTION).add({ data })
|
||||
const res = await db.collection(DB_COLLECTION).add({ data })
|
||||
if (res && res._id) _docId = res._id
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently retry on next write
|
||||
@@ -75,14 +98,20 @@ const clearAll = async () => {
|
||||
const db = getDb()
|
||||
if (!db) return
|
||||
try {
|
||||
const existing = await db.collection(DB_COLLECTION)
|
||||
.where({ _openid: '{openid}' })
|
||||
.get()
|
||||
if (existing && existing.data && existing.data.length > 0) {
|
||||
await db.collection(DB_COLLECTION)
|
||||
.doc(existing.data[0]._id)
|
||||
.remove()
|
||||
if (_docId) {
|
||||
try {
|
||||
await db.collection(DB_COLLECTION).doc(_docId).remove()
|
||||
_docId = null
|
||||
return
|
||||
} catch (e) {
|
||||
_docId = null
|
||||
}
|
||||
}
|
||||
const existing = await db.collection(DB_COLLECTION).limit(1).get()
|
||||
if (existing && existing.data && existing.data.length > 0) {
|
||||
await db.collection(DB_COLLECTION).doc(existing.data[0]._id).remove()
|
||||
}
|
||||
_docId = null
|
||||
} catch (e) {
|
||||
// Nothing to clear
|
||||
}
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Inline SVG icon library for the plank training app.
|
||||
*
|
||||
* Each icon is a 24x24 Material-style path. We bake the color directly into
|
||||
* the SVG data URI, so consumers can switch between "active" and "inactive"
|
||||
* variants by simply selecting a different key — no font loading, no CSS
|
||||
* pseudo-element tricks, and exact pixel rendering at any size.
|
||||
*
|
||||
* Usage:
|
||||
* const icons = require('../../utils/icons')
|
||||
* const map = icons.build({ primary: '#FF6B35', primaryLight: '#FF8C5A', success: '#4CAF50' })
|
||||
* // map.playFill => "data:image/svg+xml;..." (orange play)
|
||||
* // map.play => "data:image/svg+xml;..." (gray play)
|
||||
*
|
||||
* Naming convention:
|
||||
* <name> = outlined / inactive (neutral gray)
|
||||
* <name>Fill = filled / active (theme color)
|
||||
*/
|
||||
|
||||
const PATHS = {
|
||||
// Navigation & UI
|
||||
home: '<path d="M12 5.2L5.5 11H7v8h2v-6h6v6h2v-8h1.5L12 5.2M12 3l9 8h-3v9h-5v-6h-2v6H6v-9H3l9-8z" fill-rule="evenodd"/>',
|
||||
homeFill: '<path d="M12 3l9 8h-3v9h-5v-6h-2v6H6v-9H3l9-8z"/>',
|
||||
calendar: '<path d="M19 4h-2V2h-2v2H9V2H7v2H5C3.9 4 3 4.9 3 6v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V10h14v10zm0-12H5V6h14v2z" fill-rule="evenodd"/>',
|
||||
calendarFill: '<path d="M19 4h-2V2h-2v2H9V2H7v2H5C3.9 4 3 4.9 3 6v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V10h14v10zm0-12H5V6h14v2zM7 12h5v5H7z"/>',
|
||||
settings: '<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" fill-rule="evenodd"/>',
|
||||
settingsFill: '<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/>',
|
||||
back: '<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>',
|
||||
right: '<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>',
|
||||
close: '<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>',
|
||||
check: '<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>',
|
||||
// Status & feedback
|
||||
hot: '<path d="M13.5.67s.74 2.65.74 4.8c0 2.06-1.35 3.73-3.41 3.73-2.07 0-3.63-1.67-3.63-3.73l.03-.36C5.21 7.51 4 10.62 4 14c0 4.42 3.58 8 8 8s8-3.58 8-8C20 8.61 17.41 3.8 13.5.67zM11.71 19c-1.78 0-3.22-1.4-3.22-3.14 0-1.62 1.05-2.76 2.81-3.12 1.77-.36 3.6-1.21 4.62-2.58.39 1.29.59 2.65.59 4.04 0 2.65-2.15 4.8-4.8 4.8z" fill-rule="evenodd"/>',
|
||||
hotFill: '<path d="M13.5.67s.74 2.65.74 4.8c0 2.06-1.35 3.73-3.41 3.73-2.07 0-3.63-1.67-3.63-3.73l.03-.36C5.21 7.51 4 10.62 4 14c0 4.42 3.58 8 8 8s8-3.58 8-8C20 8.61 17.41 3.8 13.5.67zM11.71 19c-1.78 0-3.22-1.4-3.22-3.14 0-1.62 1.05-2.76 2.81-3.12 1.77-.36 3.6-1.21 4.62-2.58.39 1.29.59 2.65.59 4.04 0 2.65-2.15 4.8-4.8 4.8z"/>',
|
||||
like: '<path d="M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z" fill-rule="evenodd"/>',
|
||||
likeFill: '<path d="M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"/>',
|
||||
// Training specific
|
||||
play: '<path d="M8 5v14l11-7z" fill-rule="evenodd"/>',
|
||||
playFill: '<path d="M8 5v14l11-7z"/>',
|
||||
stop: '<path d="M6 6h12v12H6z" fill-rule="evenodd"/>',
|
||||
stopFill: '<path d="M6 6h12v12H6z"/>',
|
||||
pause: '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" fill-rule="evenodd"/>',
|
||||
pauseFill: '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>',
|
||||
time: '<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z" fill-rule="evenodd"/>',
|
||||
timeFill: '<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/>',
|
||||
// Markers & targets
|
||||
target: '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z" fill-rule="evenodd"/><circle cx="12" cy="12" r="5" fill-rule="evenodd"/><circle cx="12" cy="12" r="2"/>',
|
||||
targetFill: '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm0-14c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm0 10c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>',
|
||||
mark: '<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 010-5 2.5 2.5 0 010 5z" fill-rule="evenodd"/>',
|
||||
markFill: '<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 010-5 2.5 2.5 0 010 5z"/>',
|
||||
// Stats & charts
|
||||
rank: '<path d="M3.5 18.49l6-6.01 4 4L22 6.92l-1.41-1.41-7.09 7.97-4-4L2 16.99z" fill-rule="evenodd"/>',
|
||||
rankFill: '<path d="M3.5 18.49l6-6.01 4 4L22 6.92l-1.41-1.41-7.09 7.97-4-4L2 16.99z"/>',
|
||||
crown: '<path d="M5 16L3 5l5.5 5L12 4l3.5 6L21 5l-2 11H5zm0 2h14v2H5z" fill-rule="evenodd"/>',
|
||||
crownFill: '<path d="M5 16L3 5l5.5 5L12 4l3.5 6L21 5l-2 11H5zm0 2h14v2H5z"/>',
|
||||
form: '<path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z" fill-rule="evenodd"/>',
|
||||
formFill: '<path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/>',
|
||||
// Notification
|
||||
notification: '<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" fill-rule="evenodd"/>',
|
||||
notificationFill: '<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>',
|
||||
notificationForbid: '<path d="M20 18.69L7.84 6.14 5.27 3.49 4 4.76l2.8 2.8v.01c-.52.99-.8 2.16-.8 3.43v5l-2 2v1h13.69l2 2L21 19.72l-1-1.03zM12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6.69V11c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68c-.15.03-.29.07-.43.11-.07.02-.14.04-.2.06-.15.05-.29.1-.43.16-.06.02-.12.05-.18.08-.14.06-.28.13-.41.2-.05.03-.1.05-.15.08L18 14.31z" fill-rule="evenodd"/>',
|
||||
notificationForbidFill: '<path d="M20 18.69L7.84 6.14 5.27 3.49 4 4.76l2.8 2.8v.01c-.52.99-.8 2.16-.8 3.43v5l-2 2v1h13.69l2 2L21 19.72l-1-1.03zM12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6.69V11c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68c-.15.03-.29.07-.43.11-.07.02-.14.04-.2.06-.15.05-.29.1-.43.16-.06.02-.12.05-.18.08-.14.06-.28.13-.41.2-.05.03-.1.05-.15.08L18 14.31z"/>',
|
||||
// People & users
|
||||
people: '<path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z" fill-rule="evenodd"/>',
|
||||
peopleFill: '<path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/>',
|
||||
// Actions
|
||||
delete: '<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" fill-rule="evenodd"/>',
|
||||
deleteFill: '<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>',
|
||||
info: '<path d="M11 17h2v-6h-2v6zm1-15C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zM11 9h2V7h-2v2z" fill-rule="evenodd"/>',
|
||||
infoFill: '<path d="M11 17h2v-6h-2v6zm1-15C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zM11 9h2V7h-2v2z"/>',
|
||||
// Theme & preferences
|
||||
skin: '<path d="M12 2C6.49 2 2 6.49 2 12s4.49 10 10 10c1.38 0 2.5-1.12 2.5-2.5 0-.61-.23-1.2-.64-1.67-.08-.1-.13-.21-.13-.33 0-.28.22-.5.5-.5H16c3.31 0 6-2.69 6-6 0-4.96-4.49-9-10-9zm5.5 11c-.83 0-1.5-.67-1.5-1.5S16.67 10 17.5 10s1.5.67 1.5 1.5S18.33 13 17.5 13zm-3-4c-.83 0-1.5-.67-1.5-1.5S13.67 7 14.5 7s1.5.67 1.5 1.5S15.33 9 14.5 9zM9 12c-.83 0-1.5-.67-1.5-1.5S8.17 9 9 9s1.5.67 1.5 1.5S9.83 12 9 12z" fill-rule="evenodd"/>',
|
||||
skinFill: '<path d="M12 2C6.49 2 2 6.49 2 12s4.49 10 10 10c1.38 0 2.5-1.12 2.5-2.5 0-.61-.23-1.2-.64-1.67-.08-.1-.13-.21-.13-.33 0-.28.22-.5.5-.5H16c3.31 0 6-2.69 6-6 0-4.96-4.49-9-10-9zm5.5 11c-.83 0-1.5-.67-1.5-1.5S16.67 10 17.5 10s1.5.67 1.5 1.5S18.33 13 17.5 13zm-3-4c-.83 0-1.5-.67-1.5-1.5S13.67 7 14.5 7s1.5.67 1.5 1.5S15.33 9 14.5 9zM9 12c-.83 0-1.5-.67-1.5-1.5S8.17 9 9 9s1.5.67 1.5 1.5S9.83 12 9 12z"/>',
|
||||
// Lightbulb & tip
|
||||
light: '<path d="M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9v1zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7z" fill-rule="evenodd"/>',
|
||||
lightFill: '<path d="M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9v1zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7z"/>',
|
||||
// Voice
|
||||
voice: '<path d="M12 15c1.66 0 2.99-1.34 2.99-3L15 6c0-1.66-1.34-3-3-3S9 4.34 9 6v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 15 6.7 12H5c0 3.41 2.72 6.23 6 6.72V22h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z" fill-rule="evenodd"/>',
|
||||
voiceFill: '<path d="M12 15c1.66 0 2.99-1.34 2.99-3L15 6c0-1.66-1.34-3-3-3S9 4.34 9 6v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 15 6.7 12H5c0 3.41 2.72 6.23 6 6.72V22h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z"/>',
|
||||
// Round check
|
||||
roundCheck: '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" fill-rule="evenodd"/>',
|
||||
roundCheckFill: '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>',
|
||||
// Emoji / face
|
||||
emoji: '<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" fill-rule="evenodd"/>',
|
||||
emojiFill: '<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z"/>',
|
||||
// Edit (pencil)
|
||||
edit: '<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 00-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" fill-rule="evenodd"/>',
|
||||
editFill: '<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 00-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>'
|
||||
}
|
||||
|
||||
// NOTE: must encodeURIComponent the SVG so that the `#` in colors like
|
||||
// `#FF6B35` doesn't get parsed as a fragment-identifier delimiter — that
|
||||
// would silently truncate the SVG and the image would fail to render.
|
||||
const _svg = (path, color) => {
|
||||
const svg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="' +
|
||||
color + '">' + path + '</svg>'
|
||||
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg)
|
||||
}
|
||||
|
||||
const INACTIVE = '#999999'
|
||||
const DANGER = '#E53935'
|
||||
|
||||
/**
|
||||
* Build an icon map keyed by name. Two variants per name:
|
||||
* <name> = inactive (gray)
|
||||
* <name>Fill = active (theme primary)
|
||||
* Plus semantic aliases (delete, etc.) with appropriate colors.
|
||||
*
|
||||
* @param {object} theme - { primary, primaryLight, success, danger }
|
||||
* @returns {object} { <iconName>: dataUri, ... }
|
||||
*/
|
||||
const build = (theme) => {
|
||||
const t = theme || {}
|
||||
const p = t.primary || '#FF6B35'
|
||||
const s = t.success || '#4CAF50'
|
||||
const d = t.danger || DANGER
|
||||
const out = {}
|
||||
Object.keys(PATHS).forEach((name) => {
|
||||
if (name.endsWith('Fill')) {
|
||||
const base = name.slice(0, -4)
|
||||
out[base] = _svg(PATHS[name], INACTIVE)
|
||||
out[name] = _svg(PATHS[name], p)
|
||||
} else {
|
||||
out[name] = _svg(PATHS[name], INACTIVE)
|
||||
}
|
||||
})
|
||||
// Semantic overrides
|
||||
out.deleteActive = _svg(PATHS.deleteFill, d)
|
||||
out.deleteInactive = _svg(PATHS.delete, INACTIVE)
|
||||
out.successFill = _svg(PATHS.roundCheckFill, s)
|
||||
out.successLine = _svg(PATHS.roundCheck, INACTIVE)
|
||||
return out
|
||||
}
|
||||
|
||||
module.exports = { build, PATHS, INACTIVE }
|
||||
+76
-26
@@ -1,36 +1,77 @@
|
||||
const { formatDate } = require('./util')
|
||||
|
||||
/**
|
||||
* Build the per-day target array for a plan from its formula fields.
|
||||
*
|
||||
* target(day) = startTarget + floor((day - 1) / cycleDays) * increment
|
||||
*
|
||||
* Examples:
|
||||
* { totalDays: 7, startTarget: 30, increment: 10, cycleDays: 1 }
|
||||
* → 30, 40, 50, 60, 70, 80, 90 (preset: 初级)
|
||||
* { totalDays: 14, startTarget: 60, increment: 15, cycleDays: 2 }
|
||||
* → 60, 60, 75, 75, 90, 90, ... (preset: 中级)
|
||||
* { totalDays: 30, startTarget: 90, increment: 15, cycleDays: 3 }
|
||||
* → 90, 90, 90, 105, 105, 105, ... (preset: 高级)
|
||||
*/
|
||||
const generatePlanDays = ({ totalDays, startTarget, increment, cycleDays }) => {
|
||||
const n = Math.max(0, Math.floor(totalDays) || 0)
|
||||
const start = Math.max(0, Math.floor(startTarget) || 0)
|
||||
const inc = Math.max(0, Math.floor(increment) || 0)
|
||||
const cycle = Math.max(1, Math.floor(cycleDays) || 1)
|
||||
const days = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
const d = i + 1
|
||||
days.push({ day: d, target: start + Math.floor((d - 1) / cycle) * inc })
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
const _describe = (plan) =>
|
||||
`${plan.totalDays}天计划 · ${plan.startTarget}秒起步`
|
||||
|
||||
const _buildPlan = (base) => {
|
||||
const plan = { ...base }
|
||||
plan.days = generatePlanDays(plan)
|
||||
plan.description = _describe(plan)
|
||||
return plan
|
||||
}
|
||||
|
||||
const plans = {
|
||||
beginner: {
|
||||
beginner: _buildPlan({
|
||||
id: 'beginner',
|
||||
name: '初级 (7天)',
|
||||
totalDays: 7,
|
||||
description: '适合初学者,从30秒起步',
|
||||
days: Array.from({ length: 7 }, (_, i) => ({ day: i + 1, target: 30 + i * 10 }))
|
||||
},
|
||||
intermediate: {
|
||||
startTarget: 30,
|
||||
increment: 10,
|
||||
cycleDays: 1
|
||||
}),
|
||||
intermediate: _buildPlan({
|
||||
id: 'intermediate',
|
||||
name: '中级 (14天)',
|
||||
totalDays: 14,
|
||||
description: '有一定基础,从60秒起步',
|
||||
days: Array.from({ length: 14 }, (_, i) => {
|
||||
const pair = Math.floor(i / 2)
|
||||
return { day: i + 1, target: 60 + pair * 15 }
|
||||
})
|
||||
},
|
||||
advanced: {
|
||||
startTarget: 60,
|
||||
increment: 15,
|
||||
cycleDays: 2
|
||||
}),
|
||||
advanced: _buildPlan({
|
||||
id: 'advanced',
|
||||
name: '高级 (30天)',
|
||||
totalDays: 30,
|
||||
description: '挑战自我,从90秒起步',
|
||||
days: Array.from({ length: 30 }, (_, i) => ({
|
||||
day: i + 1,
|
||||
target: 90 + Math.floor(i / 3) * 15
|
||||
}))
|
||||
}
|
||||
startTarget: 90,
|
||||
increment: 15,
|
||||
cycleDays: 3
|
||||
})
|
||||
}
|
||||
|
||||
const getPlan = (planId) => plans[planId] || plans.beginner
|
||||
/**
|
||||
* Look up a plan by id. User-customized plans (passed in `customPlans`)
|
||||
* shadow the preset with the same id, so e.g. editing "beginner" replaces
|
||||
* the preset beginner for the user without changing the planId.
|
||||
*/
|
||||
const getPlan = (planId, customPlans) => {
|
||||
if (customPlans && customPlans[planId]) return customPlans[planId]
|
||||
return plans[planId] || plans.beginner
|
||||
}
|
||||
|
||||
const getTodayTarget = (planId, currentDay) => {
|
||||
const plan = getPlan(planId)
|
||||
@@ -38,15 +79,24 @@ const getTodayTarget = (planId, currentDay) => {
|
||||
return day ? day.target : plan.days[0].target
|
||||
}
|
||||
|
||||
const getPlanDay = (planId, records) => {
|
||||
/**
|
||||
* Calculate the current plan day based on training records since plan start.
|
||||
* Only counts records from the current plan's start date onward.
|
||||
*/
|
||||
const getPlanDay = (planId, records, planStartDate) => {
|
||||
const plan = getPlan(planId)
|
||||
const today = formatDate(new Date())
|
||||
const trainedDays = new Set(
|
||||
records
|
||||
.filter(r => r.date !== today && r.planId === planId)
|
||||
.map(r => r.date)
|
||||
).size
|
||||
|
||||
// Filter records: same plan, after plan start date, and not today
|
||||
const eligibleRecords = records.filter(r => {
|
||||
if (r.planId !== planId) return false
|
||||
if (r.date === today) return false
|
||||
if (planStartDate && r.date < planStartDate) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const trainedDays = new Set(eligibleRecords.map(r => r.date)).size
|
||||
return Math.min(trainedDays + 1, plan.totalDays)
|
||||
}
|
||||
|
||||
module.exports = { plans, getPlan, getTodayTarget, getPlanDay }
|
||||
module.exports = { plans, generatePlanDays, getPlan, getTodayTarget, getPlanDay }
|
||||
|
||||
+124
-7
@@ -4,11 +4,48 @@ const cloud = require('./cloud')
|
||||
const RECORDS_KEY = 'training_records'
|
||||
const SETTINGS_KEY = 'user_settings'
|
||||
const STREAK_KEY = 'current_streak'
|
||||
const CUSTOM_PLANS_KEY = 'custom_plans'
|
||||
|
||||
const getRecords = () => wx.getStorageSync(RECORDS_KEY) || {}
|
||||
/**
|
||||
* Backfill an `id` for any record that doesn't have one.
|
||||
* Older records (e.g. data restored from cloud or migrated from a build
|
||||
* that didn't yet write the id field) come in without an id, which makes
|
||||
* delete-by-id fail with "记录数据异常". Assign a stable, deterministic id
|
||||
* derived from the record's position + payload so the user can still
|
||||
* delete them.
|
||||
*
|
||||
* Returns true if any record was patched.
|
||||
*/
|
||||
const _ensureRecordIds = (records) => {
|
||||
if (!records || typeof records !== 'object') return false
|
||||
let changed = false
|
||||
Object.keys(records).forEach((month) => {
|
||||
const list = records[month]
|
||||
if (!Array.isArray(list)) return
|
||||
list.forEach((r, i) => {
|
||||
if (r && (r.id == null || r.id === '')) {
|
||||
// month is YYYY-MM; strip the dash so the id stays a safe token
|
||||
const m = String(month).replace(/[^0-9]/g, '')
|
||||
r.id = `legacy-${m}-${i}-${r.duration || 0}`
|
||||
changed = true
|
||||
}
|
||||
})
|
||||
})
|
||||
return changed
|
||||
}
|
||||
|
||||
const getRecords = () => {
|
||||
const records = wx.getStorageSync(RECORDS_KEY) || {}
|
||||
if (_ensureRecordIds(records)) {
|
||||
// Persist the backfill so subsequent calls don't re-mutate; the cloud
|
||||
// will resync on the next saveRecord/deleteRecord.
|
||||
try { wx.setStorageSync(RECORDS_KEY, records) } catch (e) {}
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
const saveRecord = (record) => {
|
||||
record.id = Date.now()
|
||||
record.id = String(Date.now())
|
||||
const records = getRecords()
|
||||
const month = record.date.substring(0, 7)
|
||||
if (!records[month]) records[month] = []
|
||||
@@ -19,12 +56,21 @@ const saveRecord = (record) => {
|
||||
}
|
||||
|
||||
const deleteRecord = (recordId) => {
|
||||
if (!recordId && recordId !== 0) return // guard against undefined/null
|
||||
const id = String(recordId)
|
||||
if (!id || id === 'undefined' || id === 'null') return
|
||||
const records = getRecords()
|
||||
let found = false
|
||||
Object.keys(records).forEach((month) => {
|
||||
records[month] = records[month].filter((r) => r.id !== recordId)
|
||||
const before = records[month].length
|
||||
records[month] = records[month].filter((r) => String(r.id) !== id)
|
||||
if (records[month].length < before) found = true
|
||||
if (records[month].length === 0) delete records[month]
|
||||
})
|
||||
if (!found) return // id not found, don't overwrite storage
|
||||
wx.setStorageSync(RECORDS_KEY, records)
|
||||
// Re-validate streak: deleting the last record for a day should break the streak
|
||||
validateStreak()
|
||||
cloud.pushAll()
|
||||
}
|
||||
|
||||
@@ -50,8 +96,6 @@ const getTotalStats = () => {
|
||||
|
||||
const getSettings = () => wx.getStorageSync(SETTINGS_KEY) || {
|
||||
planId: 'beginner',
|
||||
dailyReminder: true,
|
||||
reminderTime: '08:00',
|
||||
voiceGuide: true,
|
||||
vibrate: true
|
||||
}
|
||||
@@ -63,6 +107,75 @@ const saveSettings = (settings) => {
|
||||
|
||||
const getStreak = () => wx.getStorageSync(STREAK_KEY) || { count: 0, lastDate: '' }
|
||||
|
||||
/**
|
||||
* User-customized training plans, keyed by planId (e.g. 'beginner').
|
||||
* Each value is a full plan object built by `utils/plan.generatePlanDays`
|
||||
* plus the editor's formula fields (startTarget / increment / cycleDays).
|
||||
*
|
||||
* Storage shape mirrors `getRecords()`: an object map so partial writes
|
||||
* don't have to read+modify+write the whole collection.
|
||||
*/
|
||||
const getCustomPlans = () => {
|
||||
const raw = wx.getStorageSync(CUSTOM_PLANS_KEY)
|
||||
if (raw && typeof raw === 'object' && !Array.isArray(raw)) return raw
|
||||
return {}
|
||||
}
|
||||
|
||||
const saveCustomPlan = (planId, planData) => {
|
||||
if (!planId) return
|
||||
const plans = getCustomPlans()
|
||||
plans[planId] = planData
|
||||
wx.setStorageSync(CUSTOM_PLANS_KEY, plans)
|
||||
cloud.pushAll()
|
||||
}
|
||||
|
||||
const resetCustomPlan = (planId) => {
|
||||
if (!planId) return
|
||||
const plans = getCustomPlans()
|
||||
if (!(planId in plans)) return
|
||||
delete plans[planId]
|
||||
wx.setStorageSync(CUSTOM_PLANS_KEY, plans)
|
||||
cloud.pushAll()
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate streak against actual training records.
|
||||
* Call on app launch to correct any inconsistencies
|
||||
* (e.g. after deleting the only record for a day).
|
||||
*/
|
||||
const validateStreak = () => {
|
||||
const streak = getStreak()
|
||||
if (!streak.lastDate) return streak
|
||||
|
||||
const today = getToday()
|
||||
const yesterday = getDateOffset(today, -1)
|
||||
const records = getRecords()
|
||||
const allDates = new Set()
|
||||
Object.values(records).forEach(monthRecs => {
|
||||
monthRecs.forEach(r => allDates.add(r.date))
|
||||
})
|
||||
|
||||
// If lastDate is today, check today still has records
|
||||
if (streak.lastDate === today) {
|
||||
if (!allDates.has(today)) {
|
||||
// Today's records were deleted
|
||||
if (allDates.has(yesterday)) {
|
||||
streak.lastDate = yesterday
|
||||
streak.count = Math.max(1, streak.count - 1)
|
||||
} else {
|
||||
streak.count = 0
|
||||
streak.lastDate = ''
|
||||
}
|
||||
wx.setStorageSync(STREAK_KEY, streak)
|
||||
}
|
||||
return streak
|
||||
}
|
||||
|
||||
// If lastDate is not today and not yesterday, streak is already broken
|
||||
// (updateStreak handles reset on next training), no action needed here
|
||||
return streak
|
||||
}
|
||||
|
||||
const updateStreak = (date) => {
|
||||
const streak = getStreak()
|
||||
const today = date || getToday()
|
||||
@@ -84,7 +197,8 @@ const updateStreak = (date) => {
|
||||
const getToday = () => formatDate(new Date())
|
||||
|
||||
const getDateOffset = (dateStr, offset) => {
|
||||
const d = new Date(dateStr)
|
||||
// Use YYYY/MM/DD for iOS compatibility (YYYY-MM-DD may fail on some iOS)
|
||||
const d = new Date(dateStr.replace(/-/g, '/'))
|
||||
d.setDate(d.getDate() + offset)
|
||||
return formatDate(d)
|
||||
}
|
||||
@@ -113,10 +227,13 @@ module.exports = {
|
||||
getTotalStats,
|
||||
getSettings,
|
||||
saveSettings,
|
||||
getCustomPlans,
|
||||
saveCustomPlan,
|
||||
resetCustomPlan,
|
||||
getStreak,
|
||||
validateStreak,
|
||||
updateStreak,
|
||||
getToday,
|
||||
formatDate,
|
||||
getDateOffset,
|
||||
getTodayRecord
|
||||
}
|
||||
|
||||
+11
-9
@@ -94,20 +94,22 @@ const setTheme = (id) => {
|
||||
return theme
|
||||
}
|
||||
|
||||
const applyThemeToPage = (self) => {
|
||||
const theme = getCurrentTheme()
|
||||
self.setData({
|
||||
theme,
|
||||
themeStyle: `${BASE_VARS}--primary:${theme.primary};--primary-light:${theme.primaryLight};--primary-bg:${theme.primaryBg};--primary-rgb:${theme.primaryRgb};`
|
||||
})
|
||||
_setNavBarColor(theme.primary)
|
||||
}
|
||||
|
||||
const getThemeStyle = (theme) => {
|
||||
const t = theme || getCurrentTheme()
|
||||
return `${BASE_VARS}--primary:${t.primary};--primary-light:${t.primaryLight};--primary-bg:${t.primaryBg};--primary-rgb:${t.primaryRgb};`
|
||||
}
|
||||
|
||||
const applyThemeToPage = (self) => {
|
||||
const theme = getCurrentTheme()
|
||||
const nextStyle = getThemeStyle(theme)
|
||||
// Skip setData if nothing changed — avoid unnecessary re-renders on every onShow
|
||||
if (self.data && self.data.themeStyle === nextStyle) {
|
||||
return
|
||||
}
|
||||
self.setData({ theme, themeStyle: nextStyle })
|
||||
_setNavBarColor(theme.primary)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
THEMES,
|
||||
DEFAULT_THEME_ID,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Voice prompt utility for the timer.
|
||||
*
|
||||
* 4 fixed, polished prompts live in `cloudfunctions/tts`. We call that
|
||||
* cloud function with a key, get back an audio URL (cached in cloud
|
||||
* storage after first synthesis), and play it via createInnerAudioContext.
|
||||
*
|
||||
* The in-memory `_urlCache` avoids hitting the cloud function more than
|
||||
* once per prompt per session. The audio context is a singleton so
|
||||
* overlapping prompts interrupt each other cleanly.
|
||||
*
|
||||
* If the cloud function is unconfigured (no TTS credentials) and returns
|
||||
* `success: false`, we fall back to a silent no-op — better than spamming
|
||||
* the user with toast errors mid-plank.
|
||||
*/
|
||||
|
||||
const PROMPTS = {
|
||||
halfway: '已完成一半啦,坚持就是胜利!',
|
||||
last30: '最后30秒,保持呼吸,稳住姿势!',
|
||||
last10: '最后10秒,再加把劲!',
|
||||
complete: '太棒了!今天的目标已完成,继续加油!'
|
||||
}
|
||||
|
||||
let _ctx = null
|
||||
const _urlCache = {} // { promptKey: audioUrl }
|
||||
|
||||
const _getCtx = () => {
|
||||
if (!_ctx) {
|
||||
_ctx = wx.createInnerAudioContext()
|
||||
// Play even when the device is in silent mode — we want the user to
|
||||
// actually hear training cues during a session.
|
||||
_ctx.obeyMuteSwitch = false
|
||||
}
|
||||
return _ctx
|
||||
}
|
||||
|
||||
const _fetchUrl = async (promptKey) => {
|
||||
if (_urlCache[promptKey]) return _urlCache[promptKey]
|
||||
try {
|
||||
const res = await wx.cloud.callFunction({
|
||||
name: 'tts',
|
||||
data: { promptKey },
|
||||
// Default WeChat cloud timeout is 3s, which is exactly the TTS
|
||||
// first-call budget (synth + upload + tempURL). Bump to 30s for
|
||||
// cold starts; cached calls return in <500ms.
|
||||
config: { timeout: 30000 }
|
||||
})
|
||||
if (res && res.result && res.result.success && res.result.audioUrl) {
|
||||
_urlCache[promptKey] = res.result.audioUrl
|
||||
return res.result.audioUrl
|
||||
}
|
||||
} catch (e) {
|
||||
// cloud function not deployed or other error — silent fallback
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a prompt. Returns a Promise that resolves when playback starts
|
||||
* (or immediately if there's no audio available).
|
||||
*/
|
||||
const play = async (promptKey) => {
|
||||
if (!PROMPTS[promptKey]) return
|
||||
const url = await _fetchUrl(promptKey)
|
||||
if (!url) return
|
||||
const ctx = _getCtx()
|
||||
try {
|
||||
ctx.stop()
|
||||
ctx.src = url
|
||||
ctx.play()
|
||||
} catch (e) { /* ignore playback errors */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop any currently-playing voice prompt. Call this when the user
|
||||
* pauses/stops/finishes training to avoid stray audio.
|
||||
*/
|
||||
const stop = () => {
|
||||
if (_ctx) {
|
||||
try { _ctx.stop() } catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down the audio context. Call on page unload.
|
||||
*/
|
||||
const destroy = () => {
|
||||
if (_ctx) {
|
||||
try { _ctx.destroy() } catch (e) { /* ignore */ }
|
||||
_ctx = null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { play, stop, destroy, PROMPTS }
|
||||
Reference in New Issue
Block a user