Files
smt/pages/index/index.vue
T
nepiedg c883ae7b17 init
2026-01-25 11:45:16 +08:00

552 lines
11 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 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 class="iconfont"></text>
</view>
</view>
<view v-if="showAiTip" class="ai-tip card">
<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">
<canvas canvas-id="timerCanvas" class="timer-canvas"></canvas>
<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-icon"></text>
<text class="next-time-text">下次建议: {{ nextSmokeTimeText }}</text>
</view>
</view>
</view>
</view>
<view class="stats-row">
<view class="stat-card 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" :style="{ width: progressWidth }"></view>
</view>
</view>
<view class="stat-card 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 stat-progress-green">
<view class="stat-progress-bar" :style="{ width: resistedProgressWidth }"></view>
</view>
</view>
</view>
<view class="action-buttons">
<view class="btn btn-secondary action-btn" @tap="recordSmoke">
<text class="action-icon">🚬</text>
<text>记录抽烟</text>
</view>
<view class="btn btn-primary action-btn" @tap="recordResisted">
<text class="action-icon">💪</text>
<text>想抽忍住了</text>
</view>
</view>
</view>
</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 * as api from '@/api'
const dashboardStore = useDashboardStore()
const profileStore = useProfileStore()
const userStore = useUserStore()
const loading = ref(true)
const showAiTip = ref(true)
const aiTipText = ref('你的烟瘾通常在下午2点达到高峰。我们为你准备了一个快速呼吸练习。')
const resistedCount = ref(0)
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 || '用户'
})
const userAvatar = computed(() => {
return userStore.user?.avatar_url || '/static/icons/default-avatar.png'
})
const todayCount = computed(() => dashboardStore.todayCount)
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 changeClass = computed(() => {
return 'stat-change-down'
})
const timerDisplay = computed(() => {
const totalSeconds = dashboardStore.minutesSinceLast * 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 nextSmokeTimeText = computed(() => {
if (!dashboardStore.nextSmokeTime?.suggested_at) return ''
const date = new Date(dashboardStore.nextSmokeTime.suggested_at)
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
})
function startTimer() {
timerInterval = setInterval(() => {
timerSeconds.value++
}, 1000)
}
function stopTimer() {
if (timerInterval) {
clearInterval(timerInterval)
timerInterval = null
}
}
async function initPage() {
loading.value = true
try {
const [profileRes, dashboardRes, nextTimeRes] = await Promise.all([
api.getProfile(),
api.getDashboard(),
api.getNextSmokeTime()
])
if (!profileRes.data.exists || !profileRes.data.is_completed) {
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)
} finally {
loading.value = false
}
}
function goSettings() {
uni.navigateTo({ url: '/pages/profile/index' })
}
function closeAiTip() {
showAiTip.value = false
}
async function recordSmoke() {
try {
await api.createLog({
smoke_time: new Date().toISOString().split('T')[0],
smoke_at: new Date().toISOString().replace('T', ' ').substring(0, 19),
num: 1,
level: 3
})
dashboardStore.incrementTodayCount()
dashboardStore.resetTimer()
timerSeconds.value = 0
uni.showToast({ title: '记录成功', icon: 'success' })
} catch (e) {
console.error('recordSmoke error:', e)
}
}
async function recordResisted() {
try {
await api.createResistedLog({
smoke_time: new Date().toISOString().split('T')[0],
smoke_at: new Date().toISOString().replace('T', ' ').substring(0, 19),
remark: '想抽但忍住了'
})
resistedCount.value++
uni.showToast({ title: '太棒了!', icon: 'success' })
} catch (e) {
console.error('recordResisted error:', e)
}
}
onMounted(() => {
initPage()
})
onUnmounted(() => {
stopTimer()
})
</script>
<style scoped>
.page {
min-height: 100vh;
background-color: var(--color-bg);
padding: 32rpx;
padding-top: 88rpx;
box-sizing: border-box;
}
.skeleton {
display: flex;
flex-direction: column;
gap: 32rpx;
}
.skeleton-header,
.skeleton-tip,
.skeleton-timer,
.skeleton-cards,
.skeleton-buttons {
background: linear-gradient(90deg, var(--color-bg-card) 25%, var(--color-bg-card-light) 50%, var(--color-bg-card) 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: var(--color-bg-card);
}
.greeting-text {
font-size: 36rpx;
font-weight: 600;
display: block;
}
.greeting-sub {
font-size: 24rpx;
color: var(--color-text-secondary);
display: block;
}
.settings-btn {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 40rpx;
color: var(--color-text-secondary);
}
.ai-tip {
display: flex;
align-items: flex-start;
gap: 24rpx;
background-color: rgba(74, 222, 128, 0.1);
border: 2rpx solid rgba(74, 222, 128, 0.3);
}
.ai-tip-icon {
font-size: 48rpx;
background-color: var(--color-primary);
width: 72rpx;
height: 72rpx;
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;
display: block;
margin-bottom: 8rpx;
}
.ai-tip-desc {
font-size: 24rpx;
color: var(--color-text-secondary);
display: block;
}
.ai-tip-close {
font-size: 40rpx;
color: var(--color-text-muted);
padding: 8rpx;
}
.timer-section {
display: flex;
justify-content: center;
padding: 48rpx 0;
}
.timer-ring {
position: relative;
width: 400rpx;
height: 400rpx;
}
.timer-canvas {
width: 400rpx;
height: 400rpx;
}
.timer-content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
}
.timer-label {
font-size: 24rpx;
color: var(--color-text-secondary);
display: block;
margin-bottom: 16rpx;
}
.timer-value {
font-size: 72rpx;
font-weight: 700;
display: block;
font-family: 'SF Mono', 'Monaco', monospace;
}
.next-time {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
margin-top: 24rpx;
background-color: var(--color-bg-card);
padding: 12rpx 24rpx;
border-radius: 32rpx;
}
.next-time-icon {
font-size: 24rpx;
}
.next-time-text {
font-size: 24rpx;
color: var(--color-primary);
}
.stats-row {
display: flex;
gap: 24rpx;
margin-bottom: 32rpx;
}
.stat-card {
flex: 1;
padding: 24rpx;
}
.stat-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
margin-bottom: 16rpx;
}
.stat-dot-red {
background-color: var(--color-danger);
}
.stat-dot-green {
background-color: var(--color-success);
}
.stat-label {
font-size: 24rpx;
color: var(--color-text-secondary);
display: block;
margin-bottom: 8rpx;
}
.stat-value-row {
display: flex;
align-items: baseline;
gap: 8rpx;
margin-bottom: 16rpx;
}
.stat-value {
font-size: 56rpx;
font-weight: 700;
}
.stat-target,
.stat-unit {
font-size: 24rpx;
color: var(--color-text-secondary);
}
.stat-change {
font-size: 22rpx;
padding: 4rpx 12rpx;
border-radius: 8rpx;
margin-left: auto;
}
.stat-change-down {
background-color: rgba(74, 222, 128, 0.2);
color: var(--color-success);
}
.stat-change-up {
background-color: rgba(239, 68, 68, 0.2);
color: var(--color-danger);
}
.stat-progress {
height: 8rpx;
background-color: var(--color-bg);
border-radius: 4rpx;
overflow: hidden;
}
.stat-progress-bar {
height: 100%;
background-color: var(--color-danger);
border-radius: 4rpx;
transition: width 0.3s ease;
}
.stat-progress-green .stat-progress-bar {
background-color: var(--color-success);
}
.action-buttons {
display: flex;
gap: 24rpx;
}
.action-btn {
flex: 1;
gap: 12rpx;
}
.action-icon {
font-size: 32rpx;
}
</style>