187c426243
- 移除全项目 34 处调试 console.* 输出 - project.config.json: minified true、uploadWithSourceMap false、启用 lazyCodeLoading(requiredComponents 组件按需注入) - pages/records/records.json 补全 ui-skeleton 组件声明(此前漏声明,WXML 已使用) - 删除根目录残留 preview-buttons.html
535 lines
18 KiB
JavaScript
535 lines
18 KiB
JavaScript
const { formatDate, dateOnly } = require('./util')
|
|
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 PROFILE_KEY = 'user_profile'
|
|
const CIRCUIT_CONFIG_KEY = 'circuit_config'
|
|
|
|
let _idSeq = 0
|
|
|
|
/**
|
|
* Record the timestamp of the last local mutation to single-value fields
|
|
* (settings / profile / customPlans / theme). _restoreFromCloud compares
|
|
* this against the cloud doc's updatedAt: if local changed after the cloud
|
|
* snapshot, we keep local to avoid clobbering unsynced edits. Records are
|
|
* exempt - they go through mergeRecords() which never loses data.
|
|
*/
|
|
const _markLocalChanged = () => {
|
|
try { wx.setStorageSync('_local_changed_at', Date.now()) } catch (e) {}
|
|
}
|
|
|
|
/**
|
|
* 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 = String(Date.now()) + '_' + (++_idSeq)
|
|
|
|
// Defensive: reject records with a future date. Indicates the user has
|
|
// changed their phone clock, which would otherwise poison the cloud
|
|
// leaderboard (and their own stats) with impossible timestamps. Allow
|
|
// a 1-minute tolerance for normal clock skew.
|
|
const recordTime = new Date(record.date.replace(/-/g, '/')).getTime()
|
|
if (recordTime > Date.now() + 60 * 1000) {
|
|
wx.showToast({ title: '系统时间异常,请检查', icon: 'none' })
|
|
return
|
|
}
|
|
|
|
const records = getRecords()
|
|
const month = record.date.substring(0, 7)
|
|
if (!records[month]) records[month] = []
|
|
records[month].push(record)
|
|
records[month].sort((a, b) => b.date.localeCompare(a.date))
|
|
wx.setStorageSync(RECORDS_KEY, records)
|
|
cloud.pushAll()
|
|
}
|
|
|
|
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) => {
|
|
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)
|
|
// 重新计算连续打卡:删连续链中间某天会断链,validateStreak 只在 lastDate 被删时重算,不够
|
|
recomputeStreak()
|
|
cloud.pushAll()
|
|
}
|
|
|
|
/**
|
|
* Merge cloud records into local records WITHOUT losing either side.
|
|
*
|
|
* Why this exists: _restoreFromCloud used to overwrite training_records
|
|
* with the cloud doc verbatim. If the user finished a training during the
|
|
* async pullAll on launch, saveRecord had already written locally, and the
|
|
* subsequent restore wiped that fresh record with the cloud's older
|
|
* snapshot. Same hazard for multi-device edits.
|
|
*
|
|
* Dedup: records with a real (non-legacy) id dedup by id; legacy/idless
|
|
* records dedup by `date|duration` so old migrated data doesn't double up.
|
|
* Result is re-bucketed by month and sorted newest-first.
|
|
*/
|
|
const mergeRecords = (cloudRecords) => {
|
|
const local = wx.getStorageSync(RECORDS_KEY) || {}
|
|
const cloud = (cloudRecords && typeof cloudRecords === 'object') ? cloudRecords : {}
|
|
const seen = new Set()
|
|
const out = []
|
|
const push = (r) => {
|
|
if (!r) return
|
|
const id = r.id
|
|
const isRealId = id != null && id !== '' && !String(id).startsWith('legacy-')
|
|
const key = isRealId ? `id:${id}` : `c:${r.date}|${r.duration}`
|
|
if (seen.has(key)) return
|
|
seen.add(key)
|
|
out.push(r)
|
|
}
|
|
Object.keys(local).forEach((m) => (local[m] || []).forEach(push))
|
|
Object.keys(cloud).forEach((m) => (cloud[m] || []).forEach(push))
|
|
|
|
const merged = {}
|
|
out.forEach((r) => {
|
|
const mo = (r.date && r.date.substring) ? r.date.substring(0, 7) : 'unknown'
|
|
if (!merged[mo]) merged[mo] = []
|
|
merged[mo].push(r)
|
|
})
|
|
Object.keys(merged).forEach((mo) => {
|
|
merged[mo].sort((a, b) => (b.date || '').localeCompare(a.date || ''))
|
|
})
|
|
_ensureRecordIds(merged)
|
|
return merged
|
|
}
|
|
|
|
const getRecordsByMonth = (month) => {
|
|
const records = getRecords()
|
|
return records[month] || []
|
|
}
|
|
|
|
const getTotalStats = () => {
|
|
const records = getRecords()
|
|
let totalDuration = 0
|
|
let totalSessions = 0
|
|
let maxDuration = 0
|
|
// 累计训练天数:有训练记录的不同日期数(一天多次只算 1 天)。
|
|
// 用于记录页里程碑勋章,只增不减(删记录才会减)。
|
|
const days = new Set()
|
|
Object.keys(records).forEach((key) => {
|
|
records[key].forEach((r) => {
|
|
const d = Number(r && r.duration) || 0
|
|
totalDuration += d
|
|
totalSessions++
|
|
if (d > maxDuration) maxDuration = d
|
|
if (r && r.date) days.add(dateOnly(r.date))
|
|
})
|
|
})
|
|
return { totalDuration, totalSessions, maxDuration, totalDays: days.size }
|
|
}
|
|
|
|
const getSettings = () => {
|
|
const s = wx.getStorageSync(SETTINGS_KEY)
|
|
if (s) {
|
|
// Backfill voiceGender for existing users (added with the male/female
|
|
// voice option). Defaults to female, the prior behavior.
|
|
if (!s.voiceGender) s.voiceGender = 'female'
|
|
// Backfill vibrate/voiceGuide for users whose settings predate these
|
|
// toggles. Without this, s.vibrate is undefined and `!s.vibrate` in
|
|
// the timer reads it as "off" - vibration silently no-ops until the
|
|
// user manually toggles the switch (which writes a real boolean).
|
|
if (s.vibrate == null) s.vibrate = true
|
|
if (s.voiceGuide == null) s.voiceGuide = true
|
|
if (!s.vibrateIntensity) s.vibrateIntensity = 'heavy'
|
|
return s
|
|
}
|
|
return {
|
|
planId: 'beginner',
|
|
voiceGuide: true,
|
|
vibrate: true,
|
|
voiceGender: 'female',
|
|
vibrateIntensity: 'heavy'
|
|
}
|
|
}
|
|
|
|
const saveSettings = (settings) => {
|
|
wx.setStorageSync(SETTINGS_KEY, settings)
|
|
_markLocalChanged()
|
|
cloud.pushAll()
|
|
}
|
|
|
|
/**
|
|
* User profile (nickname + avatar). Stored locally and pushed to the
|
|
* cloud alongside records/settings so the leaderboard can show real
|
|
* names instead of masked openids.
|
|
*
|
|
* Shape: { nickname: string, avatarUrl: string (wxfile://...) }
|
|
* Both fields are optional; absent profile returns `{}`.
|
|
*/
|
|
const getProfile = () => {
|
|
const raw = wx.getStorageSync(PROFILE_KEY)
|
|
if (raw && typeof raw === 'object' && !Array.isArray(raw)) return raw
|
|
return {}
|
|
}
|
|
|
|
const saveProfile = (profile) => {
|
|
// Strip empties so we don't push {nickname: '', avatarUrl: ''}
|
|
const clean = {}
|
|
if (profile && typeof profile.nickname === 'string' && profile.nickname.trim()) {
|
|
clean.nickname = profile.nickname.trim().slice(0, 16)
|
|
}
|
|
if (profile && typeof profile.avatarUrl === 'string' && profile.avatarUrl) {
|
|
clean.avatarUrl = profile.avatarUrl
|
|
}
|
|
wx.setStorageSync(PROFILE_KEY, clean)
|
|
_markLocalChanged()
|
|
cloud.pushAll()
|
|
return clean
|
|
}
|
|
|
|
const getStreak = () => wx.getStorageSync(STREAK_KEY) || { count: 0, lastDate: '' }
|
|
|
|
/**
|
|
* Circuit (循环) training configuration — persisted locally (device-only,
|
|
* NOT cloud-synced; it's a per-device training preference, not account
|
|
* data, so no cloud.pushAll()). This is the "持久化" the simplified
|
|
* circuit-train feature needs: the user's { 每组时长 / 组数 / 休息 / 每周次数 }
|
|
* survives restarts and pre-fills the config panel next time.
|
|
*
|
|
* Shape: {
|
|
* holdPerSet: number // seconds held per set (>=5)
|
|
* sets: number // sets per session (>=1)
|
|
* restPerSet: number // rest seconds between sets (>=0; 0 = none)
|
|
* sessionsPerWeek: number // weekly target (>=1) — DISPLAY ONLY in the
|
|
* simplified version, no progress tracking yet
|
|
* }
|
|
* Any missing / out-of-range field falls back to a sensible default, so a
|
|
* corrupt or partial config never crashes the timer.
|
|
*/
|
|
const DEFAULT_CIRCUIT_CONFIG = { holdPerSet: 30, sets: 4, restPerSet: 15, sessionsPerWeek: 3 }
|
|
|
|
const _clampInt = (v, min, max, fallback) => {
|
|
const n = parseInt(v)
|
|
if (!Number.isFinite(n)) return fallback
|
|
return Math.max(min, Math.min(max, n))
|
|
}
|
|
|
|
const getCircuitConfig = () => {
|
|
const raw = wx.getStorageSync(CIRCUIT_CONFIG_KEY)
|
|
if (!raw || typeof raw !== 'object') return { ...DEFAULT_CIRCUIT_CONFIG }
|
|
return {
|
|
holdPerSet: _clampInt(raw.holdPerSet, 5, 600, DEFAULT_CIRCUIT_CONFIG.holdPerSet),
|
|
sets: _clampInt(raw.sets, 1, 50, DEFAULT_CIRCUIT_CONFIG.sets),
|
|
restPerSet: _clampInt(raw.restPerSet, 0, 600, DEFAULT_CIRCUIT_CONFIG.restPerSet),
|
|
sessionsPerWeek: _clampInt(raw.sessionsPerWeek, 1, 14, DEFAULT_CIRCUIT_CONFIG.sessionsPerWeek)
|
|
}
|
|
}
|
|
|
|
const saveCircuitConfig = (cfg) => {
|
|
if (!cfg || typeof cfg !== 'object') return
|
|
const clean = {
|
|
holdPerSet: _clampInt(cfg.holdPerSet, 5, 600, DEFAULT_CIRCUIT_CONFIG.holdPerSet),
|
|
sets: _clampInt(cfg.sets, 1, 50, DEFAULT_CIRCUIT_CONFIG.sets),
|
|
restPerSet: _clampInt(cfg.restPerSet, 0, 600, DEFAULT_CIRCUIT_CONFIG.restPerSet),
|
|
sessionsPerWeek: _clampInt(cfg.sessionsPerWeek, 1, 14, DEFAULT_CIRCUIT_CONFIG.sessionsPerWeek)
|
|
}
|
|
wx.setStorageSync(CIRCUIT_CONFIG_KEY, clean)
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
_markLocalChanged()
|
|
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)
|
|
_markLocalChanged()
|
|
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).
|
|
*
|
|
* Streak compares date-ONLY portions of `lastDate` so that training
|
|
* twice on the same day (with different seconds in `record.date`)
|
|
* doesn't accidentally reset the streak.
|
|
*/
|
|
const validateStreak = () => {
|
|
const streak = getStreak()
|
|
if (!streak.lastDate) return streak
|
|
|
|
const today = getToday()
|
|
const todayOnly = dateOnly(today)
|
|
const yesterdayOnly = dateOnly(getDateOffset(today, -1))
|
|
const records = getRecords()
|
|
const allDates = new Set()
|
|
Object.values(records).forEach(monthRecs => {
|
|
monthRecs.forEach(r => allDates.add(dateOnly(r.date)))
|
|
})
|
|
|
|
const lastOnly = dateOnly(streak.lastDate)
|
|
|
|
// If lastDate is today, check today still has records
|
|
if (lastOnly === todayOnly) {
|
|
if (!allDates.has(todayOnly)) {
|
|
// Today's records were deleted
|
|
if (allDates.has(yesterdayOnly)) {
|
|
streak.lastDate = yesterdayOnly
|
|
streak.count = Math.max(1, streak.count - 1)
|
|
} else {
|
|
streak.count = 0
|
|
streak.lastDate = ''
|
|
}
|
|
wx.setStorageSync(STREAK_KEY, streak)
|
|
}
|
|
return streak
|
|
}
|
|
|
|
// If lastDate points to a date that no longer has ANY records,
|
|
// recompute the streak from actual recorded dates.
|
|
if (!allDates.has(lastOnly)) {
|
|
const sorted = Array.from(allDates).sort().reverse()
|
|
if (sorted.length === 0) {
|
|
streak.count = 0
|
|
streak.lastDate = ''
|
|
} else {
|
|
let count = 1
|
|
let prev = sorted[0]
|
|
for (let i = 1; i < sorted.length; i++) {
|
|
const prevDate = new Date(prev.replace(/-/g, '/'))
|
|
const curDate = new Date(sorted[i].replace(/-/g, '/'))
|
|
const diff = Math.round((prevDate - curDate) / (1000 * 60 * 60 * 24))
|
|
if (diff === 1) {
|
|
count++
|
|
prev = sorted[i]
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
streak.count = count
|
|
streak.lastDate = sorted[0]
|
|
}
|
|
wx.setStorageSync(STREAK_KEY, streak)
|
|
}
|
|
|
|
return streak
|
|
}
|
|
|
|
/**
|
|
* Fully recompute the streak from actual training records. Used after a
|
|
* cloud restore/merge: the cloud doc's streak may be stale or belong to
|
|
* another device, so we trust the merged records over the cloud value.
|
|
*/
|
|
const recomputeStreak = () => {
|
|
const records = getRecords()
|
|
const allDates = new Set()
|
|
Object.values(records).forEach((monthRecs) => {
|
|
monthRecs.forEach((r) => allDates.add(dateOnly(r.date)))
|
|
})
|
|
const sorted = Array.from(allDates).sort().reverse()
|
|
if (sorted.length === 0) {
|
|
const empty = { count: 0, lastDate: '' }
|
|
wx.setStorageSync(STREAK_KEY, empty)
|
|
return empty
|
|
}
|
|
let count = 1
|
|
let prev = sorted[0]
|
|
for (let i = 1; i < sorted.length; i++) {
|
|
const prevDate = new Date(prev.replace(/-/g, '/'))
|
|
const curDate = new Date(sorted[i].replace(/-/g, '/'))
|
|
const diff = Math.round((prevDate - curDate) / (1000 * 60 * 60 * 24))
|
|
if (diff === 1) {
|
|
count++
|
|
prev = sorted[i]
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
const streak = { count, lastDate: sorted[0] }
|
|
wx.setStorageSync(STREAK_KEY, streak)
|
|
return streak
|
|
}
|
|
|
|
const updateStreak = (date) => {
|
|
const streak = getStreak()
|
|
const todayFull = date || getToday()
|
|
const todayOnly = dateOnly(todayFull)
|
|
const yesterdayOnly = dateOnly(getDateOffset(todayFull, -1))
|
|
|
|
if (dateOnly(streak.lastDate) === todayOnly) return streak
|
|
|
|
if (dateOnly(streak.lastDate) === yesterdayOnly) {
|
|
streak.count += 1
|
|
} else {
|
|
streak.count = 1
|
|
}
|
|
// Store the date-ONLY portion so subsequent comparisons are stable
|
|
// across multiple same-day trainings with different seconds.
|
|
streak.lastDate = todayOnly
|
|
wx.setStorageSync(STREAK_KEY, streak)
|
|
cloud.pushAll()
|
|
return streak
|
|
}
|
|
|
|
const getToday = () => formatDate(new Date())
|
|
|
|
const getDateOffset = (dateStr, offset) => {
|
|
// Strip the time portion (formatDate now includes HH:MM:SS) and convert
|
|
// dashes to slashes — `new Date("YYYY/MM/DD")` is the only reliably-
|
|
// parsed form across both V8 (WeChat dev tools) and JSCore (iOS).
|
|
const d = new Date(dateOnly(dateStr).replace(/-/g, '/'))
|
|
d.setDate(d.getDate() + offset)
|
|
return formatDate(d)
|
|
}
|
|
|
|
const getTodayRecord = () => {
|
|
const today = getToday()
|
|
// Record `date` is "YYYY-MM-DD HH:MM:SS" (formatted at saveRecord time),
|
|
// so a strict === check against the freshly-formatted `today` only matches
|
|
// a record saved in the exact same second. Compare date-only portions
|
|
// instead, otherwise the second training of the day reads as zero and
|
|
// the home page flips "今日已完成" back off.
|
|
const todayOnly = dateOnly(today)
|
|
const records = getRecords()
|
|
let totalDuration = 0
|
|
let hasRecord = false
|
|
Object.keys(records).forEach((month) => {
|
|
records[month].forEach((r) => {
|
|
if (dateOnly(r.date) === todayOnly) {
|
|
totalDuration += r.duration
|
|
hasRecord = true
|
|
}
|
|
})
|
|
})
|
|
return hasRecord ? { date: today, duration: totalDuration } : null
|
|
}
|
|
|
|
/**
|
|
* Leaderboard display cache, keyed by period ('day' | 'month' | 'year').
|
|
*
|
|
* Why: building the board requires a full-collection scan in the cloud
|
|
* function (cursor-paginated 100/page, serial), which takes 2-3s when
|
|
* the function's in-instance cache is cold. This client cache lets the
|
|
* page paint the last-rendered result instantly on re-entry / period
|
|
* switch while the cloud refreshes in the background.
|
|
*
|
|
* Freshness: entries older than LB_CACHE_MAX_AGE are treated as misses -
|
|
* the cached avatarUrl is a cloud:// temp URL (~2h TTL) and stale links
|
|
* render as broken images. 30min stays safely inside that window while
|
|
* still covering the common "switch tab / switch period" re-entry case.
|
|
*/
|
|
const LEADERBOARD_CACHE_KEY = 'leaderboard_cache'
|
|
const LB_CACHE_MAX_AGE = 30 * 60 * 1000
|
|
|
|
const getLeaderboardCache = (period) => {
|
|
if (!period) return null
|
|
try {
|
|
const all = wx.getStorageSync(LEADERBOARD_CACHE_KEY) || {}
|
|
const entry = all[period]
|
|
if (!entry) return null
|
|
if (Date.now() - (entry.cachedAt || 0) > LB_CACHE_MAX_AGE) return null
|
|
return entry
|
|
} catch (e) { return null }
|
|
}
|
|
|
|
const setLeaderboardCache = (period, payload) => {
|
|
if (!period || !payload) return
|
|
try {
|
|
const all = wx.getStorageSync(LEADERBOARD_CACHE_KEY) || {}
|
|
all[period] = { ...payload, cachedAt: Date.now() }
|
|
wx.setStorageSync(LEADERBOARD_CACHE_KEY, all)
|
|
} catch (e) {}
|
|
}
|
|
|
|
module.exports = {
|
|
getRecords,
|
|
saveRecord,
|
|
deleteRecord,
|
|
mergeRecords,
|
|
getRecordsByMonth,
|
|
getTotalStats,
|
|
getSettings,
|
|
saveSettings,
|
|
getCustomPlans,
|
|
saveCustomPlan,
|
|
resetCustomPlan,
|
|
getProfile,
|
|
saveProfile,
|
|
getStreak,
|
|
getCircuitConfig,
|
|
saveCircuitConfig,
|
|
validateStreak,
|
|
recomputeStreak,
|
|
updateStreak,
|
|
_markLocalChanged,
|
|
getToday,
|
|
getDateOffset,
|
|
getTodayRecord,
|
|
getLeaderboardCache,
|
|
setLeaderboardCache
|
|
}
|