Files
hejiu/common/utils.js
T
cg 18e7b4fd50 feat(api): 添加成就系统并升级SDK
- 升级 HaveADrink SDK 从 1.0.16 到 1.0.17 版本
- 新增 GetAchievements API 接口及相关数据类型定义
- 添加 17 个成就定义,包括打卡次数、连续打卡、标准杯累计、酒类探索等类别
- 实现成就解锁逻辑计算,支持 category_records 类型成就判断
- 创建成就徽章对接文档,定义接口格式和存储建议
- 在用户资料页面集成成就展示功能,优化图标匹配逻辑
2026-08-17 23:07:23 +08:00

400 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 碰盏日记 - 工具函数 */
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':
// 依赖 stats.categoryRecords({ baijiu: n, beer: n, ... });
// 统计中无该字段时保守判为未解锁
unlocked = ((stats.categoryRecords && stats.categoryRecords[category]) || 0) >= value
break
}
return { ...a, unlocked }
})
}