175 lines
5.7 KiB
JavaScript
175 lines
5.7 KiB
JavaScript
const formatTime = (seconds) => {
|
|
const m = Math.floor(seconds / 60)
|
|
const s = seconds % 60
|
|
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
|
}
|
|
|
|
const formatDuration = (seconds) => {
|
|
if (seconds < 60) return `${seconds}秒`
|
|
const m = Math.floor(seconds / 60)
|
|
const s = seconds % 60
|
|
return s > 0 ? `${m}分${s}秒` : `${m}分钟`
|
|
}
|
|
|
|
const getDaysInMonth = (year, month) => new Date(year, month, 0).getDate()
|
|
|
|
const getMonthCalendar = (year, month) => {
|
|
const firstDay = new Date(year, month - 1, 1)
|
|
const daysInMonth = new Date(year, month, 0).getDate()
|
|
const startDayOfWeek = firstDay.getDay()
|
|
|
|
const weeks = []
|
|
let week = [null, null, null, null, null, null, null]
|
|
|
|
for (let d = 1; d <= daysInMonth; d++) {
|
|
const dow = (startDayOfWeek + d - 1) % 7
|
|
week[dow] = d
|
|
if (dow === 6 || d === daysInMonth) {
|
|
weeks.push(week.slice())
|
|
week = [null, null, null, null, null, null, null]
|
|
}
|
|
}
|
|
|
|
return weeks
|
|
}
|
|
|
|
/**
|
|
* Format a Date as `YYYY-MM-DD HH:MM:SS` in local time.
|
|
*
|
|
* Used as the canonical `record.date` string so each record has a
|
|
* precise timestamp (e.g. "2026-06-11 14:30:45"). Use `_dateOnly()`
|
|
* to strip back to YYYY-MM-DD for date-level comparisons (streak,
|
|
* leaderboard day filter, etc.).
|
|
*/
|
|
const formatDate = (date) => {
|
|
const y = date.getFullYear()
|
|
const mo = String(date.getMonth() + 1).padStart(2, '0')
|
|
const d = String(date.getDate()).padStart(2, '0')
|
|
const h = String(date.getHours()).padStart(2, '0')
|
|
const mi = String(date.getMinutes()).padStart(2, '0')
|
|
const s = String(date.getSeconds()).padStart(2, '0')
|
|
return `${y}-${mo}-${d} ${h}:${mi}:${s}`
|
|
}
|
|
|
|
/** Strip the time portion of a formatDate string → "YYYY-MM-DD". */
|
|
const _dateOnly = (s) => (typeof s === 'string' && s.length >= 10) ? s.substring(0, 10) : ''
|
|
|
|
const _normalizeDuration = (value) => Math.max(0, Math.floor(Number(value) || 0))
|
|
|
|
const _formatTrendDuration = (seconds) => {
|
|
const duration = _normalizeDuration(seconds)
|
|
if (duration >= 3600) return `${Math.floor(duration / 60)}m`
|
|
return formatTime(duration)
|
|
}
|
|
|
|
/** Build daily or monthly duration totals from one training-record snapshot. */
|
|
const getTrendData = (recordGroups, mode, now = new Date()) => {
|
|
const dateTotals = {}
|
|
const groups = recordGroups && typeof recordGroups === 'object' ? recordGroups : {}
|
|
|
|
Object.values(groups).forEach((records) => {
|
|
if (!Array.isArray(records)) return
|
|
records.forEach((record) => {
|
|
const date = _dateOnly(record && record.date)
|
|
if (!date) return
|
|
dateTotals[date] = (dateTotals[date] || 0) + _normalizeDuration(record.duration)
|
|
})
|
|
})
|
|
|
|
const today = new Date(now)
|
|
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
|
const pad = (n) => String(n).padStart(2, '0')
|
|
|
|
if (mode === 'month') {
|
|
const monthTotals = {}
|
|
Object.keys(dateTotals).forEach((date) => {
|
|
const month = date.substring(0, 7)
|
|
monthTotals[month] = (monthTotals[month] || 0) + dateTotals[date]
|
|
})
|
|
return Array.from({ length: 6 }, (_, index) => {
|
|
const offset = 5 - index
|
|
const date = new Date(today.getFullYear(), today.getMonth() - offset, 1)
|
|
const monthKey = `${date.getFullYear()}-${pad(date.getMonth() + 1)}`
|
|
const value = monthTotals[monthKey] || 0
|
|
return {
|
|
label: `${date.getMonth() + 1}月`,
|
|
value,
|
|
valueText: value > 0 ? _formatTrendDuration(value) : '',
|
|
highlight: offset === 0
|
|
}
|
|
})
|
|
}
|
|
|
|
return Array.from({ length: 7 }, (_, index) => {
|
|
const offset = 6 - index
|
|
const date = new Date(today)
|
|
date.setDate(date.getDate() - offset)
|
|
const dateKey = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
|
const value = dateTotals[dateKey] || 0
|
|
return {
|
|
label: offset === 0 ? '今天' : weekdays[date.getDay()],
|
|
value,
|
|
valueText: value > 0 ? _formatTrendDuration(value) : '',
|
|
highlight: offset === 0
|
|
}
|
|
})
|
|
}
|
|
|
|
const getBarPercent = (value, max) => {
|
|
const amount = _normalizeDuration(value)
|
|
const maximum = _normalizeDuration(max)
|
|
return maximum > 0 ? amount / maximum * 100 : 0
|
|
}
|
|
|
|
/**
|
|
* Convert a local file path (typically `wxfile://...` from chooseAvatar) to a
|
|
* base64 data URI. Used to make temporary file URLs survive the WeChat
|
|
* expiration window (a few days) by embedding the bytes inline.
|
|
*
|
|
* Rejects if the file exceeds `maxBytes` (default 512KB) so a stray huge
|
|
* image doesn't blow the 10MB total wx.storage budget. Caller should
|
|
* fall back to the raw URL on rejection.
|
|
*/
|
|
const fileToDataURI = (filePath, maxBytes = 512 * 1024) => {
|
|
return new Promise((resolve, reject) => {
|
|
if (!filePath || typeof wx.getFileSystemManager !== 'function') {
|
|
reject(new Error('FileSystemManager unavailable'))
|
|
return
|
|
}
|
|
const fs = wx.getFileSystemManager()
|
|
fs.getFileInfo({
|
|
filePath,
|
|
success: (info) => {
|
|
if (info.size > maxBytes) {
|
|
reject(new Error(`File too large: ${info.size} > ${maxBytes}`))
|
|
return
|
|
}
|
|
fs.readFile({
|
|
filePath,
|
|
encoding: 'base64',
|
|
success: (res) => {
|
|
// chooseAvatar returns PNG; allow JPG/GIF/WEBP fallbacks.
|
|
const m = ((filePath.match(/\.(\w+)(?:\?|$)/) || [])[1] || 'png').toLowerCase()
|
|
const mime = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' }[m] || 'image/png'
|
|
resolve(`data:${mime};base64,${res.data}`)
|
|
},
|
|
fail: reject
|
|
})
|
|
},
|
|
fail: reject
|
|
})
|
|
})
|
|
}
|
|
|
|
module.exports = {
|
|
formatTime,
|
|
formatDuration,
|
|
getDaysInMonth,
|
|
getMonthCalendar,
|
|
formatDate,
|
|
dateOnly: _dateOnly,
|
|
getTrendData,
|
|
getBarPercent,
|
|
fileToDataURI
|
|
}
|