Files
smt/pages/index/index.vue
T
nepiedg ebca42c34e feat: MVP 精简版 - 隐藏 AI 助手模块
从 TabBar 移除 AI 助手入口,首页隐藏 AI 提示卡片。保留首页、统计、记录、我的四个核心 Tab,聚焦记录与数据功能优先上线。

Made-with: Cursor
2026-03-04 00:32:44 +08:00

688 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="page">
<!-- 自定义导航栏占位状态栏 + 胶囊按钮区域 -->
<view class="nav-placeholder" :style="{ height: navBarHeight + 'px' }"></view>
<view v-if="loading" class="skeleton">
<view class="skeleton-header"></view>
<view class="skeleton-tip"></view>
<view class="skeleton-timer"></view>
<view class="skeleton-cards"></view>
<view class="skeleton-buttons"></view>
</view>
<view v-else class="dashboard">
<view class="header">
<view class="user-info">
<image class="avatar" :src="userAvatar" mode="aspectFill"></image>
<view class="greeting">
<text class="greeting-text">{{ greetingTitle }}</text>
<text class="greeting-sub">{{ greetingSubtitle }}</text>
</view>
</view>
</view>
<!-- AI 提示卡片MVP 阶段隐藏
<view v-if="shouldShowAiTip" class="ai-tip">
<view class="ai-tip-icon">🤖</view>
<view class="ai-tip-content">
<text class="ai-tip-title">{{ aiTipTitle }}</text>
<text class="ai-tip-desc">{{ aiTipText }}</text>
</view>
<view class="ai-tip-close" @tap="closeAiTip">×</view>
</view>
-->
<view class="timer-section">
<view class="timer-ring">
<view class="timer-ring-bg"></view>
<view class="timer-ring-progress" :style="{ background: timerGradient }"></view>
<view class="timer-content">
<text class="timer-label">距上次抽烟</text>
<text class="timer-value">{{ timerDisplay }}</text>
<view class="next-time" v-if="nextSmokeTimeText">
<text class="next-time-text"> 下次建议: {{ nextSmokeTimeText }}</text>
</view>
</view>
</view>
</view>
<view class="stats-row">
<view class="stat-card">
<view class="stat-dot stat-dot-red"></view>
<text class="stat-label">今日已抽</text>
<view class="stat-value-row">
<text class="stat-value">{{ todayCount }}</text>
<text class="stat-target">/ {{ dailyTarget }}</text>
<view class="stat-change" :class="changeClass">{{ changeText }}</view>
</view>
<view class="stat-progress">
<view class="stat-progress-bar stat-progress-red" :style="{ width: progressWidth }"></view>
</view>
</view>
<view class="stat-card">
<view class="stat-dot stat-dot-green"></view>
<text class="stat-label">烟瘾发作</text>
<view class="stat-value-row">
<text class="stat-value">{{ resistedCount }}</text>
<text class="stat-unit">已抵抗</text>
</view>
<view class="stat-progress">
<view class="stat-progress-bar stat-progress-green" :style="{ width: resistedProgressWidth }"></view>
</view>
</view>
</view>
<view class="action-buttons">
<view class="action-btn action-btn-secondary" @tap="openSmokeDialog">
<text class="action-icon">🚬</text>
<text>记录抽烟</text>
</view>
<view class="action-btn action-btn-primary" @tap="openResistedDialog">
<text class="action-icon">💪</text>
<text>想抽忍住了</text>
</view>
</view>
</view>
<!-- 记录弹框组件 -->
<smoke-record-dialog
v-model:show="showDialog"
:type="dialogType"
@submit="handleSubmit"
/>
</view>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useProfileStore } from '@/stores/profile'
import { useUserStore } from '@/stores/user'
import { useLogin } from '@/hooks/useLogin'
import * as api from '@/api'
const profileStore = useProfileStore()
const userStore = useUserStore()
const { waitForLogin } = useLogin()
const loading = ref(true)
const showAiTip = ref(true)
const navBarHeight = ref(0)
const showDialog = ref(false)
const dialogType = ref('smoke') // 'smoke' 或 'resisted'
const homeData = ref(null)
let timerInterval = null
const timerSeconds = ref(0)
const timerBaseSeconds = ref(-1)
const aiTipFallback = '你的烟瘾通常在下午2点达到高峰。我们为你准备了一个快速呼吸练习。'
const greeting = computed(() => {
const hour = new Date().getHours()
if (hour < 6) return '凌晨好'
if (hour < 12) return '早上好'
if (hour < 14) return '中午好'
if (hour < 18) return '下午好'
return '晚上好'
})
const userName = computed(() => {
return homeData.value?.greeting?.nickname || userStore.user?.nickname || 'Alex'
})
const userAvatar = computed(() => {
return homeData.value?.greeting?.avatar_url || userStore.user?.avatar_url || '/static/images/default-avatar.png'
})
const greetingTitle = computed(() => {
return homeData.value?.greeting?.title || `${greeting.value}${userName.value}`
})
const greetingSubtitle = computed(() => {
return homeData.value?.greeting?.subtitle || '保持连胜纪录!🔥'
})
const aiTipTitle = computed(() => {
return homeData.value?.advice_card?.title || '发现规律'
})
const aiTipText = computed(() => {
const advice = homeData.value?.advice_card
if (advice?.message) return advice.message
if (advice?.status === 'locked') return '看广告解锁 AI 建议'
const motivation = homeData.value?.motivation?.message
return motivation || aiTipFallback
})
const shouldShowAiTip = computed(() => showAiTip.value && !!aiTipText.value)
const homeSummary = computed(() => homeData.value?.summary || {})
const homeTimer = computed(() => homeData.value?.timer || {})
const todayCount = computed(() => homeSummary.value.today_count ?? 0)
const dailyTarget = computed(() => {
const target = homeSummary.value.daily_target
if (target !== undefined && target !== null) return target
return profileStore.profile?.baseline_cigs_per_day || 0
})
const resistedCount = computed(() => homeSummary.value.resisted_count ?? 0)
const progressWidth = computed(() => {
const target = dailyTarget.value
if (!target) return '0%'
const percent = Math.min((todayCount.value / target) * 100, 100)
return `${percent}%`
})
const resistedProgressWidth = computed(() => {
const percent = Math.min((resistedCount.value / 10) * 100, 100)
return `${percent}%`
})
const changeText = computed(() => {
const reduced = homeSummary.value.reduced_from_yesterday
if (reduced === undefined || reduced === null) return '较昨日 --'
if (reduced === 0) return '较昨日 0'
return homeSummary.value.exceeded_yesterday ? `较昨日 +${reduced}` : `较昨日 -${reduced}`
})
const changeClass = computed(() => {
return homeSummary.value.exceeded_yesterday ? 'stat-change-up' : 'stat-change-down'
})
const timerDisplay = computed(() => {
if (timerBaseSeconds.value < 0) {
return '--:--:--'
}
const totalSeconds = timerBaseSeconds.value + timerSeconds.value
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
})
const timerGradient = computed(() => {
return 'conic-gradient(#10B981 0deg 270deg, #E5E7EB 270deg 360deg)'
})
const nextSmokeTimeText = computed(() => {
const timer = homeTimer.value
if (!timer) return ''
if (timer.next_suggested_clock) return timer.next_suggested_clock
if (timer.next_suggested_at) {
const date = new Date(timer.next_suggested_at)
if (!isNaN(date.getTime())) {
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
}
return ''
})
const dialogTitle = computed(() => {
return dialogType.value === 'smoke' ? '记录抽烟' : '想抽忍住了'
})
function startTimer() {
stopTimer()
if (timerBaseSeconds.value < 0) {
return
}
timerInterval = setInterval(() => {
timerSeconds.value++
}, 1000)
}
function stopTimer() {
if (timerInterval) {
clearInterval(timerInterval)
timerInterval = null
}
}
function openSmokeDialog() {
dialogType.value = 'smoke'
showDialog.value = true
}
function openResistedDialog() {
dialogType.value = 'resisted'
showDialog.value = true
}
function applyHomeData(data) {
homeData.value = data
const seconds = data?.timer?.seconds_since_last
timerBaseSeconds.value = typeof seconds === 'number' ? seconds : -1
timerSeconds.value = 0
startTimer()
}
async function fetchHomeData() {
const res = await api.getHome()
const data = res.data || {}
applyHomeData(data)
return data
}
async function handleSubmit(submitData) {
try {
if (dialogType.value === 'smoke') {
await api.createLog(submitData)
timerBaseSeconds.value = 0
timerSeconds.value = 0
startTimer()
uni.showToast({ title: '记录成功', icon: 'success' })
try {
await fetchHomeData()
} catch (e) {
console.error('refreshHomeData error:', e)
}
} else {
await api.createResistedLog({
smoke_time: submitData.smoke_time,
smoke_at: submitData.smoke_at,
remark: submitData.remark,
level: submitData.level,
num: submitData.num
})
uni.showToast({ title: '太棒了!', icon: 'success' })
try {
await fetchHomeData()
} catch (e) {
console.error('refreshHomeData error:', e)
}
}
} catch (e) {
console.error('handleSubmit error:', e)
uni.showToast({ title: dialogType.value === 'smoke' ? '记录成功' : '太棒了!', icon: 'success' })
}
}
async function initPage() {
const systemInfo = uni.getSystemInfoSync()
const statusBarH = systemInfo.statusBarHeight || 0
try {
const menuBtn = uni.getMenuButtonBoundingClientRect()
navBarHeight.value = menuBtn.bottom + (menuBtn.top - statusBarH)
} catch (e) {
navBarHeight.value = statusBarH + 44
}
loading.value = true
try {
await waitForLogin()
const home = await fetchHomeData()
const profileData = home.profile || {}
const profile = profileData.profile
const isCompleted = profileData.is_completed ||
(profile && profile.onboarding_completed_at) ||
(profile && profile.baseline_cigs_per_day > 0)
if (!profileData.exists || !isCompleted) {
uni.redirectTo({ url: '/pages/onboarding/index' })
return
}
profileStore.exists = !!profileData.exists
profileStore.isCompleted = !!isCompleted
profileStore.awakeMinutes = profileData.awake_minutes || 960
profileStore.baselineIntervalMinutes = profileData.baseline_interval_minutes || 60
if (profile) {
profileStore.profile = profile
}
} catch (e) {
console.error('initPage error:', e)
} finally {
loading.value = false
}
}
function closeAiTip() {
showAiTip.value = false
}
onMounted(() => {
initPage()
})
onUnmounted(() => {
stopTimer()
})
</script>
<style scoped>
.page {
min-height: 100vh;
background: linear-gradient(to bottom, #D1FAE5 0%, #F0FDF4 45%, #FFFFFF 100%);
box-sizing: border-box;
}
.nav-placeholder {
background: linear-gradient(to bottom, #D1FAE5, #E9FDF2);
}
.dashboard {
padding: 32rpx;
padding-bottom: 160rpx;
}
.skeleton {
display: flex;
flex-direction: column;
gap: 32rpx;
padding: 32rpx;
}
.skeleton-header,
.skeleton-tip,
.skeleton-timer,
.skeleton-cards,
.skeleton-buttons {
background: linear-gradient(90deg, #D1FAE5 25%, #ECFDF5 50%, #D1FAE5 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 24rpx;
}
.skeleton-header { height: 80rpx; }
.skeleton-tip { height: 120rpx; }
.skeleton-timer { height: 400rpx; border-radius: 50%; margin: 32rpx auto; width: 400rpx; }
.skeleton-cards { height: 200rpx; }
.skeleton-buttons { height: 96rpx; }
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32rpx;
}
.user-info {
display: flex;
align-items: center;
gap: 24rpx;
}
.avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background-color: #ECFDF5;
border: 4rpx solid #A7F3D0;
}
.greeting-text {
font-size: 36rpx;
font-weight: 600;
color: #111827;
display: block;
}
.greeting-sub {
font-size: 24rpx;
color: #6B7280;
display: block;
margin-top: 4rpx;
}
.ai-tip {
display: flex;
align-items: flex-start;
gap: 24rpx;
background-color: #FFFFFF;
border: 2rpx solid #D9FBE7;
border-radius: 24rpx;
padding: 28rpx;
margin-bottom: 24rpx;
box-shadow: 0 10rpx 20rpx rgba(16, 185, 129, 0.1);
}
.ai-tip-icon {
font-size: 36rpx;
background: linear-gradient(135deg, #34D399 0%, #10B981 100%);
width: 64rpx;
height: 64rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 16rpx rgba(16, 185, 129, 0.2);
}
.ai-tip-content { flex: 1; }
.ai-tip-title {
font-size: 28rpx;
font-weight: 600;
color: #111827;
display: block;
margin-bottom: 8rpx;
}
.ai-tip-desc {
font-size: 24rpx;
color: #6B7280;
display: block;
line-height: 1.5;
}
.ai-tip-close {
font-size: 36rpx;
color: #9CA3AF;
padding: 8rpx;
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
}
.timer-section {
display: flex;
justify-content: center;
padding: 24rpx 0 32rpx;
}
.timer-ring {
position: relative;
width: 400rpx;
height: 400rpx;
}
.timer-ring-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 50%;
border: 16rpx solid #D1FAE5;
box-sizing: border-box;
background-color: #FFFFFF;
box-shadow: 0 12rpx 32rpx rgba(16, 185, 129, 0.1);
}
.timer-ring-progress {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 50%;
mask: radial-gradient(transparent 55%, #000 56%);
-webkit-mask: radial-gradient(transparent 55%, #000 56%);
}
.timer-content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
width: 100%;
}
.timer-label {
font-size: 24rpx;
color: #64748B;
display: block;
margin-bottom: 16rpx;
}
.timer-value {
font-size: 64rpx;
font-weight: 700;
color: #111827;
display: block;
font-family: 'SF Mono', 'Monaco', 'Menlo', monospace;
letter-spacing: 2rpx;
}
.next-time {
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 20rpx;
background-color: #ECFDF5;
padding: 12rpx 24rpx;
border-radius: 32rpx;
border: 2rpx solid #D1FAE5;
}
.next-time-text {
font-size: 24rpx;
color: #059669;
}
.stats-row {
display: flex;
gap: 24rpx;
margin-bottom: 32rpx;
}
.stat-card {
flex: 1;
background-color: #FFFFFF;
border-radius: 24rpx;
padding: 24rpx;
border: 2rpx solid #ECFDF3;
box-shadow: 0 10rpx 22rpx rgba(16, 185, 129, 0.08);
}
.stat-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
margin-bottom: 16rpx;
}
.stat-dot-red { background-color: #EF4444; }
.stat-dot-green { background-color: #10B981; }
.stat-label {
font-size: 24rpx;
color: #64748B;
display: block;
margin-bottom: 8rpx;
}
.stat-value-row {
display: flex;
align-items: baseline;
gap: 8rpx;
margin-bottom: 16rpx;
flex-wrap: wrap;
}
.stat-value {
font-size: 48rpx;
font-weight: 700;
color: #111827;
}
.stat-target,
.stat-unit {
font-size: 24rpx;
color: #94A3B8;
}
.stat-change {
font-size: 20rpx;
padding: 4rpx 12rpx;
border-radius: 999rpx;
margin-left: auto;
font-weight: 600;
}
.stat-change-down {
background-color: #E8FFF1;
color: #16A34A;
border: 2rpx solid #BBF7D0;
}
.stat-change-up {
background-color: #FEF2F2;
color: #EF4444;
border: 2rpx solid #FECACA;
}
.stat-progress {
height: 8rpx;
background-color: #F1F5F9;
border-radius: 999rpx;
overflow: hidden;
}
.stat-progress-bar {
height: 100%;
border-radius: 999rpx;
transition: width 0.3s ease;
}
.stat-progress-red { background-color: #EF4444; }
.stat-progress-green { background-color: #10B981; }
.action-buttons {
display: flex;
gap: 24rpx;
}
.action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
height: 96rpx;
border-radius: 48rpx;
font-size: 30rpx;
font-weight: 500;
}
.action-btn-primary {
background-color: #10B981;
color: #FFFFFF;
box-shadow: 0 12rpx 28rpx rgba(16, 185, 129, 0.25);
}
.action-btn-secondary {
background-color: #FFFFFF;
color: #111827;
border: 2rpx solid #ECFDF3;
box-shadow: 0 8rpx 20rpx rgba(16, 185, 129, 0.08);
}
.action-icon {
font-size: 32rpx;
}
</style>