Files
hejiu/pages/index/index.vue
T
cg 860136bceb feat(theme): 实现日夜主题自动切换功能
- 添加日夜主题CSS变量定义,支持琥珀夜光和暖白琥珀两种风格
- 实现主题切换逻辑,根据时间自动切换白天(light)和夜间(dark)主题
- 在App.vue中集成主题初始化和token恢复功能
- 更新全局样式类应用主题颜色变量,包括卡片、文本、边框等
- 创建主题混入(mixin)供各页面使用,确保主题同步更新
- 实现导航栏和TabBar主题动态切换,提升用户体验
- 添加API服务层封装HaveADrink SDK,统一处理认证和请求
- 优化DrinkCard组件显示饮酒感受信息,丰富打卡记录展示
- 更新项目依赖配置,集成新的API客户端库
2026-07-16 22:47:38 +08:00

579 lines
15 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>{{ drink.emoji || '🥃' }} {{ 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 { getGreeting, formatDate, getWeekStart } from '../../common/utils'
import { FEELINGS } 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')
const isLoggedIn = uni.getStorageSync('is_logged_in')
if (isFirst === 'true' || !isLoggedIn) {
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.GetRecords({ page: 1, pageSize: 100 })
])
// 用户信息
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 list = recordsResp.value.list || recordsResp.value
this.records = Array.isArray(list) ? list.map(r => ({
...r,
mode: String(r.mode),
feeling: r.feeling ? String(r.feeling) : null,
visibility: r.visibility ? String(r.visibility) : 'private',
drinks: (r.drinks || []).map(d => ({
...d,
category: String(d.category),
unit: String(d.unit)
}))
})) : []
}
} 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 monthStr = `${year}-${String(month).padStart(2, '0')}`
const resp = await client.GetRecords({ page: 1, pageSize: 100, month: monthStr })
const list = resp.list || resp
this.records = Array.isArray(list) ? list.map(r => ({
...r,
mode: String(r.mode),
feeling: r.feeling ? String(r.feeling) : null,
visibility: r.visibility ? String(r.visibility) : 'private',
drinks: (r.drinks || []).map(d => ({
...d,
category: String(d.category),
unit: String(d.unit)
}))
})) : []
} 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}` : ''
},
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>