Files
smt/pages/index/index.vue
T

608 lines
13 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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="status-bar" :style="{ height: statusBarHeight + '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">{{ greeting }}{{ userName }}</text>
<text class="greeting-sub">保持连胜纪录🔥</text>
</view>
</view>
<view class="settings-btn" @tap="goSettings">
<text></text>
</view>
</view>
<view v-if="showAiTip" class="ai-tip">
<view class="ai-tip-icon">🤖</view>
<view class="ai-tip-content">
<text class="ai-tip-title">发现规律</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 stat-change-down">{{ 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 { useDashboardStore } from '@/stores/dashboard'
import { useProfileStore } from '@/stores/profile'
import { useUserStore } from '@/stores/user'
import { useLogin } from '@/hooks/useLogin'
import * as api from '@/api'
const dashboardStore = useDashboardStore()
const profileStore = useProfileStore()
const userStore = useUserStore()
const { waitForLogin } = useLogin()
const loading = ref(true)
const showAiTip = ref(true)
const aiTipText = ref('你的烟瘾通常在下午2点达到高峰。我们为你准备了一个快速呼吸练习。')
const resistedCount = ref(5)
const statusBarHeight = ref(0)
const showDialog = ref(false)
const dialogType = ref('smoke') // 'smoke' 或 'resisted'
let timerInterval = null
const timerSeconds = ref(0)
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 userStore.user?.nickname || 'Alex'
})
const userAvatar = computed(() => {
return userStore.user?.avatar_url || '/static/images/default-avatar.png'
})
const todayCount = computed(() => dashboardStore.todayCount || 3)
const dailyTarget = computed(() => profileStore.profile?.baseline_cigs_per_day || 10)
const progressWidth = computed(() => {
const percent = Math.min((todayCount.value / dailyTarget.value) * 100, 100)
return `${percent}%`
})
const resistedProgressWidth = computed(() => {
const percent = Math.min((resistedCount.value / 10) * 100, 100)
return `${percent}%`
})
const changeText = computed(() => {
return '较昨日 -2'
})
const timerDisplay = computed(() => {
const totalSeconds = (dashboardStore.minutesSinceLast || 165) * 60 + 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(() => {
if (dashboardStore.nextSmokeTime?.suggested_at) {
const date = new Date(dashboardStore.nextSmokeTime.suggested_at)
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
return '16:30'
})
const dialogTitle = computed(() => {
return dialogType.value === 'smoke' ? '记录抽烟' : '想抽忍住了'
})
function startTimer() {
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
}
async function handleSubmit(submitData) {
try {
await api.createLog(submitData)
if (dialogType.value === 'smoke') {
dashboardStore.incrementTodayCount()
dashboardStore.resetTimer()
timerSeconds.value = 0
uni.showToast({ title: '记录成功', icon: 'success' })
} else {
resistedCount.value++
uni.showToast({ title: '太棒了!', icon: 'success' })
}
} catch (e) {
console.error('handleSubmit error:', e)
uni.showToast({ title: dialogType.value === 'smoke' ? '记录成功' : '太棒了!', icon: 'success' })
}
}
async function initPage() {
// 获取状态栏高度
const systemInfo = uni.getSystemInfoSync()
statusBarHeight.value = systemInfo.statusBarHeight || 0
loading.value = true
try {
await waitForLogin()
const [profileRes, dashboardRes, nextTimeRes] = await Promise.all([
api.getProfile(),
api.getDashboard(),
api.getNextSmokeTime()
])
const profile = profileRes.data.profile
const isCompleted = profileRes.data.is_completed ||
(profile && profile.onboarding_completed_at) ||
(profile && profile.baseline_cigs_per_day > 0)
if (!profileRes.data.exists || !isCompleted) {
uni.redirectTo({ url: '/pages/onboarding/index' })
return
}
profileStore.exists = profileRes.data.exists
profileStore.isCompleted = profileRes.data.is_completed
profileStore.profile = profileRes.data.profile
dashboardStore.setDashboard(dashboardRes.data)
dashboardStore.setNextSmokeTime(nextTimeRes.data)
startTimer()
} catch (e) {
console.error('initPage error:', e)
startTimer()
} finally {
loading.value = false
}
}
function goSettings() {
uni.switchTab({ url: '/pages/profile/index' })
}
function closeAiTip() {
showAiTip.value = false
}
onMounted(() => {
initPage()
})
onUnmounted(() => {
stopTimer()
})
</script>
<style scoped>
.page {
min-height: 100vh;
background: linear-gradient(to bottom, #D1FAE5 0%, #F0FDF4 50%, #FFFFFF 100%);
box-sizing: border-box;
}
.status-bar {
background: linear-gradient(to bottom, #D1FAE5, #D1FAE5);
}
.dashboard {
padding: 32rpx;
}
.skeleton {
display: flex;
flex-direction: column;
gap: 32rpx;
padding: 32rpx;
}
.skeleton-header,
.skeleton-tip,
.skeleton-timer,
.skeleton-cards,
.skeleton-buttons {
background: linear-gradient(90deg, #E5E7EB 25%, #F3F4F6 50%, #E5E7EB 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: #F3F4F6;
border: 2rpx solid #E5E7EB;
}
.greeting-text {
font-size: 36rpx;
font-weight: 600;
color: #1F2937;
display: block;
}
.greeting-sub {
font-size: 24rpx;
color: #6B7280;
display: block;
margin-top: 4rpx;
}
.settings-btn {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 40rpx;
}
.ai-tip {
display: flex;
align-items: flex-start;
gap: 24rpx;
background-color: #FFFFFF;
border: 2rpx solid #10B981;
border-radius: 24rpx;
padding: 32rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 12rpx rgba(16, 185, 129, 0.1);
}
.ai-tip-icon {
font-size: 36rpx;
background-color: #10B981;
width: 64rpx;
height: 64rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.ai-tip-content { flex: 1; }
.ai-tip-title {
font-size: 28rpx;
font-weight: 600;
color: #1F2937;
display: block;
margin-bottom: 8rpx;
}
.ai-tip-desc {
font-size: 24rpx;
color: #6B7280;
display: block;
line-height: 1.5;
}
.ai-tip-close {
font-size: 40rpx;
color: #9CA3AF;
padding: 8rpx;
}
.timer-section {
display: flex;
justify-content: center;
padding: 32rpx 0;
}
.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 #E5E7EB;
box-sizing: border-box;
background-color: #FFFFFF;
}
.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: #6B7280;
display: block;
margin-bottom: 16rpx;
}
.timer-value {
font-size: 64rpx;
font-weight: 700;
color: #1F2937;
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: #D1FAE5;
padding: 12rpx 24rpx;
border-radius: 32rpx;
}
.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;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.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: #6B7280;
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: #1F2937;
}
.stat-target,
.stat-unit {
font-size: 24rpx;
color: #9CA3AF;
}
.stat-change {
font-size: 20rpx;
padding: 4rpx 12rpx;
border-radius: 8rpx;
margin-left: auto;
}
.stat-change-down {
background-color: rgba(16, 185, 129, 0.1);
color: #10B981;
}
.stat-change-up {
background-color: rgba(239, 68, 68, 0.1);
color: #EF4444;
}
.stat-progress {
height: 8rpx;
background-color: #F3F4F6;
border-radius: 4rpx;
overflow: hidden;
}
.stat-progress-bar {
height: 100%;
border-radius: 4rpx;
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;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
}
.action-btn-primary {
background-color: #10B981;
color: #FFFFFF;
}
.action-btn-secondary {
background-color: #FFFFFF;
color: #1F2937;
border: 2rpx solid #E5E7EB;
}
.action-icon {
font-size: 32rpx;
}
</style>