Files
cg 57eef74eb1 feat(sdk): 升级HaveADrink SDK并集成邀请系统
- 将HaveADrink依赖从1.0.8升级至1.0.12版本
- 集成邀请码功能,新增common/invite.js处理邀请链路
- 实现发送好友请求返回邀请码的新流程
- 添加删除动态功能,新增DeleteFeed接口
- 集成UniAppWebSocket适配器,重构WebSocket连接管理
- 优化好友请求支持关键词搜索功能
- 在FeedCard组件中添加删除按钮和分享按钮
- 更新SDK类型定义文件以匹配新接口规范
2026-08-04 23:32:45 +08:00

737 lines
20 KiB
Vue
Raw Permalink 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.
<template>
<view :class="themeClass" class="page-container home">
<!-- 顶部问候 -->
<view class="greeting-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="greeting-left">
<text class="greeting-text">{{ greeting }}{{ userName }}</text>
<text class="greeting-sub">今晚喝一杯</text>
</view>
</view>
<!-- 核心CTA - 首页最醒目位置 -->
<view class="hero-cta" @click="goRecord">
<view class="hero-cta-bg"></view>
<view class="hero-cta-content">
<text class="hero-cta-emoji">🍻</text>
<view class="hero-cta-text">
<text class="hero-cta-title">开始记录</text>
<text class="hero-cta-sub">记录今晚的酒局</text>
</view>
<text class="hero-cta-arrow"></text>
</view>
</view>
<!-- 统计卡片 -->
<view class="stats-row">
<view class="stat-card card">
<text class="stat-value text-num text-amber">{{ stats.monthDrinkCount }}</text>
<text class="stat-label">本月饮酒</text>
</view>
<view class="stat-card card">
<text class="stat-value text-num">{{ stats.monthCups }}</text>
<text class="stat-label">标准杯</text>
</view>
<view class="stat-card card">
<text class="stat-value text-num" :class="stats.streakType === 'drank' ? 'text-amber' : 'text-mint'">
{{ stats.streak }}
</text>
<text class="stat-label">{{ stats.streakType === 'drank' ? '连喝天数' : '戒酒天数' }}</text>
</view>
</view>
<!-- 饮酒日历 -->
<view class="calendar-section card">
<DrinkCalendar
:year="calendarYear"
:month="calendarMonth"
:records="records"
@change-month="onMonthChange"
@select-date="onDateSelect"
/>
</view>
<!-- 本周摘要 -->
<view class="week-summary card">
<view class="week-header flex-between">
<text class="text-h3">本周摘要</text>
<text class="text-caption">{{ weekRange }}</text>
</view>
<view class="week-stats flex">
<view class="week-stat">
<text class="week-stat-value text-num">{{ stats.weekDrinkCount }}</text>
<text class="week-stat-label">次饮酒</text>
</view>
<view class="week-divider"></view>
<view class="week-stat">
<text class="week-stat-value text-num text-amber">{{ stats.weekCups }}</text>
<text class="week-stat-label">标准杯</text>
</view>
</view>
<view class="health-ref" v-if="stats.weekCups > 0">
<view class="health-bar-fill" :style="{ width: healthPercent + '%' }"></view>
<text class="health-text">
{{ stats.weekCups > 14 ? '本周超标了,注意休息' : '本周控制得不错' }}
</text>
</view>
</view>
<!-- 底部健康提示 -->
<view class="bottom-health-tip">
<text>过量饮酒有害健康 · 请理性记录</text>
</view>
<!-- 日期记录弹窗 -->
<view v-if="showDayDetail" class="day-overlay" @click="showDayDetail = false">
<view class="day-detail card-elevated" @click.stop>
<view class="day-detail-header flex-between">
<text class="text-h3">{{ selectedDate }}</text>
<text class="btn-text" @click="showDayDetail = false">关闭</text>
</view>
<view v-if="selectedRecord">
<view v-if="selectedRecord.mode === 'drank'" class="day-record-info">
<view class="day-drinks">
<view v-for="(drink, i) in selectedRecord.drinks" :key="i" class="day-drink-item">
<view class="day-drink-row">
<view class="day-drink-name">
<image v-if="isIconPath(getCatIcon(drink.category))" :src="getCatIcon(drink.category)" class="day-drink-icon" mode="aspectFit"></image>
<text v-else>{{ getCatEmoji(drink.category) }}</text>
<text>{{ drink.brand }} {{ drink.product }}</text>
</view>
<text v-if="drink.customAmount != null" class="day-drink-amount text-num">{{ drink.customAmount }}{{ getUnitLabel(drink.customUnit) }}</text>
</view>
</view>
</view>
<view class="day-meta flex-between">
<text class="text-caption">{{ getFeelingName(selectedRecord.feeling) }}</text>
<text class="text-caption text-amber text-num">{{ selectedRecord.standardCupsTotal }} 标准杯</text>
</view>
<view class="day-action-row">
<button class="btn-ghost day-action-btn" @click="editRecord">修改</button>
<button class="btn-ghost day-action-btn" @click="toggleToAbstain">改为未饮酒</button>
</view>
<button class="btn-danger day-delete-btn" @click="deleteRecord">删除记录</button>
</view>
<view v-else class="day-abstain">
<text class="day-abstain-emoji">🚫</text>
<text class="text-caption">今日未饮酒好样的</text>
<button class="btn-ghost day-toggle-btn" @click="toggleToDrank">改为饮酒</button>
<button class="btn-danger day-delete-btn" @click="deleteRecord">删除记录</button>
</view>
</view>
<view v-else class="day-empty">
<text class="text-caption">当天无记录</text>
<button v-if="isSelectedToday" class="btn-primary day-record-btn" @click="goRecordToday">去记录</button>
<button v-else class="btn-ghost" @click="backfillDate">补打卡</button>
</view>
</view>
</view>
</view>
</template>
<script>
import DrinkCalendar from '../../components/DrinkCalendar.vue'
import client from '../../common/api'
import { GetCalendarReq, DeleteRecordReq, CreateRecordReq, GetRecordDetailReq } from 'HaveADrink'
import { getGreeting, formatDate, getWeekStart, getCatIcon, isIconPath } from '../../common/utils'
import { FEELINGS, DRINK_CATEGORIES, DRINK_UNITS } from '../../common/constants'
import { savePendingInvite, handlePendingInvite } from '../../common/invite'
import themeMixin from '../../common/theme-mixin'
export default {
mixins: [themeMixin],
components: { DrinkCalendar },
data() {
const now = new Date()
const sysInfo = uni.getSystemInfoSync()
return {
statusBarHeight: sysInfo.statusBarHeight || 25,
calendarYear: now.getFullYear(),
calendarMonth: now.getMonth() + 1,
records: [],
stats: {},
greeting: '',
userName: '',
showDayDetail: false,
selectedDate: '',
selectedRecord: null
}
},
computed: {
weekRange() {
const weekStart = getWeekStart()
const weekEnd = new Date(weekStart)
weekEnd.setDate(weekEnd.getDate() + 6)
return `${formatDate(weekStart, 'MM/DD')} - ${formatDate(weekEnd, 'MM/DD')}`
},
healthPercent() {
// 建议每周不超过14标准杯
return Math.min((this.stats.weekCups / 14) * 100, 100)
},
isSelectedToday() {
return this.selectedDate === formatDate(new Date(), 'YYYY-MM-DD')
}
},
onLoad(options) {
// 分享邀请落地:携带 inviteCode 时暂存邀请,已登录则立即接受邀请
if (options && options.inviteCode) {
savePendingInvite(options.inviteCode, options.inviterNick)
if (uni.getStorageSync('is_logged_in') === 'true') {
handlePendingInvite()
}
}
},
onShow() {
// 首次启动跳转引导页
const isFirst = uni.getStorageSync('is_first_launch')
if (isFirst === 'true') {
uni.reLaunch({ url: '/pages/onboarding/onboarding' })
return
}
this.loadData()
},
onShareAppMessage() {
return {
title: '碰盏日记 - 让每一杯酒都有迹可循',
path: '/pages/index/index'
}
},
onShareTimeline() {
return {
title: '碰盏日记 - 让每一杯酒都有迹可循',
query: ''
}
},
methods: {
async loadData() {
this.greeting = getGreeting()
// 并行加载用户信息、统计概览、记录列表
try {
const [userResp, statsResp, recordsResp] = await Promise.allSettled([
client.GetUserProfile({}),
client.GetStatsOverview({}),
client.GetCalendar(new GetCalendarReq(this.calendarYear, this.calendarMonth))
])
// 用户信息
if (userResp.status === 'fulfilled' && userResp.value) {
const user = userResp.value
this.userName = user.nickname || '酒友'
} else {
this.userName = '酒友'
}
// 统计概览
if (statsResp.status === 'fulfilled' && statsResp.value) {
const s = statsResp.value.data || statsResp.value
this.stats = {
weekDrinkCount: s.weekDrinkCount || 0,
weekCups: s.weekCups || 0,
monthDrinkCount: s.monthDrinkCount || 0,
monthCups: s.monthCups || 0,
streak: s.streak || 0,
streakType: String(s.streakType || 'drank'),
totalDays: s.totalDays || 0,
totalRecords: s.totalRecords || 0,
totalCups: s.totalCups || 0,
favCategory: s.favCategory ? String(s.favCategory) : null,
categoryVariety: s.categoryVariety || 0
}
}
// 日历数据
if (recordsResp.status === 'fulfilled' && recordsResp.value) {
const calData = recordsResp.value.data || recordsResp.value
const days = calData.days || []
this.records = days.filter(d => d && d.hasRecord).map(d => ({
id: d.id || '',
date: d.date,
mode: String(d.mode),
standardCupsTotal: d.standardCupsTotal || 0,
drinks: (d.drinks || []).map(dr => ({
...dr,
category: String(dr.category),
customAmount: dr.customAmount,
customUnit: dr.customUnit ? String(dr.customUnit) : ''
})),
food: d.food ? { category: String(d.food.category), name: d.food.name || '' } : null,
feeling: d.feeling ? String(d.feeling) : null,
quote: d.quote || '',
visibility: d.visibility ? String(d.visibility) : 'friends'
}))
}
} catch (e) {
console.warn('加载首页数据失败:', e)
this.userName = '酒友'
}
},
onMonthChange({ year, month }) {
this.calendarYear = year
this.calendarMonth = month
// 切换月份时重新加载记录
this.loadRecordsForMonth(year, month)
},
async loadRecordsForMonth(year, month) {
try {
const resp = await client.GetCalendar(new GetCalendarReq(year, month))
const calData = resp.data || resp
const days = calData.days || []
this.records = days.filter(d => d && d.hasRecord).map(d => ({
id: d.id || '',
date: d.date,
mode: String(d.mode),
standardCupsTotal: d.standardCupsTotal || 0,
drinks: (d.drinks || []).map(dr => ({
...dr,
category: String(dr.category),
customAmount: dr.customAmount,
customUnit: dr.customUnit ? String(dr.customUnit) : ''
})),
food: d.food ? { category: String(d.food.category), name: d.food.name || '' } : null,
feeling: d.feeling ? String(d.feeling) : null,
quote: d.quote || '',
visibility: d.visibility ? String(d.visibility) : 'friends'
}))
} catch (e) {
console.warn('加载月度记录失败:', e)
}
},
async onDateSelect(dateStr) {
this.selectedDate = dateStr
const localRecord = this.records.find(r => r.date === dateStr) || null
// 如果有记录且有id,拉取完整详情(确保拿到 customAmount/customUnit 等全量字段)
if (localRecord && localRecord.id && localRecord.mode === 'drank') {
this.selectedRecord = localRecord // 先用本地数据展示
this.showDayDetail = true
try {
const resp = await client.GetRecordDetail(new GetRecordDetailReq(localRecord.id))
const rec = resp.data || resp
if (rec) {
this.selectedRecord = {
...localRecord,
...rec,
mode: String(rec.mode || localRecord.mode),
drinks: (rec.drinks || localRecord.drinks || []).map(d => ({
...d,
category: String(d.category),
customUnit: d.customUnit ? String(d.customUnit) : ''
})),
feeling: rec.feeling ? String(rec.feeling) : localRecord.feeling,
visibility: rec.visibility ? String(rec.visibility) : localRecord.visibility
}
}
} catch (e) {
console.warn('获取记录详情失败,使用本地数据:', e)
}
} else {
this.selectedRecord = localRecord
this.showDayDetail = true
}
},
getFeelingName(feelingId) {
const f = FEELINGS.find(f => f.id === feelingId)
return f ? `${f.emoji} ${f.name}` : ''
},
getCatEmoji(catId) {
const cat = DRINK_CATEGORIES.find(c => c.id === catId)
return cat ? cat.emoji : '🥃'
},
getCatIcon,
isIconPath,
getUnitLabel(unitId) {
if (!unitId) return ''
const u = DRINK_UNITS.find(u => u.id === unitId)
return u ? u.name : unitId
},
backfillDate() {
this.showDayDetail = false
uni.navigateTo({
url: `/pages/record/record?date=${this.selectedDate}&mode=backfill`
})
},
// 修改记录(跳转到记录页编辑)
editRecord() {
this.showDayDetail = false
uni.navigateTo({
url: `/pages/record/record?date=${this.selectedDate}&mode=edit`
})
},
// 改为未饮酒
toggleToAbstain() {
uni.showModal({
title: '确认修改',
content: '确定将这天改为未饮酒吗?饮酒记录将被清除。',
success: async (res) => {
if (!res.confirm) return
const recordId = this.selectedRecord && this.selectedRecord.id
try {
if (recordId) {
await client.DeleteRecord(new DeleteRecordReq(recordId))
}
uni.showToast({ title: '已修改', icon: 'success' })
this.showDayDetail = false
this.loadData()
} catch (e) {
console.warn('修改为未饮酒失败:', e)
uni.showToast({ title: '修改失败,请重试', icon: 'none' })
}
}
})
},
// 改为饮酒(跳转到补录页)
toggleToDrank() {
this.showDayDetail = false
uni.navigateTo({
url: `/pages/record/record?date=${this.selectedDate}&mode=backfill`
})
},
// 删除记录
deleteRecord() {
uni.showModal({
title: '确认删除',
content: `确定删除 ${this.selectedDate} 的记录吗?删除后不可恢复。`,
confirmColor: '#e85d3a',
success: async (res) => {
if (!res.confirm) return
const recordId = this.selectedRecord && this.selectedRecord.id
try {
if (recordId) {
await client.DeleteRecord(new DeleteRecordReq(recordId))
}
this.showDayDetail = false
uni.showToast({ title: '已删除', icon: 'success' })
// 重新加载统计和日历数据
this.loadData()
} catch (e) {
console.warn('删除记录失败:', e)
uni.showToast({ title: '删除失败,请重试', icon: 'none' })
}
}
})
},
goRecord() {
uni.navigateTo({ url: '/pages/record/record' })
},
goRecordToday() {
this.showDayDetail = false
uni.navigateTo({ url: '/pages/record/record' })
},
}
}
</script>
<style lang="scss" scoped>
.home {
padding: $sp-md;
padding-top: 0;
}
.greeting-bar {
display: flex;
align-items: flex-start;
padding: $sp-md 0 $sp-sm;
}
.greeting-text {
display: block;
font-size:38rpx;
font-weight: $fw-bold;
color: $text-primary;
}
.greeting-sub {
display: block;
font-size: $fs-xs;
color: $text-secondary;
margin-top: 4rpx;
}
.calendar-section {
margin-bottom: $sp-md;
padding: $sp-md;
}
.stats-row {
display: flex;
gap: $sp-xs;
margin-bottom: $sp-md;
}
.stat-card {
flex: 1;
text-align: center;
padding: $sp-md $sp-xs;
}
.stat-value {
display: block;
font-size: $fs-xl;
color: $text-primary;
margin-bottom: 4rpx;
}
.stat-label {
font-size: $fs-xs;
color: $text-secondary;
}
.week-summary {
padding: $sp-md;
}
.week-header {
margin-bottom: $sp-sm;
}
.week-stats {
gap: 0;
}
.week-stat {
flex: 1;
text-align: center;
}
.week-stat-value {
display: block;
font-size: $fs-xl;
font-weight: $fw-bold;
color: $text-primary;
margin-bottom: 4rpx;
}
.week-stat-label {
font-size: $fs-sm;
color: $text-secondary;
}
.week-divider {
width: 2rpx;
background: var(--divider-color, rgba(255, 255, 255, 0.08));
margin: $sp-xs 0;
}
.health-ref {
margin-top: $sp-md;
position: relative;
height: 12rpx;
background: $bg-elevated;
border-radius: $radius-full;
overflow: hidden;
}
.health-bar-fill {
height: 100%;
border-radius: $radius-full;
background: linear-gradient(90deg, $mint, $amber);
transition: width $duration-slow $ease-out;
}
.health-text {
display: block;
font-size: $fs-xs;
color: $text-tertiary;
margin-top: $sp-xs;
}
/* 核心CTA - 首页英雄区域 */
.hero-cta {
position: relative;
margin-bottom: $sp-md;
border-radius: $radius-lg;
overflow: hidden;
padding: $sp-md $sp-lg;
cursor: pointer;
&:active {
transform: scale(0.98);
}
}
.hero-cta-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, $amber-light, $amber, $amber-deep);
z-index: 0;
&::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, var(--btn-shimmer, rgba(255, 255, 255, 0.15)), transparent);
animation: shimmer 4s infinite;
}
}
@keyframes shimmer {
0% { left: -100%; }
100% { left: 100%; }
}
.hero-cta-content {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: $sp-md;
}
.hero-cta-emoji {
font-size: 56rpx;
}
.hero-cta-text {
flex: 1;
}
.hero-cta-title {
display: block;
font-size: $fs-xl;
font-weight: $fw-black;
color: $text-on-amber;
letter-spacing: -1rpx;
}
.hero-cta-sub {
display: block;
font-size: $fs-xs;
color: rgba(11, 11, 20, 0.6);
margin-top: 4rpx;
}
.hero-cta-arrow {
font-size: 44rpx;
color: $text-on-amber;
opacity: 0.5;
font-weight: $fw-bold;
}
.bottom-health-tip {
text-align: center;
padding: $sp-sm 0;
font-size: $fs-xs;
color: $text-tertiary;
opacity: 0.4;
}
/* 日期弹窗 */
.day-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--overlay-mask, rgba(0, 0, 0, 0.6));
display: flex;
align-items: flex-end;
z-index: 100;
padding: $sp-lg;
}
.day-detail {
width: 100%;
padding: $sp-xl;
color: $text-primary;
border-radius: $radius-xl $radius-xl 0 0;
}
.day-detail-header {
margin-bottom: $sp-lg;
}
.day-drink-item {
padding: $sp-sm 0;
font-size: $fs-base;
color: $text-primary;
border-bottom: 2rpx solid var(--border-micro, rgba(255, 255, 255, 0.05));
}
.day-drink-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.day-drink-name {
display: flex;
align-items: center;
gap: $sp-xs;
flex: 1;
min-width: 0;
}
.day-drink-icon {
width: 36rpx;
height: 36rpx;
flex-shrink: 0;
}
.day-drink-amount {
font-size: $fs-sm;
color: $text-secondary;
white-space: nowrap;
margin-left: $sp-sm;
}
.day-meta {
margin-top: $sp-lg;
}
.day-abstain {
text-align: center;
padding: $sp-xl 0;
}
.day-abstain-emoji {
display: block;
font-size: $fs-hero;
margin-bottom: $sp-sm;
}
.day-empty {
text-align: center;
padding: $sp-xl 0;
.btn-ghost, .day-record-btn {
margin-top: $sp-lg;
width: 100%;
}
}
.day-action-row {
display: flex;
gap: $sp-sm;
margin-top: $sp-lg;
}
.day-action-btn {
flex: 1;
font-size: $fs-sm;
color: $text-secondary;
border-color: var(--border-active, rgba(255, 255, 255, 0.12));
}
.day-delete-btn {
margin-top: $sp-md;
width: 100%;
font-size: $fs-sm;
color: #e85d3a;
background: rgba(232, 93, 58, 0.08);
border: 2rpx solid rgba(232, 93, 58, 0.2);
}
.day-toggle-btn {
margin-top: $sp-lg;
width: 100%;
font-size: $fs-sm;
color: $text-secondary;
border-color: var(--border-active, rgba(255, 255, 255, 0.12));
}
</style>