Files
smt/pages/index/index.vue
T
你çšnepiedg 31e504a997 feat: 添加模式选择功能与页面更新
- 在 onboarding 页面中新增使用模式选择功能,用户可选择“戒烟打卡”或“记录抽烟”模式
- 更新个人资料页面以显示当前模式并允许用户切换模式
- 在 pages.json 中注册新的模式选择页面
- 优化首页和其他相关页面以适应新模式功能
2026-03-18 00:06:01 +08:00

764 lines
19 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-card skeleton-card-lg"></view>
<view class="skeleton-card skeleton-card-md"></view>
<view class="skeleton-grid">
<view class="skeleton-card"></view>
<view class="skeleton-card"></view>
</view>
<view class="skeleton-card skeleton-card-sm"></view>
</view>
<view v-else class="dashboard">
<view class="header-card">
<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 class="mode-chip" :class="isQuitMode ? 'mode-chip-quit' : 'mode-chip-record'">
{{ isQuitMode ? '戒烟模式' : '记录模式' }}
</view>
</view>
<view v-if="isQuitMode">
<view class="hero-card hero-card-quit">
<text class="hero-label">已坚持</text>
<view class="hero-value-row">
<text class="hero-value">{{ quitDays }}</text>
<text class="hero-unit"></text>
</view>
<text class="hero-sub">{{ quitSubtitle }}</text>
</view>
<view class="primary-action" :class="{ 'primary-action-done': todayChecked }" @tap="handleQuitCheckin">
<text class="primary-action-icon">{{ todayChecked ? '✓' : '🔥' }}</text>
<text class="primary-action-title">{{ todayChecked ? '今日已打卡' : '今天没抽,去打卡' }}</text>
<text class="primary-action-desc">{{ todayChecked ? `已于 ${todayCheckinTime} 记录` : '把今天记成无烟的一天' }}</text>
</view>
<view class="stats-grid">
<view class="stat-card">
<text class="stat-label">已省下</text>
<text class="stat-value">¥{{ savedMoney }}</text>
<text class="stat-desc">按日均 {{ baselineCigsPerDay }} 支估算</text>
</view>
<view class="stat-card">
<text class="stat-label">恢复进度</text>
<text class="stat-value">{{ healthProgress }}%</text>
<text class="stat-desc">{{ healthTip }}</text>
</view>
</view>
<view class="note-card">
<text class="note-title">今日提醒</text>
<text class="note-text">{{ quitEncouragement }}</text>
</view>
</view>
<view v-else>
<view class="hero-card hero-card-record">
<text class="hero-label">距上次抽烟</text>
<text class="hero-value hero-value-time">{{ timerDisplay }}</text>
<text class="hero-sub">{{ nextSmokeTimeText ? `下次建议:${nextSmokeTimeText}` : '先把今天的抽烟情况记下来' }}</text>
</view>
<view class="stats-grid">
<view class="stat-card">
<text class="stat-label">今日已抽</text>
<text class="stat-value">{{ todayCount }}<text class="stat-unit"></text></text>
<text class="stat-desc">目标 {{ dailyTarget }} {{ changeText }}</text>
</view>
<view class="stat-card">
<text class="stat-label">今天忍住</text>
<text class="stat-value">{{ resistedCount }}<text class="stat-unit"></text></text>
<text class="stat-desc">每次忍住都在拉开间隔</text>
</view>
</view>
<view class="action-row">
<view class="action-btn action-btn-record" @tap="openSmokeDialog">
<text class="action-icon">🚬</text>
<text class="action-title">记录抽烟</text>
<text class="action-desc">补记这一根</text>
</view>
<view class="action-btn action-btn-resist" @tap="openResistedDialog">
<text class="action-icon">💪</text>
<text class="action-title">想抽忍住了</text>
<text class="action-desc">记一次成功抵抗</text>
</view>
</view>
</view>
</view>
<smoke-record-dialog
v-if="!isQuitMode"
v-model:show="showDialog"
:type="dialogType"
@submit="handleSubmit"
/>
</view>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { onShareAppMessage, onShow } from '@dcloudio/uni-app'
import * as api from '@/api'
import { useLogin } from '@/hooks/useLogin'
import { useProfileStore } from '@/stores/profile'
import { useUserStore } from '@/stores/user'
import { storage, QUIT_CHECKIN_KEY } from '@/utils/storage'
const profileStore = useProfileStore()
const userStore = useUserStore()
const { waitForLogin } = useLogin()
const loading = ref(true)
const navBarHeight = ref(0)
const showDialog = ref(false)
const dialogType = ref('smoke')
const homeData = ref(null)
const pageReady = ref(false)
const quitState = ref(defaultQuitState())
let timerInterval = null
const timerBaseSeconds = ref(-1)
const timerSeconds = ref(0)
const isQuitMode = computed(() => userStore.mode === 'quit')
const homeSummary = computed(() => homeData.value?.summary || {})
const homeTimer = computed(() => homeData.value?.timer || {})
const userName = computed(() => homeData.value?.greeting?.nickname || userStore.user?.nickname || '戒烟用户')
const userAvatar = computed(() => homeData.value?.greeting?.avatar_url || userStore.user?.avatar_url || 'https://linghu-wmr.oss-cn-beijing.aliyuncs.com/smt/avatar.png')
const greetingTitle = computed(() => {
const hour = new Date().getHours()
let greeting = '晚上好'
if (hour < 6) greeting = '凌晨好'
else if (hour < 12) greeting = '早上好'
else if (hour < 14) greeting = '中午好'
else if (hour < 18) greeting = '下午好'
return `${greeting}${userName.value}`
})
const greetingSubtitle = computed(() => {
if (isQuitMode.value) {
return todayChecked.value ? '今天已经记下来了,继续保持。' : '先把今天记成无烟的一天。'
}
return '记录越及时,后面的趋势越准。'
})
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 changeText = computed(() => {
const reduced = homeSummary.value.reduced_from_yesterday
if (reduced === undefined || reduced === null) return '较昨日暂无对比'
if (reduced === 0) return '较昨日持平'
return homeSummary.value.exceeded_yesterday ? `较昨日多 ${reduced}` : `较昨日少 ${reduced}`
})
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 nextSmokeTimeText = computed(() => {
const timer = homeTimer.value
if (!timer) return ''
if (timer.next_suggested_clock) return timer.next_suggested_clock
if (!timer.next_suggested_at) return ''
const date = new Date(timer.next_suggested_at)
if (Number.isNaN(date.getTime())) return ''
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
})
const baselineCigsPerDay = computed(() => profileStore.profile?.baseline_cigs_per_day || 10)
const packPriceYuan = computed(() => (profileStore.profile?.pack_price_cent || 2500) / 100)
const quitDays = computed(() => {
if (!quitState.value.lastCheckinDate) return 0
const gap = diffDays(quitState.value.lastCheckinDate, formatDate(new Date()))
if (gap > 1) return 0
return Number(quitState.value.streakDays || 0)
})
const todayChecked = computed(() => quitState.value.lastCheckinDate === formatDate(new Date()))
const todayCheckinTime = computed(() => formatClock(quitState.value.lastCheckinAt))
const savedMoney = computed(() => {
const total = (quitDays.value * baselineCigsPerDay.value / 20) * packPriceYuan.value
return Math.round(total)
})
const healthProgress = computed(() => {
if (quitDays.value >= 365) return 100
if (quitDays.value >= 180) return 86
if (quitDays.value >= 90) return 72
if (quitDays.value >= 30) return 58
if (quitDays.value >= 14) return 38
if (quitDays.value >= 7) return 18
if (quitDays.value >= 1) return 6
return 0
})
const healthTip = computed(() => {
if (quitDays.value >= 365) return '肺部功能已进入长期恢复阶段'
if (quitDays.value >= 180) return '血液循环和咳嗽症状通常会继续改善'
if (quitDays.value >= 30) return '味觉和嗅觉通常会逐步恢复'
if (quitDays.value >= 7) return '一周后,呼吸会比开始时更轻松一些'
if (quitDays.value >= 1) return '第一阶段最难,坚持住就有意义'
return '打卡从今天开始,先拿下第一天'
})
const quitSubtitle = computed(() => {
if (!quitState.value.lastCheckinDate) return '还没有开始记录,先拿下第一天。'
const gap = diffDays(quitState.value.lastCheckinDate, formatDate(new Date()))
if (gap > 1) return '连续记录已中断,今天重新开始。'
if (todayChecked.value) return '今天已经打卡,明天继续。'
return '别漏掉今天这次打卡。'
})
const quitEncouragement = computed(() => {
if (todayChecked.value) return '今天的无烟记录已经落下,尽量把第一支拖得更晚一些。'
if (quitDays.value === 0) return '先不要想很久,只把今天守住就够了。'
return `连续 ${quitDays.value} 天很难得,今天再补上一天。`
})
function defaultQuitState() {
return {
lastCheckinDate: '',
lastCheckinAt: '',
streakDays: 0
}
}
function formatDate(date) {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
function diffDays(fromDate, toDate) {
if (!fromDate || !toDate) return 0
const from = new Date(`${fromDate}T00:00:00`)
const to = new Date(`${toDate}T00:00:00`)
const diff = to.getTime() - from.getTime()
return Math.floor(diff / (24 * 60 * 60 * 1000))
}
function formatClock(value) {
if (!value) return '--:--'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '--:--'
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
function setupNavBar() {
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
}
}
function loadQuitState() {
quitState.value = {
...defaultQuitState(),
...(storage.get(QUIT_CHECKIN_KEY) || {})
}
}
function saveQuitState(nextState) {
quitState.value = {
...defaultQuitState(),
...nextState
}
storage.set(QUIT_CHECKIN_KEY, quitState.value)
}
function startTimer() {
stopTimer()
if (timerBaseSeconds.value < 0) return
timerInterval = setInterval(() => {
timerSeconds.value++
}, 1000)
}
function stopTimer() {
if (!timerInterval) return
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 fetchRecordHomeData() {
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' })
} 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' })
}
await fetchRecordHomeData()
} catch (e) {
console.error('handleSubmit error:', e)
uni.showToast({ title: '保存失败', icon: 'none' })
}
}
function handleQuitCheckin() {
if (todayChecked.value) {
uni.showToast({ title: '今天已经打过卡', icon: 'none' })
return
}
const today = formatDate(new Date())
const previousDate = quitState.value.lastCheckinDate
let streakDays = 1
if (previousDate) {
const gap = diffDays(previousDate, today)
if (gap === 1) {
streakDays = Number(quitState.value.streakDays || 0) + 1
} else if (gap === 0) {
streakDays = Number(quitState.value.streakDays || 0)
}
}
saveQuitState({
lastCheckinDate: today,
lastCheckinAt: new Date().toISOString(),
streakDays
})
uni.showToast({ title: '打卡成功', icon: 'success' })
}
async function ensureProfileReady() {
const profileData = await profileStore.fetchProfile()
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.navigateTo({ url: '/pages/onboarding/index' })
return false
}
return true
}
async function refreshCurrentMode() {
if (!userStore.mode) return
const profileReady = await ensureProfileReady()
if (!profileReady) return
if (isQuitMode.value) {
stopTimer()
loadQuitState()
return
}
await fetchRecordHomeData()
}
async function initPage() {
setupNavBar()
loading.value = true
try {
await waitForLogin()
await profileStore.fetchProfile()
if (!userStore.mode) {
uni.navigateTo({ url: '/pages/mode-select/index' })
return
}
await refreshCurrentMode()
} catch (e) {
console.error('initPage error:', e)
} finally {
pageReady.value = true
loading.value = false
}
}
onMounted(() => {
initPage()
})
onShow(async () => {
if (!pageReady.value) return
try {
await refreshCurrentMode()
} catch (e) {
console.error('home onShow error:', e)
}
})
onUnmounted(() => {
stopTimer()
})
onShareAppMessage(() => {
return {
title: isQuitMode.value ? '我在坚持戒烟打卡' : '我在记录自己的抽烟变化',
path: 'pages/index/index'
}
})
</script>
<style scoped>
.page {
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(16, 185, 129, 0.16), transparent 36%),
linear-gradient(180deg, #ecfdf5 0%, #f0fdf4 42%, #ffffff 100%);
box-sizing: border-box;
}
.nav-placeholder {
background: transparent;
}
.dashboard {
padding: 24rpx 24rpx 160rpx;
}
.header-card,
.hero-card,
.primary-action,
.stat-card,
.note-card,
.action-btn,
.skeleton-card {
background: rgba(255, 255, 255, 0.88);
border-radius: 28rpx;
border: 2rpx solid rgba(236, 253, 245, 0.9);
box-shadow: 0 16rpx 42rpx rgba(15, 23, 42, 0.08);
}
.header-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx;
margin-bottom: 24rpx;
}
.user-info {
display: flex;
align-items: center;
gap: 20rpx;
}
.avatar {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
border: 4rpx solid #a7f3d0;
background: #ecfdf5;
}
.greeting-text {
display: block;
font-size: 34rpx;
font-weight: 700;
color: #111827;
}
.greeting-sub {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
line-height: 1.5;
color: #6b7280;
}
.mode-chip {
padding: 10rpx 18rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 600;
}
.mode-chip-quit {
background: rgba(16, 185, 129, 0.12);
color: #047857;
}
.mode-chip-record {
background: rgba(248, 113, 113, 0.12);
color: #b91c1c;
}
.hero-card {
padding: 34rpx 30rpx;
margin-bottom: 24rpx;
}
.hero-card-quit {
background: linear-gradient(135deg, rgba(220, 252, 231, 0.92), rgba(255, 255, 255, 0.92));
}
.hero-card-record {
background: linear-gradient(135deg, rgba(254, 242, 242, 0.92), rgba(255, 255, 255, 0.92));
}
.hero-label {
display: block;
font-size: 24rpx;
color: #6b7280;
letter-spacing: 2rpx;
}
.hero-value-row {
display: flex;
align-items: baseline;
gap: 12rpx;
margin-top: 18rpx;
}
.hero-value,
.hero-value-time {
display: block;
margin-top: 18rpx;
font-size: 84rpx;
line-height: 1;
font-weight: 800;
color: #111827;
}
.hero-unit {
font-size: 28rpx;
color: #4b5563;
}
.hero-sub {
display: block;
margin-top: 18rpx;
font-size: 26rpx;
line-height: 1.6;
color: #4b5563;
}
.primary-action {
display: flex;
flex-direction: column;
align-items: center;
padding: 38rpx 30rpx;
margin-bottom: 24rpx;
background: linear-gradient(135deg, #10b981 0%, #34d399 100%);
color: #ffffff;
}
.primary-action-done {
background: linear-gradient(135deg, #059669 0%, #10b981 100%);
}
.primary-action-icon {
font-size: 58rpx;
font-weight: 700;
}
.primary-action-title {
display: block;
margin-top: 12rpx;
font-size: 34rpx;
font-weight: 700;
}
.primary-action-desc {
display: block;
margin-top: 12rpx;
font-size: 24rpx;
opacity: 0.88;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20rpx;
margin-bottom: 24rpx;
}
.stat-card {
padding: 28rpx 24rpx;
}
.stat-label {
display: block;
font-size: 24rpx;
color: #6b7280;
}
.stat-value {
display: block;
margin-top: 18rpx;
font-size: 48rpx;
line-height: 1.1;
font-weight: 800;
color: #111827;
}
.stat-unit {
font-size: 26rpx;
font-weight: 600;
color: #6b7280;
}
.stat-desc {
display: block;
margin-top: 16rpx;
font-size: 23rpx;
line-height: 1.5;
color: #6b7280;
}
.note-card {
padding: 28rpx;
}
.note-title {
display: block;
font-size: 24rpx;
color: #047857;
font-weight: 700;
}
.note-text {
display: block;
margin-top: 14rpx;
font-size: 26rpx;
line-height: 1.7;
color: #374151;
}
.action-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20rpx;
}
.action-btn {
padding: 30rpx 24rpx;
}
.action-btn-record {
background: linear-gradient(135deg, rgba(254, 226, 226, 0.92), rgba(255, 255, 255, 0.92));
}
.action-btn-resist {
background: linear-gradient(135deg, rgba(220, 252, 231, 0.92), rgba(255, 255, 255, 0.92));
}
.action-icon {
font-size: 40rpx;
}
.action-title {
display: block;
margin-top: 14rpx;
font-size: 30rpx;
font-weight: 700;
color: #111827;
}
.action-desc {
display: block;
margin-top: 10rpx;
font-size: 23rpx;
color: #6b7280;
}
.skeleton {
padding: 24rpx;
}
.skeleton-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20rpx;
margin-bottom: 20rpx;
}
.skeleton-card {
height: 180rpx;
background: linear-gradient(90deg, #d1fae5 25%, #ecfdf5 50%, #d1fae5 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
margin-bottom: 20rpx;
}
.skeleton-card-lg {
height: 140rpx;
}
.skeleton-card-md {
height: 260rpx;
}
.skeleton-card-sm {
height: 120rpx;
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
</style>