init
This commit is contained in:
@@ -0,0 +1,551 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user