/* 碰盏日记 - 工具函数 */ import { DRINK_CATEGORIES } from './constants' /** * 获取酒类图标:优先返回自定义图片路径,否则返回 emoji */ export function getCatIcon(catId) { const cat = DRINK_CATEGORIES.find(c => c.id === catId) if (!cat) return '🥃' return cat.icon || cat.emoji } export function isIconPath(val) { return typeof val === 'string' && val.charAt(0) === '/' } /** * 判断头像地址是否可用 * wxfile://tmp_ 等本地临时路径仅本机当次有效,不能用于展示他人头像, * 展示层对此类地址应回退为占位头像 */ export function isValidAvatar(url) { return typeof url === 'string' && /^https?:\/\//.test(url) } /** * 标准杯换算引擎 * 核心公式: 标准杯 = (饮用量ml × 酒精度数% × 0.8) / 10 * 1标准杯 = 10g纯酒精 */ export function calcStandardCups(amountMl, degreePercent) { if (!amountMl || !degreePercent) return 0 return Math.round((amountMl * (degreePercent / 100) * 0.8 / 10) * 100) / 100 } /** * 单位换算为ml */ const UNIT_TO_ML = { ml: 1, liang: 50, // 1两 = 50ml bottle: 500, // 1瓶 = 500ml (啤酒默认) cup: 150, // 1杯 = 150ml can: 330, // 1听 = 330ml shot: 30 // 1shot = 30ml } export function unitToMl(amount, unit) { const factor = UNIT_TO_ML[unit] || 1 return amount * factor } /** * 计算一组饮品的总标准杯 */ export function calcTotalStandardCups(drinks) { return drinks.reduce((sum, d) => { const ml = unitToMl(d.amount, d.unit) return sum + calcStandardCups(ml, d.degree) }, 0) } /** * 日期格式化 */ export function formatDate(date, format = 'YYYY-MM-DD') { const d = new Date(date) const year = d.getFullYear() const month = String(d.getMonth() + 1).padStart(2, '0') const day = String(d.getDate()).padStart(2, '0') const hours = String(d.getHours()).padStart(2, '0') const minutes = String(d.getMinutes()).padStart(2, '0') return format .replace('YYYY', year) .replace('MM', month) .replace('DD', day) .replace('HH', hours) .replace('mm', minutes) } /** * 获取指定月份的日历数据 * @param {number} year * @param {number} month 1-12 * @returns {Array} 日历数组,包含前置空白、日期、后置空白 */ export function getCalendarDays(year, month) { const firstDay = new Date(year, month - 1, 1) const lastDay = new Date(year, month, 0) const daysInMonth = lastDay.getDate() // 周一为一周起始 (0=周一, 6=周日) let startWeekday = firstDay.getDay() - 1 if (startWeekday < 0) startWeekday = 6 const days = [] // 前置空白 for (let i = 0; i < startWeekday; i++) { days.push({ day: null, date: null }) } // 日期 for (let i = 1; i <= daysInMonth; i++) { const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(i).padStart(2, '0')}` days.push({ day: i, date: dateStr, isToday: dateStr === formatDate(new Date(), 'YYYY-MM-DD') }) } return days } /** * 获取本周一的日期 */ export function getWeekStart(date = new Date()) { const d = new Date(date) const day = d.getDay() const diff = d.getDate() - day + (day === 0 ? -6 : 1) return new Date(d.setDate(diff)) } /** * 获取时间问候语 */ export function getGreeting() { const hour = new Date().getHours() if (hour < 6) return '夜深了' if (hour < 12) return '早上好' if (hour < 14) return '中午好' if (hour < 18) return '下午好' if (hour < 22) return '晚上好' return '夜深了' } /** * 随机获取一条酒言酒语 */ export function getRandomQuote(quotes) { return quotes[Math.floor(Math.random() * quotes.length)] } /** * 统计数据计算 */ export function calcStats(records) { const now = new Date() const today = formatDate(now, 'YYYY-MM-DD') // 本周统计 const weekStart = getWeekStart(now) const weekRecords = records.filter(r => new Date(r.date) >= weekStart) const weekDrinkCount = weekRecords.filter(r => r.mode === 'drank').length const weekCups = weekRecords.reduce((sum, r) => sum + (r.standardCupsTotal || 0), 0) // 本月统计 const monthStr = formatDate(now, 'YYYY-MM') const monthRecords = records.filter(r => r.date && r.date.startsWith(monthStr)) const monthDrinkCount = monthRecords.filter(r => r.mode === 'drank').length const monthCups = monthRecords.reduce((sum, r) => sum + (r.standardCupsTotal || 0), 0) // 连续天数 let streak = 0 let streakType = 'drank' const d = new Date() // 检查今天是否有记录 const todayRecord = records.find(r => r.date === today) if (todayRecord) { streakType = todayRecord.mode streak = 1 // 向前追溯 for (let i = 1; i < 365; i++) { const prevDate = new Date(d) prevDate.setDate(prevDate.getDate() - i) const prevStr = formatDate(prevDate, 'YYYY-MM-DD') const prev = records.find(r => r.date === prevStr) if (prev && prev.mode === streakType) { streak++ } else { break } } } // 总打卡天数 const totalDays = new Set(records.filter(r => r.mode === 'drank').map(r => r.date)).size const totalRecords = records.filter(r => r.mode === 'drank').length const totalCups = records.reduce((sum, r) => sum + (r.standardCupsTotal || 0), 0) // 最爱酒类 const categoryCount = {} records.forEach(r => { if (r.drinks) { r.drinks.forEach(d => { categoryCount[d.category] = (categoryCount[d.category] || 0) + 1 }) } }) const favCategory = Object.entries(categoryCount).sort((a, b) => b[1] - a[1])[0] return { weekDrinkCount, weekCups: Math.round(weekCups * 10) / 10, monthDrinkCount, monthCups: Math.round(monthCups * 10) / 10, streak, streakType, totalDays, totalRecords, totalCups: Math.round(totalCups * 10) / 10, favCategory: favCategory ? favCategory[0] : null, categoryVariety: Object.keys(categoryCount).length } } /** * 本地计算连续打卡天数(修正后端 streak 不区分饮酒/戒酒模式的问题) * 双轨计算: * - 连续饮酒:从今天的饮酒记录向前追溯;今天未饮酒(戒酒/未打卡)则从昨天算起 * - 连续戒酒:从今天的戒酒记录向前追溯;今天饮酒/未打卡则从昨天算起 * @param {Array} records 记录列表,每项需含 date 和 mode 字段 * @returns {{ streak, streakType, drankStreak, abstainStreak }} * streak/streakType:主展示值(今天有记录取今天模式,否则取最近记录模式) */ export function calcLocalStreak(records) { if (!records || !records.length) { return { streak: 0, streakType: 'drank', drankStreak: 0, abstainStreak: 0 } } const today = formatDate(new Date(), 'YYYY-MM-DD') // 按日期去重(同一天多条取第一条) const byDate = {} records.forEach(r => { if (r && r.date && byDate[r.date] === undefined) byDate[r.date] = r }) const dates = Object.keys(byDate).sort() // 单模式连续链:优先从今天起算,今天非该模式则宽限到昨天 const chainOf = (mode) => { const todayMode = byDate[today] ? byDate[today].mode : null let base = null if (todayMode === mode) { base = today } else { const y = new Date() y.setDate(y.getDate() - 1) const yStr = formatDate(y, 'YYYY-MM-DD') if (byDate[yStr] && byDate[yStr].mode === mode) base = yStr } if (!base) return 0 let count = 1 const d = new Date(base) for (let i = 1; i < 366; i++) { const prev = new Date(d) prev.setDate(prev.getDate() - i) const r = byDate[formatDate(prev, 'YYYY-MM-DD')] if (r && r.mode === mode) { count++ } else { break } } return count } const drankStreak = chainOf('drank') const abstainStreak = chainOf('abstain') // 主展示值:今天有记录取今天模式,否则取最近一次记录的模式 let streak let streakType if (byDate[today]) { streakType = byDate[today].mode || 'drank' streak = streakType === 'drank' ? drankStreak : abstainStreak } else { const lastDate = dates[dates.length - 1] streakType = byDate[lastDate].mode || 'drank' streak = streakType === 'drank' ? drankStreak : abstainStreak } return { streak, streakType, drankStreak, abstainStreak } } /** * 检查成就解锁 */ export function checkAchievements(stats, achievements) { return achievements.map(a => { let unlocked = false const { type, value, category } = a.condition switch (type) { case 'total_records': unlocked = stats.totalRecords >= value break case 'total_standard_cups': unlocked = stats.totalCups >= value break case 'streak_days': unlocked = stats.streak >= value break case 'category_variety': unlocked = stats.categoryVariety >= value break case 'category_records': // 依赖 stats.categoryRecords({ baijiu: n, beer: n, ... }); // 统计中无该字段时保守判为未解锁 unlocked = ((stats.categoryRecords && stats.categoryRecords[category]) || 0) >= value break } return { ...a, unlocked } }) }