Files
wx_pbzc/utils/util.js
T
2026-07-29 14:46:38 +08:00

230 lines
7.9 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 getBestTrendData = (recordGroups, mode, now = new Date()) => {
const peaks = {}
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
const key = mode === 'month' ? date.substring(0, 7) : date
peaks[key] = Math.max(peaks[key] || 0, _normalizeDuration(record.duration))
})
})
const today = new Date(now)
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
const pad = (n) => String(n).padStart(2, '0')
const count = mode === 'month' ? 6 : 7
return Array.from({ length: count }, (_, index) => {
const offset = count - 1 - index
const date = mode === 'month'
? new Date(today.getFullYear(), today.getMonth() - offset, 1)
: new Date(today.getFullYear(), today.getMonth(), today.getDate() - offset)
const key = mode === 'month'
? `${date.getFullYear()}-${pad(date.getMonth() + 1)}`
: `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
const value = peaks[key] || 0
return {
label: mode === 'month' ? `${date.getMonth() + 1}月` : 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
}
const getTrendSummary = (trendData, mode) => {
const items = Array.isArray(trendData) ? trendData : []
const total = items.reduce((sum, item) => sum + _normalizeDuration(item && item.value), 0)
const activeItems = items.filter(item => _normalizeDuration(item && item.value) > 0)
const peak = activeItems.reduce((current, item) => {
return !current || _normalizeDuration(item.value) > _normalizeDuration(current.value) ? item : current
}, null)
const isMonth = mode === 'month'
return {
totalText: _formatTrendDuration(total),
activeCount: activeItems.length,
activeLabel: isMonth ? '活跃月份' : '训练天数',
peakTitle: isMonth ? '最高训练月' : '最高训练日',
peakText: peak ? `${peak.label} · ${_formatTrendDuration(peak.value)}` : '暂无训练数据'
}
}
/**
* 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,
getBestTrendData,
getTrendSummary,
getBarPercent,
fileToDataURI
}