Files
hejiu/pages/index/index.vue
T
cg 9f5c69e1e5 feat(app): 项目重命名为干杯日记并优化UI设计
- 将应用名称从"喝了么"更改为"干杯日记"
- 更新所有相关文件中的品牌标识和描述信息
- 升级HaveADrink依赖包从1.0.3到1.0.4版本
- 在pages.json中新增分享个人资料页面配置
- 重构DrinkCard组件UI,增加装饰背景、酒类图标支持、饮酒感受徽章等功能
- 优化API错误处理逻辑,增加静默模式支持
- 修改白酒类别图标为 urn emoji,黄酒图标为 tea emoji
- 添加分享功能按钮并优化页面返回交互体验
- 引入新的工具函数用于获取酒类图标和路径判断
- 更新主题混入、常量定义等基础配置文件
- 调整全局样式和设计令牌以匹配新品牌形象
2026-07-20 22:12:27 +08:00

727 lines
20 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">
<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 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')
}
},
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>