/* 碰盏日记 - 工具函数 */ 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 getCurrentTheme() { return 'dark' } export function applyTheme(theme) { // 同步存储当前主题 uni.setStorageSync('current_theme', theme) // 应用导航栏和TabBar主题 applyNavBarTheme(theme) applyTabBarTheme(theme) } export function applyNavBarTheme(theme) { // 仅深色主题 try { uni.setNavigationBarColor({ frontColor: '#ffffff', backgroundColor: '#0B0B14', animation: { duration: 300, timingFunc: 'easeInOut' } }) } catch (e) {} } export function applyTabBarTheme(theme) { // 仅在 tabBar 页面上调用 setTabBarStyle,避免报错 // 页面清单直接从 pages.json 读取,新增 tab 页无需改这里 let tabBarPages = [] try { // #ifdef MP-WEIXIN tabBarPages = (typeof __wxConfig !== 'undefined' && __wxConfig.tabBar && __wxConfig.tabBar.list) ? __wxConfig.tabBar.list.map(t => t.pagePath) : [] // #endif if (!tabBarPages.length && typeof require === 'function') { const cfg = require('../pages.json') tabBarPages = (cfg.tabBar && cfg.tabBar.list) ? cfg.tabBar.list.map(t => t.pagePath) : [] } } catch (e) { tabBarPages = [] } // 两种方式都取不到清单时兜底,保证主题正常应用 if (!tabBarPages.length) { tabBarPages = ['pages/index/index', 'pages/circle/circle', 'pages/profile/profile'] } const pages = getCurrentPages() if (!pages.length) return const currentPath = pages[pages.length - 1].route if (!tabBarPages.includes(currentPath)) return // 仅深色主题 const s = { color: '#9494AC', selectedColor: '#E8A838', backgroundColor: '#151520', borderStyle: 'black' } try { uni.setTabBarStyle({ color: s.color, selectedColor: s.selectedColor, backgroundColor: s.backgroundColor, borderStyle: s.borderStyle, // 必须传 fail 回调吞错:不传时 uni API 返回 Promise, // 异步 reject 无法被 try/catch 捕获,会产生 UnhandledPromiseRejection fail: () => {} }) } catch (e) {} } export function getThemeColors(theme) { // 仅保留深色主题色值(参数保留以兼容既有调用) return { textPrimary: '#FFFFFF', textSecondary: '#C0C0D2', textTertiary: '#9494AC', amber: '#E8A838', bgBase: '#0B0B14', bgCard: '#1A1A28', cardGradStart: '#1A1510', cardGradEnd: '#0D0B08', borderSubtle: 'rgba(232,168,56,0.15)', bgPlaceholder: 'rgba(255,255,255,0.03)', emojiBg: 'rgba(232,168,56,0.08)' } } /** * 检查成就解锁 */ 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': // 需要更细粒度的数据,这里简化处理 unlocked = false break } return { ...a, unlocked } }) }