Files
hejiu/pages/index/index.vue
T
cg adf6b684b9 feat(calendar): 更新日历接口并优化记录流程
- 将GetRecords接口替换为GetCalendar接口获取日历数据
- 重构首页日历数据显示逻辑,支持年月维度的数据获取
- 优化打卡记录流程,合并选酒和记量步骤为单一添加酒水步骤
- 移除登录页面的游客模式选项,简化首次启动逻辑
- 调整引导页逻辑,移除登录跳转改为直接进入首页
- 优化记录页面UI布局,添加状态栏安全距离适配
- 新增酒水列表管理功能,支持添加和删除已选酒水
- 更新依赖包HaveADrink至v1.0.3版本,同步API变更
2026-07-17 23:51:43 +08:00

590 lines
16 KiB
Vue
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.
<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">
<text>{{ getCatEmoji(drink.category) }} {{ drink.brand }} {{ drink.product }}</text>
</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>
<button class="btn-ghost day-toggle-btn" @click="toggleToAbstain">改为未饮酒</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>
</view>
</view>
<view v-else class="day-empty">
<text class="text-caption">当天无记录</text>
<button 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 } from 'HaveADrink'
import { getGreeting, formatDate, getWeekStart } from '../../common/utils'
import { FEELINGS, DRINK_CATEGORIES } from '../../common/constants'
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)
}
},
onShow() {
// 首次启动跳转引导页
const isFirst = uni.getStorageSync('is_first_launch')
if (isFirst === 'true') {
uni.reLaunch({ url: '/pages/onboarding/onboarding' })
return
}
this.loadData()
},
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 || '酒友'
uni.setStorageSync('user_info', JSON.stringify(user))
} else {
// 降级读取本地缓存
try {
const cached = JSON.parse(uni.getStorageSync('user_info') || '{}')
this.userName = cached.nickname || '酒友'
} catch (e) {
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)
})),
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)
// 降级:读取本地缓存
try {
const cached = JSON.parse(uni.getStorageSync('user_info') || '{}')
this.userName = cached.nickname || '酒友'
} catch (err) {
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)
})),
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)
}
},
onDateSelect(dateStr) {
this.selectedDate = dateStr
this.selectedRecord = this.records.find(r => r.date === dateStr) || null
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 : '🥃'
},
backfillDate() {
this.showDayDetail = false
uni.navigateTo({
url: `/pages/record/record?date=${this.selectedDate}&mode=backfill`
})
},
// 改为未饮酒
toggleToAbstain() {
uni.showModal({
title: '确认修改',
content: '确定将这天改为未饮酒吗?饮酒记录将被清除。',
success: (res) => {
if (!res.confirm) return
const idx = this.records.findIndex(r => r.date === this.selectedDate)
if (idx === -1) return
this.records[idx].mode = 'abstain'
this.records[idx].drinks = []
this.records[idx].food = null
this.records[idx].feeling = null
this.records[idx].standardCupsTotal = 0
this.selectedRecord = this.records[idx]
this.saveRecords()
}
})
},
// 改为饮酒(跳转到补录页)
toggleToDrank() {
this.showDayDetail = false
uni.navigateTo({
url: `/pages/record/record?date=${this.selectedDate}&mode=backfill`
})
},
// 保存记录到本地存储
saveRecords() {
uni.setStorageSync('drink_records', JSON.stringify(this.records))
},
goRecord() {
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-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 {
margin-top: $sp-lg;
}
}
.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>