Files
smt/src/pages/index/index.vue
T
2026-04-24 20:24:48 +08:00

2302 lines
61 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" :class="{ 'page-record': !isQuitMode }">
<!-- 背景装饰 -->
<view v-if="isQuitMode" class="bg-gradient"></view>
<view v-if="isQuitMode" class="bg-pattern"></view>
<view class="nav-placeholder" :style="{ height: navBarHeight + 'px' }"></view>
<!-- 骨架屏 -->
<view v-if="loading" class="skeleton-wrap">
<view class="sk-circle"></view>
<view class="sk-row"></view>
<view class="sk-row sk-row-short"></view>
<view class="sk-card"></view>
</view>
<view v-else class="content">
<!-- 戒烟模式 -->
<view v-if="isQuitMode" class="quit-home">
<view class="quit-hero-card" :style="hpVisualStyle">
<view class="quit-hero-top">
<view class="quit-hero-copy">
<text class="quit-hero-eyebrow">无烟旅程</text>
<text class="quit-hero-title">{{ quitEncouragement }}</text>
</view>
<text class="quit-hero-chip">{{ todayChecked ? '今日已完成' : '等待打卡' }}</text>
</view>
<view class="quit-overview">
<view class="quit-hp-panel">
<view class="quit-hp-ring" :style="hpGaugeStyle">
<view class="quit-hp-ring-inner">
<text class="quit-hp-ring-kicker">肺部 HP</text>
<text class="quit-hp-ring-value">{{ hpValue }}</text>
<text class="quit-hp-ring-label">{{ hpState.label }}</text>
</view>
</view>
<view class="quit-hp-copy">
<text class="quit-hp-title">{{ hpState.title }}</text>
<text class="quit-hp-desc">{{ hpState.description }}</text>
<view class="quit-hp-pill-row">
<view class="quit-hp-pill">
<text class="quit-hp-pill-label">今日状态</text>
<text class="quit-hp-pill-value">{{ hpChangeText }}</text>
</view>
<view class="quit-hp-pill quit-hp-pill-strong">
<text class="quit-hp-pill-label">下一节点</text>
<text class="quit-hp-pill-value">{{ nextHealthMilestoneText }}</text>
</view>
</view>
</view>
</view>
<view class="quit-metrics-grid">
<view class="quit-metric-card">
<text class="quit-metric-kicker">连续无烟</text>
<text class="quit-metric-value">{{ quitDays }} </text>
</view>
<view class="quit-metric-card">
<text class="quit-metric-kicker">已省下</text>
<text class="quit-metric-value">¥{{ savedMoney }}</text>
</view>
<view class="quit-metric-card">
<text class="quit-metric-kicker">少抽</text>
<text class="quit-metric-value">{{ avoidedCigs }} </text>
</view>
<view class="quit-metric-card quit-metric-card-wide">
<text class="quit-metric-kicker">延长生命</text>
<text class="quit-metric-value">{{ lifeSaved }} 小时</text>
</view>
</view>
</view>
<view v-if="achievementData" class="quit-ach-inline">
<view class="quit-ach-left">
<text class="quit-ach-icon">{{ achievementData.theme_icon }}</text>
<view class="quit-ach-info">
<text class="quit-ach-rank">{{ achievementData.current?.name || '--' }}</text>
<text class="quit-ach-theme">{{ achievementData.theme_name }}</text>
</view>
</view>
<view class="quit-ach-right">
<view v-if="achievementData.next" class="quit-ach-progress-area">
<view class="quit-ach-bar">
<view class="quit-ach-fill" :style="{ width: (achievementData.progress * 100) + '%' }"></view>
</view>
<text class="quit-ach-hint">{{ achievementData.next.name }}还需 {{ achievementData.next.required_days - achievementData.days }} </text>
</view>
<text v-else class="quit-ach-max">已达最高等级</text>
</view>
</view>
</view>
<view
class="quit-checkin-card"
:class="{ 'quit-checkin-card-done': todayChecked }"
@tap="handleQuitCheckin"
>
<view class="quit-checkin-main">
<view class="quit-checkin-icon" :class="{ 'quit-checkin-icon-done': todayChecked }">
<text v-if="todayChecked"></text>
<text v-else>+</text>
</view>
<view class="quit-checkin-copy">
<text class="quit-checkin-title">{{ todayChecked ? '今日已打卡' : '今天没抽烟' }}</text>
<text class="quit-checkin-desc">{{ todayChecked ? `${todayCheckinTime} 已完成记录` : '点击打卡,确认自己度过了无烟的一天' }}</text>
</view>
</view>
<view class="quit-checkin-side">
<text class="quit-checkin-side-label">{{ todayChecked ? '已完成' : '去打卡' }}</text>
</view>
</view>
<view class="quit-health-card" :style="hpVisualStyle">
<view class="quit-health-header">
<view>
<text class="quit-health-title">恢复轨迹</text>
</view>
<view class="quit-health-badge" :class="{ 'quit-health-badge-done': hpValue >= 100 }">
{{ `HP ${hpValue}` }}
</view>
</view>
<view class="quit-lung-visual">
<view class="quit-lung-stage">
<view class="quit-lung-glow"></view>
<view class="quit-lung-bubbles">
<view
v-for="bubble in lungBubbleItems"
:key="bubble.id"
class="quit-lung-bubble"
:style="{
left: bubble.left,
animationDelay: bubble.delay,
animationDuration: bubble.duration
}"
></view>
</view>
<view class="quit-lung-figure">
<view class="quit-lung-trachea"></view>
<view class="quit-lung-branch quit-lung-branch-left"></view>
<view class="quit-lung-branch quit-lung-branch-right"></view>
<view class="quit-lung-lobe quit-lung-lobe-left">
<view class="quit-lung-lobe-fill" :style="lungRecoveryFillStyle"></view>
</view>
<view class="quit-lung-lobe quit-lung-lobe-right">
<view class="quit-lung-lobe-fill" :style="lungRecoveryFillStyle"></view>
</view>
</view>
</view>
<view class="quit-lung-copy">
<text class="quit-lung-kicker">无烟 {{ smokeFreeTimeLabel }}</text>
<text class="quit-lung-phase">{{ lungPhaseLabel }}</text>
<text class="quit-lung-desc">{{ lungRecoverySummary }}</text>
<view class="quit-lung-tag-row">
<view class="quit-lung-tag">
<text class="quit-lung-tag-label">下一节点</text>
<text class="quit-lung-tag-value">{{ nextHealthMilestoneText }}</text>
</view>
<view class="quit-lung-tag quit-lung-tag-strong">
<text class="quit-lung-tag-label">恢复节奏</text>
<text class="quit-lung-tag-value">{{ lungMomentumText }}</text>
</view>
</view>
</view>
</view>
<view class="quit-health-subheader">
<text class="quit-health-subtitle">恢复节点</text>
<text class="quit-health-subhint">{{ lungRecoveryCaption }}</text>
</view>
<view class="quit-milestone-list">
<view
v-for="(ms, index) in healthMilestoneItems"
:key="index"
class="quit-ms-item"
>
<view class="quit-ms-top">
<text class="quit-ms-name">{{ ms.name }}</text>
<text class="quit-ms-pct" :class="{ 'quit-ms-pct-done': ms.percent >= 100 }">{{ ms.percent }}%</text>
</view>
<view class="quit-ms-bar">
<view
class="quit-ms-fill"
:class="ms.percent >= 100 ? 'quit-ms-fill-done' : 'quit-ms-fill-pending'"
:style="{ width: ms.percent + '%' }"
></view>
</view>
</view>
</view>
</view>
<view class="quit-slip-entry" :style="hpVisualStyle" @tap="openSmokeDialog">
<view class="quit-slip-copy">
<text class="quit-slip-title">如果刚刚没扛住先记下来</text>
<text class="quit-slip-desc">用快速记录留下时间和诱因等会再说更能帮你找回节奏</text>
</view>
<view class="quit-slip-action">
<text class="quit-slip-action-main">快速记录</text>
<text class="quit-slip-action-sub">一根 / 当前时间</text>
</view>
</view>
<view class="quit-dream-entry" @tap="gotoDreamGoals">
<view class="quit-dream-left">
<text class="quit-dream-icon">🎯</text>
<view class="quit-dream-info">
<text class="quit-dream-title">梦想清单</text>
<text class="quit-dream-desc">{{ activeGoalText }}</text>
</view>
</view>
<text class="quit-dream-arrow"></text>
</view>
<view class="quit-tip-bar">
<text class="quit-tip-icon">💡</text>
<text class="quit-tip-text">{{ healthTip }}</text>
</view>
</view>
<!-- 记录模式 -->
<view v-else class="record-home">
<view class="record-hero-card" :class="{ 'record-hero-card-empty': !recordHasData }">
<view class="record-hero-bg record-hero-bg-one"></view>
<view class="record-hero-bg record-hero-bg-two"></view>
<view class="record-hero-header">
<view class="record-hero-title-block">
<text class="record-hero-eyebrow">今日控烟节奏</text>
<text class="record-hero-title">{{ recordHeroTitle }}</text>
</view>
<view class="record-hero-chip">
<text class="record-hero-chip-value">{{ todayCount }}</text>
<text class="record-hero-chip-label">/{{ dailyTarget || '-' }} </text>
</view>
</view>
<view class="record-hero-body">
<view class="record-timer-panel">
<view class="record-ring" :style="todayCountRingStyle">
<view class="record-ring-inner">
<text class="record-ring-kicker">距上次</text>
<text class="record-ring-value">{{ timerDisplay }}</text>
<text class="record-ring-label">抽烟间隔</text>
</view>
</view>
<view class="record-progress-copy">
<view class="record-progress-row">
<text class="record-progress-label">今日目标进度</text>
<text class="record-progress-value">{{ todayCountPercent }}%</text>
</view>
<view class="record-progress-track">
<view class="record-progress-fill" :style="{ width: todayCountPercent + '%' }"></view>
</view>
<text class="record-progress-note">{{ recordRhythmText }}</text>
</view>
</view>
<view class="record-today-grid record-today-grid-single">
<view class="record-today-card record-today-card-smoke">
<text class="record-today-value">{{ todayCount }}</text>
<text class="record-today-label">今日已抽</text>
</view>
</view>
</view>
<view class="record-action-card">
<view class="record-action-copy">
<text class="record-action-title">现在发生了什么</text>
<text class="record-action-desc">记录真实瞬间系统会帮你看见节奏变化</text>
</view>
<view class="record-action-buttons">
<view class="record-action-btn record-action-btn-primary" :class="{ 'record-action-btn-disabled': quickSubmitting }" @tap="quickRecordSmoke">
<text class="record-action-btn-icon">+</text>
<text class="record-action-btn-text">快捷记录</text>
</view>
<view class="record-action-btn record-action-btn-ghost" @tap="openDetailedSmokeDialog">
<text class="record-action-btn-icon"></text>
<text class="record-action-btn-text">详细记录</text>
</view>
</view>
</view>
</view>
<view class="record-insight-card">
<view class="record-section-header">
<view>
<text class="record-section-title">健康回报</text>
<text class="record-section-subtitle">少抽带来的即时反馈</text>
</view>
<text class="record-section-badge">{{ reducePercent }}% 少抽</text>
</view>
<view class="health-metrics">
<view class="health-metric-item">
<view class="health-metric-icon health-metric-icon-money"><text>¥</text></view>
<view class="health-metric-content">
<text class="health-metric-value">{{ recordSavedMoney }}</text>
<text class="health-metric-label">今日节省</text>
</view>
</view>
<view class="health-metric-item">
<view class="health-metric-icon health-metric-icon-time"><text>h</text></view>
<view class="health-metric-content">
<text class="health-metric-value">{{ recordLifeSaved }}</text>
<text class="health-metric-label">生命回收/小时</text>
</view>
</view>
</view>
<view class="health-tip-bar">
<text class="health-tip-icon"></text>
<text class="health-tip-text">{{ recordHealthTip }}</text>
</view>
</view>
<view v-if="achievementData" class="record-achievement-card">
<view class="ach-header">
<view>
<text class="ach-title">{{ achievementData.theme_icon }} {{ achievementCardTitle }}</text>
<text class="ach-theme-name">{{ achievementData.theme_name }}</text>
</view>
<text class="ach-days"> {{ achievementData.days }} </text>
</view>
<view class="ach-body">
<text class="ach-rank">{{ achievementData.current?.name || '--' }}</text>
<view v-if="achievementData.next" class="ach-progress-wrap">
<view class="ach-progress-bar">
<view class="ach-progress-fill" :style="{ width: (achievementData.progress * 100) + '%' }"></view>
</view>
<text class="ach-next-hint">距下一级{{ achievementData.next.name }}还需 {{ achievementData.next.required_days - achievementData.days }} </text>
</view>
<view v-else class="ach-max"><text class="ach-max-text">已达最高等级</text></view>
</view>
</view>
</view>
<smoke-record-dialog
v-model:show="showDialog"
:type="dialogType"
:quick-mode="dialogQuickMode"
@submit="handleSubmit"
/>
</view>
<view v-if="checkinCelebrating" class="checkin-celebration-mask">
<view class="checkin-celebration-card">
<view class="checkin-celebration-burst">
<view
v-for="burst in celebrationBursts"
:key="burst.id"
class="checkin-celebration-ray"
:style="{ transform: `rotate(${burst.rotate}deg) translateY(-88rpx)` }"
></view>
</view>
<view class="checkin-celebration-icon"></view>
<text class="checkin-celebration-title">今日打卡完成</text>
<text class="checkin-celebration-desc">已连续无烟 {{ quitDays }} {{ lungPhaseLabel }}</text>
<view class="checkin-celebration-chip-row">
<view class="checkin-celebration-chip">
<text class="checkin-celebration-chip-label">肺部 HP</text>
<text class="checkin-celebration-chip-value">{{ hpValue }}</text>
</view>
<view class="checkin-celebration-chip">
<text class="checkin-celebration-chip-label">已省下</text>
<text class="checkin-celebration-chip-value">¥{{ savedMoney }}</text>
</view>
</view>
</view>
</view>
</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'
// formatDate / formatTime 来自公共工具,避免重复实现
import { formatDate, formatTime } from '@/utils/time'
const profileStore = useProfileStore()
const userStore = useUserStore()
const { waitForLogin } = useLogin()
// ---- 页面状态 ----
const loading = ref(true)
const pageReady = ref(false)
const navBarHeight = ref(0)
const showDialog = ref(false)
const dialogType = ref('smoke')
const dialogQuickMode = ref(false)
const quickSubmitting = ref(false)
// ---- 数据 ----
const homeData = ref(null) // 记录模式首页数据
const quitHomeData = ref(null) // 戒烟模式首页数据
const achievementData = ref(null) // 成就数据
const quitState = ref(defaultQuitState()) // 本地缓存的打卡状态(网络失败时兜底)
// ---- 打卡庆祝动画 ----
const checkinCelebrating = ref(false)
let checkinCelebrationTimer = null
// ---- 记录模式计时器:距上次抽烟的秒数 ----
let timerInterval = null
const timerBaseSeconds = ref(-1) // -1 表示从未记录过
const timerSeconds = ref(0) // 页面存活期间累计秒数
// ========== 基础计算 ==========
const isQuitMode = computed(() => userStore.mode === 'quit')
const homeSummary = computed(() => homeData.value?.summary || {})
const homeTimer = computed(() => homeData.value?.timer || {})
const quitSummary = computed(() => quitHomeData.value?.summary || {})
const quitDailyStatus = computed(() => quitHomeData.value?.daily_status || {})
// 用户档案基础数据
const baselineCigsPerDay = computed(() => profileStore.profile?.baseline_cigs_per_day || 10)
const packPriceYuan = computed(() => (profileStore.profile?.pack_price_cent || 2500) / 100)
// ========== 记录模式 ==========
const todayCount = computed(() => homeSummary.value.today_count ?? 0)
const dailyTarget = computed(() => {
const target = homeSummary.value.daily_target
if (target != null) return target
return profileStore.profile?.baseline_cigs_per_day || 0
})
const changeText = computed(() => {
const reduced = homeSummary.value.reduced_from_yesterday
if (reduced == null) return '较昨日暂无对比'
if (reduced === 0) return '较昨日持平'
return homeSummary.value.exceeded_yesterday ? `较昨日多 ${reduced}` : `较昨日少 ${reduced}`
})
// 格式化计时器显示 HH:MM:SS
const timerDisplay = computed(() => {
if (timerBaseSeconds.value < 0) return '--:--:--'
const total = timerBaseSeconds.value + timerSeconds.value
const h = Math.floor(total / 3600)
const m = Math.floor((total % 3600) / 60)
const s = total % 60
return [h, m, s].map(n => String(n).padStart(2, '0')).join(':')
})
// 下次建议抽烟时间
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)
return Number.isNaN(date.getTime()) ? '' : formatTime(date)
})
// 今日进度环百分比(已抽/目标)
const todayCountPercent = computed(() => {
if (!dailyTarget.value || dailyTarget.value <= 0) return todayCount.value > 0 ? 100 : 0
return Math.min(Math.max(Math.round((todayCount.value / dailyTarget.value) * 100), 0), 100)
})
const todayCountRingStyle = computed(() => {
const angle = Math.round(todayCountPercent.value * 3.6)
const accent = todayCount.value > dailyTarget.value && dailyTarget.value > 0 ? '#f97316' : '#1fbf8f'
return { background: `conic-gradient(${accent} 0deg ${angle}deg, rgba(226, 239, 233, 0.88) ${angle}deg 360deg)` }
})
const recordHasData = computed(() => todayCount.value > 0 || timerBaseSeconds.value >= 0)
// 今日节省(比目标少抽的部分换算)
const recordSavedMoney = computed(() => {
const saved = Math.max(0, (dailyTarget.value - todayCount.value) * (packPriceYuan.value / 20))
return saved.toFixed(1)
})
const recordHeroTitle = computed(() => {
if (!recordHasData.value) return '先记录第一刻'
if (todayCount.value === 0) return '今天还没抽烟'
if (todayCount.value < dailyTarget.value) return '节奏控制不错'
if (todayCount.value === dailyTarget.value) return '已经到达目标线'
return '今天需要收一收'
})
const recordRhythmText = computed(() => {
if (!recordHasData.value) return '点击下方按钮开始记录,建立自己的控烟节奏。'
if (nextSmokeTimeText.value) return `建议下次 ${nextSmokeTimeText.value} 后再抽,给身体留一点恢复时间。`
return changeText.value
})
const achievementCardTitle = computed(() => isQuitMode.value ? '无烟等级' : '长期记录等级')
// 今日延长生命时长(每少一支 = 11 分钟)
const recordLifeSaved = computed(() => {
const minutes = Math.max(0, (dailyTarget.value - todayCount.value) * 11)
return Math.round(minutes / 60 * 10) / 10
})
const reducePercent = computed(() => {
if (dailyTarget.value <= 0) return 0
return Math.round(Math.max(0, dailyTarget.value - todayCount.value) / dailyTarget.value * 100)
})
const recordHealthTip = computed(() => {
if (todayCount.value === 0) return '今天还没抽烟,继续保持!'
if (todayCount.value < dailyTarget.value) return `今天比目标少抽了${dailyTarget.value - todayCount.value}根,很棒!`
if (todayCount.value === dailyTarget.value) return '今天已达到目标,加油!'
return '今天超标了,明天继续努力'
})
// ========== 戒烟模式 ==========
// 服务端连续无烟天数优先,失败时从本地缓存推算
const quitDays = computed(() => {
if (quitSummary.value.current_streak_days !== undefined) return quitSummary.value.current_streak_days
if (!quitState.value.lastCheckinDate) return 0
// 超过 1 天未打卡则连续中断归零
const gap = diffDays(quitState.value.lastCheckinDate, formatDate(new Date()))
return gap > 1 ? 0 : Number(quitState.value.streakDays || 0)
})
const todayChecked = computed(() => {
if (quitDailyStatus.value.status) return quitDailyStatus.value.status === 'checked_in'
return quitState.value.lastCheckinDate === formatDate(new Date())
})
const todayCheckinTime = computed(() => {
const src = quitDailyStatus.value.checkin_at || quitState.value.lastCheckinAt
return formatClock(src)
})
const savedMoney = computed(() => {
if (quitSummary.value.saved_money_cent !== undefined) return Math.round(quitSummary.value.saved_money_cent / 100)
return Math.round((quitDays.value * baselineCigsPerDay.value / 20) * packPriceYuan.value)
})
const avoidedCigs = computed(() => quitSummary.value.avoided_cigs ?? quitDays.value * baselineCigsPerDay.value)
const lifeSaved = computed(() => Math.round(avoidedCigs.value * 11 / 60))
// HP 值:服务端优先,否则按天数本地估算
const healthProgress = computed(() => {
if (quitSummary.value.health_recovery_percent !== undefined) return quitSummary.value.health_recovery_percent
const d = quitDays.value
if (d >= 365) return 100; if (d >= 180) return 85; if (d >= 90) return 70
if (d >= 30) return 55; if (d >= 14) return 35; if (d >= 7) return 20
if (d >= 3) return 10; if (d >= 1) return 5; return 0
})
const hpValue = computed(() => {
const v = Number(quitSummary.value.hp_current ?? healthProgress.value)
return Number.isNaN(v) ? 0 : Math.min(Math.max(Math.round(v), 0), 100)
})
// 根据 HP 区间决定主题色和文案
const hpState = computed(() => {
const hp = hpValue.value
if (hp <= 20) return { label: '危险期', title: '先保住今天的底线', description: '身体恢复动力偏弱,先把眼前这一波烟瘾稳住最重要。', accent: '#64748b', soft: '#e2e8f0', deep: '#334155' }
if (hp <= 40) return { label: '恢复中', title: '状态正在往回拉', description: '已经开始脱离最低谷,再多守住几次关键时刻,HP 会明显回升。', accent: '#0f766e', soft: '#ccfbf1', deep: '#115e59' }
if (hp <= 60) return { label: '稳定期', title: '节奏开始站稳', description: '你已经进入可持续恢复阶段,规律打卡会让状态越来越稳。', accent: '#0891b2', soft: '#cffafe', deep: '#155e75' }
if (hp <= 80) return { label: '强韧期', title: '恢复势头很不错', description: '肺部状态正在加速恢复,继续把高风险场景提前处理掉。', accent: '#16a34a', soft: '#dcfce7', deep: '#166534' }
return { label: '高能期', title: '你已经进入高能状态', description: '当前状态非常好,保持日常节奏和打卡,就能继续巩固恢复成果。', accent: '#ea580c', soft: '#ffedd5', deep: '#9a3412' }
})
// CSS 变量注入(供卡片内动态主题色使用)
const hpVisualStyle = computed(() => ({
'--hp-accent': hpState.value.accent,
'--hp-soft': hpState.value.soft,
'--hp-deep': hpState.value.deep
}))
const hpGaugeStyle = computed(() => {
const angle = Math.round(hpValue.value * 3.6)
return { background: `conic-gradient(var(--hp-accent) 0deg ${angle}deg, rgba(226, 232, 240, 0.88) ${angle}deg 360deg)` }
})
const hpChangeText = computed(() => {
const delta = Number(quitSummary.value.hp_change_today ?? quitSummary.value.hp_delta_today ?? quitSummary.value.hp_change)
if (!Number.isNaN(delta) && delta !== 0) return delta > 0 ? `今日 +${delta}` : `今日 ${delta}`
if (todayChecked.value) return '今日已保住状态'
return quitDays.value > 0 ? '先打卡再继续回血' : '从今天开始积累'
})
const healthTip = computed(() => {
const d = quitDays.value
if (d >= 365) return '肺部功能显著改善,心血管疾病风险大幅降低'
if (d >= 180) return '血液循环持续改善,肺功能逐步恢复'
if (d >= 90) return '味觉嗅觉恢复,呼吸更加顺畅'
if (d >= 30) return '咳嗽减少,体能开始恢复'
if (d >= 14) return '尼古丁戒断症状明显减轻'
if (d >= 7) return '一氧化碳水平恢复正常'
if (d >= 3) return '呼吸开始变得顺畅'
if (d >= 1) return '身体开始自我修复'
return '开始戒烟,身体即将启动修复'
})
const quitEncouragement = computed(() => {
const d = quitDays.value
if (todayChecked.value) {
if (d >= 30) return '太棒了!坚持一个月以上,你已经战胜了最难的部分'
if (d >= 7) return '一周没抽了!身体正在快速恢复中'
if (d >= 3) return '三天没抽了,最难的时期正在过去'
return '今天打卡成功,继续保持!'
}
if (d === 0) return '迈出第一步,从今天开始无烟生活'
if (d < 3) return '坚持住,前三天是最关键的'
if (d < 7) return '你已经走了很远,继续加油'
return `已坚持 ${d} 天,你很了不起`
})
// 静态数据:肺泡动画气泡配置
const lungBubbleItems = [
{ id: 1, left: '18rpx', delay: '0s', duration: '4.6s' },
{ id: 2, left: '64rpx', delay: '1.2s', duration: '5.2s' },
{ id: 3, left: '116rpx', delay: '0.8s', duration: '4.9s' },
{ id: 4, left: '158rpx', delay: '1.8s', duration: '5.5s' }
]
// 静态数据:打卡庆祝放射线(8 根均匀分布)
const celebrationBursts = Array.from({ length: 8 }, (_, i) => ({ id: i + 1, rotate: i * 45 }))
// 健康里程碑(天数节点)
const healthMilestones = computed(() => [
{ days: 1, label: '1天' }, { days: 7, label: '1周' },
{ days: 30, label: '1月' }, { days: 90, label: '3月' }, { days: 365, label: '1年' }
])
const nextHealthMilestone = computed(() => healthMilestones.value.find(m => quitDays.value < m.days) || null)
const nextHealthMilestoneText = computed(() => nextHealthMilestone.value ? `${nextHealthMilestone.value.label} 里程碑` : '已完成全年恢复')
const smokeFreeTimeLabel = computed(() => quitDays.value >= 1 ? `${quitDays.value}` : '刚开始')
const lungRecoveryFillStyle = computed(() => ({
height: `${Math.max(hpValue.value, 6)}%`,
background: `linear-gradient(180deg, ${hpState.value.soft} 0%, ${hpState.value.accent} 100%)`
}))
const lungPhaseLabel = computed(() => {
const hp = hpValue.value
if (hp >= 100) return '肺部状态已进入长期巩固阶段'
if (hp >= 80) return '当前恢复速度很稳定'
if (hp >= 60) return '肺部恢复节奏正在变强'
if (hp >= 40) return '身体正在持续找回呼吸效率'
if (hp >= 20) return '恢复已经启动,别让节奏断掉'
return '先把今天守住,HP 就会慢慢往上走'
})
const lungRecoverySummary = computed(() => {
const suffix = nextHealthMilestone.value
? `,距离下一阶段还差 ${Math.max(nextHealthMilestone.value.days - quitDays.value, 0)} 天。`
: ',已经进入长期巩固阶段。'
return healthTip.value + suffix
})
const lungMomentumText = computed(() => {
const hp = hpValue.value
if (hp >= 100) return '稳定达成'
if (hp >= 70) return '恢复加速中'
if (hp >= 35) return '持续回升'
return '刚刚启动'
})
const lungRecoveryCaption = computed(() => {
if (!nextHealthMilestone.value) return '所有关键节点已完成'
return `${Math.max(nextHealthMilestone.value.days - quitDays.value, 0)} 天后解锁 ${nextHealthMilestone.value.label}`
})
// 6 个关键健康恢复节点及进度
const healthMilestoneItems = computed(() => {
const milestones = [
{ name: '血压心率恢复', minutes: 20 },
{ name: '一氧化碳排出', minutes: 480 },
{ name: '尼古丁代谢完', minutes: 4320 },
{ name: '味觉嗅觉恢复', minutes: 43200 },
{ name: '血液循环改善', minutes: 129600 },
{ name: '肺功能提升', minutes: 525600 }
]
const minutes = quitDays.value * 1440
return milestones.map(m => ({
name: m.name,
percent: Math.min(Math.round((minutes / m.minutes) * 100), 100)
}))
})
const activeGoalText = computed(() => {
const goal = quitHomeData.value?.goal
if (!goal) return '设定一个小目标,攒钱实现它'
const remaining = goal.target_amount_cent - (goal.current_amount_cent || 0)
if (remaining <= 0) return `${goal.title}」已攒够!`
return `${goal.title}」还差 ¥${Math.round(remaining / 100)}`
})
// ========== 工具函数 ==========
function defaultQuitState() {
return { lastCheckinDate: '', lastCheckinAt: '', streakDays: 0 }
}
/** 两日期之间的天数差(有符号,floor),用于判断打卡是否连续 */
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`)
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) return 0
return Math.floor((to.getTime() - from.getTime()) / 86400000)
}
/** 将 ISO 时间字符串格式化为 HH:MM,无效时返回 '--:--' */
function formatClock(value) {
if (!value) return '--:--'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? '--:--' : formatTime(date)
}
function setupNavBar() {
const systemInfo = uni.getSystemInfoSync()
const statusBarH = systemInfo.statusBarHeight || 0
try {
const menuBtn = uni.getMenuButtonBoundingClientRect()
navBarHeight.value = menuBtn.bottom + (menuBtn.top - statusBarH)
} catch {
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 playQuitCheckinCelebration() {
// 先重置再触发,避免重复动画
checkinCelebrating.value = false
if (checkinCelebrationTimer) { clearTimeout(checkinCelebrationTimer); checkinCelebrationTimer = null }
setTimeout(() => {
checkinCelebrating.value = true
try { uni.vibrateShort() } catch { /* 部分平台不支持震动,忽略 */ }
checkinCelebrationTimer = setTimeout(() => {
checkinCelebrating.value = false
checkinCelebrationTimer = null
}, 1800)
}, 20)
}
function openSmokeDialog() { openDetailedSmokeDialog() }
function openDetailedSmokeDialog() { dialogType.value = 'smoke'; dialogQuickMode.value = false; showDialog.value = true }
function gotoDreamGoals() { uni.navigateTo({ url: '/pages/dream-goals/index' }) }
function buildQuickSmokePayload() {
const now = new Date()
const dateStr = formatDate(now)
const timeStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
return {
smoke_time: dateStr,
smoke_at: `${dateStr} ${timeStr}:00`,
remark: '',
reason_tags: [],
level: 2,
num: 1
}
}
// ========== API 调用 ==========
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()
applyHomeData(res.data || {})
}
async function fetchAchievement() {
try {
const res = await api.getAchievement()
achievementData.value = res.data?.achievement || null
} catch (e) {
console.error('fetchAchievement error:', e)
}
}
function applyQuitHomeData(data) {
if (!data) return
quitHomeData.value = data
if (data.daily_status?.status === 'checked_in' && data.daily_status?.checkin_at) {
saveQuitState({
lastCheckinDate: data.daily_status.date,
lastCheckinAt: data.daily_status.checkin_at,
streakDays: data.summary?.current_streak_days || 0
})
}
}
async function fetchQuitHomeData() {
try {
const res = await api.getQuitCheckinHome()
applyQuitHomeData(res.data)
} catch (e) {
const msg = e?.message || ''
if (msg.includes('基础资料')) {
// 首次使用戒烟模式时后端要求先补填档案,自动补填后重试一次
await ensureQuitProfile()
try {
const res = await api.getQuitCheckinHome()
applyQuitHomeData(res.data)
} catch (retryErr) {
console.error('fetchQuitHomeData retry error:', retryErr)
loadQuitState()
}
} else {
console.error('fetchQuitHomeData error:', e)
loadQuitState()
}
}
}
async function ensureQuitProfile() {
const profile = profileStore.profile
if (!profile) return
await api.upsertQuitCheckinProfile({
quit_start_date: formatDate(new Date()),
pack_price_cent: profile.pack_price_cent || 2500,
baseline_cigs_per_day: profile.baseline_cigs_per_day || 10
})
}
async function submitQuitCheckin() {
const today = formatDate(new Date())
const res = await api.quitCheckin({ date: today })
applyQuitHomeData(res.data)
saveQuitState({
lastCheckinDate: res.data?.daily_status?.date || today,
lastCheckinAt: res.data?.daily_status?.checkin_at || new Date().toISOString(),
streakDays: res.data?.summary?.current_streak_days || 0
})
await fetchQuitHomeData()
await fetchAchievement()
return res
}
async function handleSubmit(submitData) {
try {
await api.createLog(submitData)
if (isQuitMode.value) {
await fetchQuitHomeData()
uni.showToast({ title: '已记下这次波动', icon: 'success' })
} else {
timerBaseSeconds.value = 0
timerSeconds.value = 0
startTimer()
uni.showToast({ title: '记录成功', icon: 'success' })
}
if (isQuitMode.value) { await fetchAchievement(); return }
await fetchRecordHomeData()
} catch (e) {
console.error('handleSubmit error:', e)
uni.showToast({ title: '保存失败', icon: 'none' })
}
}
async function quickRecordSmoke() {
if (quickSubmitting.value) return
quickSubmitting.value = true
try {
await handleSubmit(buildQuickSmokePayload())
} finally {
quickSubmitting.value = false
}
}
async function handleQuitCheckin() {
if (todayChecked.value) { uni.showToast({ title: '今天已经打过卡', icon: 'none' }); return }
try {
await submitQuitCheckin()
playQuitCheckinCelebration()
uni.showToast({ title: '打卡成功', icon: 'success' })
} catch (e) {
const message = e?.message || ''
if (message.includes('基础资料')) {
try {
await ensureQuitProfile()
await submitQuitCheckin()
playQuitCheckinCelebration()
uni.showToast({ title: '打卡成功', icon: 'success' })
} catch (retryErr) {
console.error('handleQuitCheckin retry error:', retryErr)
uni.showToast({ title: retryErr?.message || '打卡失败,请重试', icon: 'none' })
}
return
}
console.error('handleQuitCheckin error:', e)
uni.showToast({ title: message || '打卡失败,请重试', icon: 'none' })
}
}
// 确认 profile 已完善,未完成则跳转 onboarding
async function ensureProfileReady() {
const profileData = await profileStore.fetchProfile()
const { profile, is_completed, exists } = profileData
const completed = is_completed || !!profile?.onboarding_completed_at || (profile?.baseline_cigs_per_day > 0)
if (!exists || !completed) {
uni.navigateTo({ url: '/pages/onboarding/index' })
return false
}
return true
}
async function refreshCurrentMode() {
if (!userStore.mode) return
if (!await ensureProfileReady()) return
if (isQuitMode.value) {
stopTimer()
await Promise.all([fetchQuitHomeData(), fetchAchievement()])
return
}
await Promise.all([fetchRecordHomeData(), fetchAchievement()])
}
async function initPage() {
setupNavBar()
loading.value = true
try {
await waitForLogin()
await profileStore.fetchProfile()
if (!userStore.mode) {
uni.reLaunch({ 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()
if (checkinCelebrationTimer) { clearTimeout(checkinCelebrationTimer); checkinCelebrationTimer = null }
})
onShareAppMessage(() => ({
title: isQuitMode.value ? '我在坚持戒烟打卡' : '我在记录自己的抽烟变化',
path: 'pages/index/index'
}))
</script>
<style lang="scss" scoped>
// ===== 页面基础 =====
.page {
min-height: 100vh;
position: relative;
background: $bg-page;
overflow: hidden;
box-sizing: border-box;
}
.page-record {
background: linear-gradient(180deg, #F6F8F6 0%, #EFF4F1 52%, #E9F0EC 100%);
}
// ===== 背景装饰 =====
.bg-gradient {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 600rpx;
background: linear-gradient(180deg, $color-primary-soft 0%, $color-primary-softer 40%, $bg-page 100%);
pointer-events: none;
}
.bg-pattern {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 400rpx;
background-image:
radial-gradient(circle at 20% 30%, rgba($color-primary-dark, 0.08) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba($color-primary, 0.06) 0%, transparent 40%);
pointer-events: none;
}
// ===== 骨架屏 =====
.skeleton-wrap {
padding: 48rpx $spacing-xl;
position: relative;
z-index: 1;
}
.sk-circle {
width: 280rpx;
height: 280rpx;
border-radius: 50%;
@include skeleton-shimmer;
margin: 0 auto 48rpx;
}
.sk-row {
height: 32rpx;
border-radius: $radius-md;
@include skeleton-shimmer;
margin-bottom: $spacing-lg;
&-short { width: 60%; }
}
.sk-card {
height: 160rpx;
border-radius: $radius-xl;
@include skeleton-shimmer;
margin-top: $spacing-xl;
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
// ===== 主内容 =====
.nav-placeholder, .content {
position: relative;
z-index: 1;
}
.content {
padding: $spacing-lg $spacing-lg 200rpx;
}
// ===== 戒烟模式 =====
.quit-home {
@include flex-col;
gap: $spacing-lg;
}
// 玻璃卡片基础样式
.quit-hero-card,
.quit-checkin-card,
.quit-health-card {
position: relative;
overflow: hidden;
background: $gradient-card;
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
border-radius: $radius-2xl;
border: 1rpx solid $border-light;
box-shadow: $shadow-lg, 0 6rpx 14rpx rgba(15, 23, 42, 0.03), inset 0 1rpx 0 rgba(255, 255, 255, 0.88);
&::before {
content: '';
position: absolute;
width: 240rpx;
height: 240rpx;
left: -72rpx;
top: -72rpx;
border-radius: 50%;
background: radial-gradient(circle, var(--hp-soft, rgba($color-primary, 0.18)) 0%, rgba($color-primary, 0) 72%);
pointer-events: none;
}
}
.quit-hero-card {
padding: 28rpx $spacing-lg;
--hp-accent: #{$color-primary-deeper};
--hp-soft: #def7ec;
--hp-deep: #0f766e;
}
.quit-hero-top,
.quit-overview,
.quit-health-header,
.quit-health-milestones {
position: relative;
z-index: 1;
}
.quit-hero-top {
@include flex-between;
align-items: flex-start;
gap: $spacing-md;
margin-bottom: 20rpx;
}
.quit-hero-copy {
flex: 1;
min-width: 0;
}
.quit-hero-eyebrow {
@include chip;
background: rgba($color-primary-softer, 0.94);
border: 1rpx solid $border-light;
color: $text-tertiary;
}
.quit-hero-title {
display: block;
margin-top: 10rpx;
font-size: $font-lg;
line-height: $line-height-normal;
font-weight: $font-weight-bold;
color: var(--hp-accent);
}
.quit-hero-chip {
@include chip;
flex-shrink: 0;
padding: 10rpx 18rpx;
background: rgba($color-primary-softer, 0.94);
border: 1rpx solid $border-light;
color: $color-primary-deeper;
}
.quit-overview {
@include flex-col;
gap: 18rpx;
}
// HP 面板
.quit-hp-panel {
display: flex;
align-items: center;
gap: 18rpx;
padding: 18rpx;
border-radius: $radius-xl + 2rpx;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.94) 0%, rgba(248, 251, 249, 0.92) 100%);
border: 1rpx solid $border-light;
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.92);
}
.quit-hp-ring {
@include ring(200rpx);
padding: 12rpx;
box-shadow: 0 16rpx 34rpx rgba(15, 23, 42, 0.08);
}
.quit-hp-ring-inner {
width: 100%;
height: 100%;
border-radius: 50%;
background: linear-gradient(180deg, $bg-card 0%, $bg-card-hover 100%);
@include flex-col;
align-items: center;
justify-content: center;
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.92);
padding: 0 12rpx;
text-align: center;
}
.quit-hp-ring-kicker {
font-size: $font-xs;
font-weight: $font-weight-bold;
color: $text-tertiary;
}
.quit-hp-ring-value {
margin-top: 10rpx;
font-size: $font-display;
font-weight: $font-weight-heavy;
line-height: 1;
color: var(--hp-accent);
font-family: $font-family-number;
}
.quit-hp-ring-label {
margin-top: $spacing-xs;
padding: $spacing-xs $spacing-md;
border-radius: $radius-full;
background: var(--hp-soft);
color: var(--hp-deep);
font-size: $font-sm;
font-weight: $font-weight-bold;
}
.quit-hp-copy {
flex: 1;
min-width: 0;
@include flex-col;
justify-content: center;
}
.quit-hp-title {
display: block;
font-size: $font-2xl;
line-height: 1.35;
font-weight: $font-weight-heavy;
color: var(--hp-deep);
}
.quit-hp-desc {
display: block;
margin-top: 10rpx;
font-size: 23rpx;
line-height: $line-height-relaxed;
color: $text-secondary;
}
.quit-hp-pill-row {
display: flex;
gap: $spacing-sm;
margin-top: 18rpx;
}
.quit-hp-pill {
flex: 1;
min-width: 0;
padding: 14rpx $spacing-md;
border-radius: 18rpx;
background: rgba(255, 255, 255, 0.8);
border: 1rpx solid $border-light;
&-strong {
background: linear-gradient(180deg, var(--hp-soft) 0%, rgba(255, 255, 255, 0.88) 100%);
}
&-label {
display: block;
font-size: 18rpx;
color: $text-muted;
}
&-value {
display: block;
margin-top: 6rpx;
font-size: $font-sm;
font-weight: $font-weight-bold;
color: $text-primary;
}
}
// 指标网格
.quit-metrics-grid {
width: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
}
.quit-metric-card {
padding: 18rpx 18rpx 20rpx;
border-radius: $radius-lg + 2rpx;
background: $gradient-card-subtle;
border: 1rpx solid $border-light;
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.92);
&-wide { grid-column: 1 / -1; }
}
.quit-metric-kicker {
display: block;
font-size: $font-sm;
color: $text-tertiary;
}
.quit-metric-value {
display: block;
margin-top: 10rpx;
font-size: $font-2xl;
line-height: $line-height-tight;
font-weight: $font-weight-heavy;
color: $text-primary;
font-family: $font-family-number;
}
// 打卡卡片
.quit-checkin-card {
@include flex-between;
gap: 18rpx;
padding: 26rpx $spacing-lg;
&-done {
background: linear-gradient(180deg, rgba($color-primary-softer, 0.98) 0%, rgba(249, 252, 250, 0.94) 100%);
}
}
.quit-checkin-main {
display: flex;
align-items: center;
gap: 18rpx;
flex: 1;
min-width: 0;
}
.quit-checkin-icon {
width: 84rpx;
height: 84rpx;
border-radius: 28rpx;
flex-shrink: 0;
background: $gradient-primary;
box-shadow: 0 16rpx 30rpx rgba($color-primary-dark, 0.22);
@include flex-center;
text {
font-size: 36rpx;
font-weight: $font-weight-bold;
color: $text-inverse;
}
&-done {
background: linear-gradient(180deg, #26b47f 0%, $color-primary-deeper 100%);
}
}
.quit-checkin-copy {
flex: 1;
min-width: 0;
}
.quit-checkin-title {
display: block;
font-size: $font-2xl;
font-weight: $font-weight-bold;
color: $text-primary;
}
.quit-checkin-desc {
display: block;
margin-top: $spacing-xs;
font-size: 23rpx;
line-height: 1.6;
color: $text-tertiary;
}
.quit-checkin-side {
flex-shrink: 0;
padding: $spacing-sm 18rpx;
border-radius: $radius-full;
background: rgba($color-primary-softer, 0.94);
border: 1rpx solid $border-light;
&-label {
font-size: $font-sm;
font-weight: $font-weight-semibold;
color: $color-primary-deeper;
}
}
// 健康卡片
.quit-health-card {
padding: 26rpx $spacing-lg $spacing-lg;
}
.quit-health-header {
@include flex-between;
gap: $spacing-md;
margin-bottom: 20rpx;
}
.quit-health-title {
display: block;
font-size: $font-xl;
font-weight: $font-weight-bold;
color: $text-primary;
}
.quit-health-badge {
@include chip;
background: rgba($color-primary-softer, 0.94);
border: 1rpx solid $border-light;
color: $color-primary-deeper;
&-done {
background: linear-gradient(135deg, $color-success, $color-primary-deeper);
color: $text-inverse;
border-color: transparent;
}
}
// 破戒入口
.quit-slip-entry {
@include flex-between;
gap: $spacing-md;
padding: 22rpx $spacing-lg;
border-radius: $radius-xl;
background: linear-gradient(135deg, var(--hp-soft, #def7ec) 0%, rgba(255, 255, 255, 0.96) 100%);
border: 1rpx solid $border-light;
box-shadow: 0 10rpx $spacing-lg rgba(15, 23, 42, 0.05);
}
.quit-slip-copy {
flex: 1;
min-width: 0;
}
.quit-slip-title {
display: block;
font-size: $font-lg;
font-weight: $font-weight-bold;
color: $text-primary;
}
.quit-slip-desc {
display: block;
margin-top: $spacing-xs;
font-size: $font-sm;
line-height: 1.6;
color: $text-tertiary;
}
.quit-slip-action {
flex-shrink: 0;
padding: $spacing-md 18rpx;
border-radius: 18rpx;
background: linear-gradient(180deg, var(--hp-accent, $color-primary-deeper) 0%, var(--hp-deep, #0f766e) 100%);
box-shadow: 0 14rpx 26rpx rgba($color-primary-deeper, 0.2);
&-main {
display: block;
font-size: $font-base;
font-weight: $font-weight-bold;
color: $text-inverse;
}
&-sub {
display: block;
margin-top: 6rpx;
font-size: 19rpx;
color: rgba(255, 255, 255, 0.82);
}
}
// 里程碑
.quit-milestone-list {
position: relative;
z-index: 1;
@include flex-col;
gap: $spacing-md;
}
.quit-ms-item { position: relative; }
.quit-ms-top {
@include flex-between;
margin-bottom: $spacing-xs;
}
.quit-ms-name {
font-size: $font-base;
color: #374151;
font-weight: $font-weight-medium;
}
.quit-ms-pct {
font-size: $font-sm;
color: $text-muted;
font-weight: $font-weight-semibold;
&-done { color: $color-primary-deeper; }
}
.quit-ms-bar {
@include progress-bar;
}
.quit-ms-fill {
@include progress-fill;
&-done { background: linear-gradient(90deg, $color-primary, $color-primary-deeper); }
&-pending { background: rgba($color-primary, 0.3); }
}
// 成就内联
.quit-ach-inline {
@include flex-between;
gap: $spacing-md;
margin-top: 20rpx;
padding: 18rpx 20rpx;
border-radius: $radius-lg + 2rpx;
background: linear-gradient(180deg, #f5fbf9 0%, #eef6f2 100%);
border: 1rpx solid $border-light;
position: relative;
z-index: 1;
}
.quit-ach-left {
display: flex;
align-items: center;
gap: $spacing-sm;
flex-shrink: 0;
}
.quit-ach-icon {
font-size: 36rpx;
line-height: 1;
}
.quit-ach-info {
@include flex-col;
}
.quit-ach-rank {
font-size: $font-lg;
font-weight: $font-weight-heavy;
color: $color-primary-deeper;
line-height: $line-height-tight;
}
.quit-ach-theme {
font-size: $font-xs;
color: $text-muted;
margin-top: 2rpx;
}
.quit-ach-right {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
justify-content: flex-end;
}
.quit-ach-progress-area {
width: 100%;
max-width: 280rpx;
}
.quit-ach-bar {
@include progress-bar(8rpx);
}
.quit-ach-fill {
@include progress-fill;
}
.quit-ach-hint {
display: block;
margin-top: 6rpx;
font-size: $font-xs;
color: $text-muted;
text-align: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.quit-ach-max {
font-size: $font-xs;
color: $color-primary-deeper;
font-weight: $font-weight-semibold;
}
// 梦想入口
.quit-dream-entry {
@include flex-between;
padding: 22rpx $spacing-lg;
background: $gradient-card;
border-radius: $radius-xl;
border: 1rpx solid $border-light;
box-shadow: 0 8rpx 20rpx rgba(15, 23, 42, 0.04);
}
.quit-dream-left {
display: flex;
align-items: center;
gap: $spacing-md;
flex: 1;
min-width: 0;
}
.quit-dream-icon {
font-size: 36rpx;
flex-shrink: 0;
}
.quit-dream-info {
flex: 1;
min-width: 0;
}
.quit-dream-title {
display: block;
font-size: $font-lg;
font-weight: $font-weight-bold;
color: $text-primary;
}
.quit-dream-desc {
display: block;
margin-top: 4rpx;
font-size: $font-sm;
color: $text-muted;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.quit-dream-arrow {
font-size: 36rpx;
color: $text-disabled;
flex-shrink: 0;
font-weight: 300;
}
// 提示栏
.quit-tip-bar {
display: flex;
align-items: center;
gap: $spacing-xs;
padding: 14rpx 18rpx;
border-radius: $radius-md;
background: rgba($color-primary-softer, 0.6);
border: 1rpx solid rgba(15, 23, 42, 0.03);
}
.quit-tip-icon {
font-size: $font-sm;
flex-shrink: 0;
}
.quit-tip-text {
font-size: $font-xs;
line-height: $line-height-normal;
color: $text-muted;
}
// ===== 记录模式 =====
.record-home {
@include flex-col;
gap: $spacing-lg;
}
.record-hero-card,
.record-insight-card,
.record-achievement-card {
position: relative;
overflow: hidden;
border-radius: 36rpx;
background: rgba(255, 255, 255, 0.86);
-webkit-backdrop-filter: blur(22px);
backdrop-filter: blur(22px);
border: 1rpx solid rgba(255, 255, 255, 0.82);
box-shadow: 0 24rpx 60rpx rgba(23, 55, 46, 0.08), inset 0 1rpx 0 rgba(255, 255, 255, 0.96);
}
.record-hero-card {
padding: 32rpx;
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.92) 0%, rgba(241, 250, 246, 0.9) 56%, rgba(232, 244, 238, 0.88) 100%);
}
.record-hero-card-empty {
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.94) 0%, rgba(244, 250, 247, 0.92) 56%, rgba(237, 247, 242, 0.9) 100%);
}
.record-hero-bg {
position: absolute;
border-radius: 50%;
pointer-events: none;
filter: blur(2rpx);
}
.record-hero-bg-one {
width: 360rpx;
height: 360rpx;
right: -138rpx;
top: -150rpx;
background: radial-gradient(circle, rgba(31, 191, 143, 0.2) 0%, rgba(31, 191, 143, 0) 70%);
}
.record-hero-bg-two {
width: 280rpx;
height: 280rpx;
left: -120rpx;
bottom: -132rpx;
background: radial-gradient(circle, rgba(14, 165, 233, 0.12) 0%, rgba(14, 165, 233, 0) 72%);
}
.record-hero-header,
.record-hero-body,
.record-action-card {
position: relative;
z-index: 1;
}
.record-hero-header {
@include flex-between;
align-items: flex-start;
gap: $spacing-md;
margin-bottom: 30rpx;
}
.record-hero-title-block {
flex: 1;
min-width: 0;
}
.record-hero-eyebrow {
display: block;
font-size: $font-sm;
font-weight: $font-weight-semibold;
letter-spacing: 2rpx;
color: $color-primary-deeper;
}
.record-hero-title {
display: block;
margin-top: 10rpx;
font-size: 46rpx;
line-height: 1.16;
font-weight: $font-weight-heavy;
color: #13251f;
letter-spacing: -0.8rpx;
}
.record-hero-chip {
flex-shrink: 0;
display: flex;
align-items: baseline;
padding: 14rpx 18rpx;
border-radius: 22rpx;
background: rgba(255, 255, 255, 0.76);
border: 1rpx solid rgba(31, 191, 143, 0.12);
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.95);
}
.record-hero-chip-value {
font-size: $font-2xl;
font-weight: $font-weight-heavy;
line-height: 1;
color: $color-primary-deeper;
font-family: $font-family-number;
}
.record-hero-chip-label {
font-size: $font-xs;
font-weight: $font-weight-semibold;
color: $text-muted;
}
.record-hero-body {
@include flex-col;
gap: 22rpx;
}
.record-timer-panel {
display: flex;
align-items: center;
gap: 24rpx;
padding: 24rpx;
border-radius: 32rpx;
background: rgba(255, 255, 255, 0.66);
border: 1rpx solid rgba(255, 255, 255, 0.86);
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.9);
}
.record-ring {
@include ring(220rpx);
flex-shrink: 0;
padding: 12rpx;
box-shadow: 0 18rpx 34rpx rgba(31, 191, 143, 0.12), inset 0 2rpx 8rpx rgba(15, 23, 42, 0.04);
}
.record-ring-inner {
width: 100%;
height: 100%;
border-radius: 50%;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(247, 252, 249, 0.96) 100%);
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 1);
@include flex-col;
align-items: center;
justify-content: center;
text-align: center;
padding: 0 18rpx;
}
.record-ring-kicker,
.record-ring-label {
font-size: 20rpx;
font-weight: $font-weight-semibold;
color: $text-muted;
}
.record-ring-value {
margin: 8rpx 0;
font-size: 38rpx;
line-height: 1;
font-weight: $font-weight-heavy;
color: $color-primary-deeper;
font-family: $font-family-number;
letter-spacing: -1rpx;
}
.record-progress-copy {
flex: 1;
min-width: 0;
}
.record-progress-row {
@include flex-between;
gap: $spacing-sm;
margin-bottom: 12rpx;
}
.record-progress-label,
.record-progress-value {
font-size: $font-sm;
font-weight: $font-weight-bold;
color: $text-secondary;
}
.record-progress-value { color: $color-primary-deeper; }
.record-progress-track {
height: 16rpx;
border-radius: $radius-full;
background: rgba(213, 232, 223, 0.82);
overflow: hidden;
}
.record-progress-fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #1fbf8f 0%, #79ddbd 100%);
box-shadow: 0 0 16rpx rgba(31, 191, 143, 0.32);
transition: width 0.35s ease;
}
.record-progress-note {
display: block;
margin-top: 16rpx;
font-size: $font-sm;
line-height: $line-height-relaxed;
color: $text-secondary;
}
.record-today-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
}
.record-today-grid-single {
grid-template-columns: 1fr;
}
.record-today-card {
padding: 22rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.7);
border: 1rpx solid rgba(255, 255, 255, 0.86);
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.9);
}
.record-today-card-smoke { background: linear-gradient(145deg, rgba(255, 255, 255, 0.78) 0%, rgba(255, 247, 237, 0.68) 100%); }
.record-today-value {
display: block;
font-size: 48rpx;
line-height: 1;
font-weight: $font-weight-heavy;
color: #14261f;
font-family: $font-family-number;
}
.record-today-label {
display: block;
margin-top: 10rpx;
font-size: $font-sm;
font-weight: $font-weight-semibold;
color: $text-muted;
}
.record-action-card {
margin-top: 24rpx;
padding: 24rpx;
border-radius: 30rpx;
background: rgba(18, 37, 31, 0.92);
box-shadow: 0 18rpx 40rpx rgba(19, 37, 31, 0.14);
}
.record-action-title {
display: block;
font-size: $font-lg;
font-weight: $font-weight-heavy;
color: $text-inverse;
}
.record-action-desc {
display: block;
margin-top: 8rpx;
font-size: $font-sm;
line-height: $line-height-normal;
color: rgba(255, 255, 255, 0.68);
}
.record-action-buttons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16rpx;
margin-top: 22rpx;
}
.record-action-btn {
height: 88rpx;
border-radius: $radius-full;
@include flex-center;
gap: 10rpx;
font-weight: $font-weight-heavy;
}
.record-action-btn-primary {
background: linear-gradient(135deg, #1fbf8f 0%, #42d6a7 100%);
box-shadow: 0 14rpx 26rpx rgba(31, 191, 143, 0.34);
color: $text-inverse;
}
.record-action-btn-ghost {
background: rgba(255, 255, 255, 0.1);
border: 1rpx solid rgba(255, 255, 255, 0.18);
color: $text-inverse;
}
.record-action-btn-disabled {
opacity: 0.68;
pointer-events: none;
}
.record-action-btn-icon {
font-size: 30rpx;
line-height: 1;
}
.record-action-btn-text {
font-size: $font-base;
}
.record-insight-card {
padding: 28rpx;
}
.record-section-header {
@include flex-between;
align-items: flex-start;
gap: $spacing-md;
margin-bottom: 22rpx;
}
.record-section-title {
display: block;
font-size: $font-xl;
font-weight: $font-weight-heavy;
color: #13251f;
}
.record-section-subtitle {
display: block;
margin-top: 6rpx;
font-size: $font-sm;
color: $text-muted;
}
.record-section-badge {
flex-shrink: 0;
padding: 10rpx 16rpx;
border-radius: $radius-full;
background: rgba(31, 191, 143, 0.1);
color: $color-primary-deeper;
font-size: $font-xs;
font-weight: $font-weight-heavy;
}
.health-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14rpx;
}
.health-metric-item {
@include flex-col;
align-items: flex-start;
gap: 14rpx;
padding: 18rpx;
min-height: 142rpx;
background: linear-gradient(180deg, rgba(247, 251, 249, 0.96) 0%, rgba(241, 248, 245, 0.96) 100%);
border-radius: 24rpx;
border: 1rpx solid $border-light;
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.92);
}
.health-metric-icon {
width: 48rpx;
height: 48rpx;
border-radius: 16rpx;
@include flex-center;
font-size: $font-base;
font-weight: $font-weight-heavy;
&-money { color: #a16207; background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); }
&-time { color: #075985; background: linear-gradient(135deg, #e0f2fe 0%, #bae6fd 100%); }
}
.health-metric-content { @include flex-col; }
.health-metric-value {
font-size: $font-xl;
font-weight: $font-weight-heavy;
line-height: 1;
color: $text-primary;
font-family: $font-family-number;
}
.health-metric-label {
margin-top: 8rpx;
font-size: 21rpx;
line-height: $line-height-normal;
color: $text-tertiary;
}
.health-tip-bar {
display: flex;
align-items: flex-start;
gap: 12rpx;
margin-top: 20rpx;
padding: 18rpx 20rpx;
background: linear-gradient(135deg, rgba(236, 253, 245, 0.82) 0%, rgba(240, 249, 255, 0.7) 100%);
border-radius: 24rpx;
border: 1rpx solid rgba(31, 191, 143, 0.1);
}
.health-tip-icon {
font-size: $font-base;
color: $color-primary-deeper;
font-weight: $font-weight-heavy;
}
.health-tip-text {
flex: 1;
font-size: $font-sm;
color: $text-secondary;
line-height: $line-height-relaxed;
}
.record-achievement-card {
padding: 28rpx;
background: linear-gradient(145deg, rgba(255, 255, 255, 0.9) 0%, rgba(248, 250, 252, 0.9) 100%);
}
.ach-header {
@include flex-between;
align-items: flex-start;
gap: $spacing-md;
margin-bottom: $spacing-md;
}
.ach-title {
display: block;
font-size: $font-md;
font-weight: $font-weight-heavy;
color: $text-primary;
}
.ach-theme-name {
display: block;
margin-top: 8rpx;
font-size: $font-xs;
color: $text-muted;
}
.ach-days {
flex-shrink: 0;
font-size: $font-xs;
font-weight: $font-weight-bold;
color: $color-primary-deeper;
padding: 8rpx 14rpx;
border-radius: $radius-full;
background: rgba(31, 191, 143, 0.1);
}
.ach-body {
position: relative;
z-index: 1;
}
.ach-rank {
display: block;
font-size: $font-3xl;
font-weight: $font-weight-heavy;
color: $color-primary-deeper;
font-family: $font-family-number;
margin-bottom: $spacing-md;
}
.ach-progress-wrap { margin-bottom: 4rpx; }
.ach-progress-bar {
@include progress-bar(12rpx);
border-radius: 6rpx;
margin-bottom: 10rpx;
}
.ach-progress-fill {
@include progress-fill;
border-radius: 6rpx;
}
.ach-next-hint {
font-size: $font-sm;
color: $text-tertiary;
}
.ach-max { margin-top: 4rpx; }
.ach-max-text {
font-size: $font-sm;
color: $color-primary-deeper;
font-weight: $font-weight-semibold;
}
// ===== 庆祝弹窗 =====
.checkin-celebration-mask {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.24);
@include flex-center;
z-index: 40;
padding: 0 $spacing-2xl;
}
.checkin-celebration-card {
position: relative;
width: 100%;
padding: 42rpx $spacing-2xl - 6rpx $spacing-2xl - 6rpx;
border-radius: $radius-2xl;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(242, 251, 247, 0.96) 100%);
box-shadow: 0 26rpx 56rpx rgba(15, 23, 42, 0.18);
@include flex-col;
align-items: center;
animation: celebrateEnter 0.28s ease-out;
}
.checkin-celebration-burst {
position: absolute;
left: 50%;
top: 82rpx;
width: 0;
height: 0;
}
.checkin-celebration-ray {
position: absolute;
left: 50%;
top: 50%;
width: 8rpx;
height: 34rpx;
margin-left: -4rpx;
border-radius: $radius-full;
background: linear-gradient(180deg, rgba($color-primary-light, 0.2) 0%, rgba($color-primary-deeper, 0.92) 100%);
}
.checkin-celebration-icon {
position: relative;
z-index: 1;
width: 112rpx;
height: 112rpx;
border-radius: 50%;
background: $gradient-primary;
box-shadow: 0 18rpx 36rpx rgba($color-primary-deeper, 0.3);
@include flex-center;
font-size: $font-4xl;
font-weight: $font-weight-heavy;
color: $text-inverse;
animation: pulseCheck 0.8s ease-out;
}
.checkin-celebration-title {
display: block;
margin-top: 26rpx;
font-size: $font-2xl;
font-weight: $font-weight-heavy;
color: $text-primary;
}
.checkin-celebration-desc {
display: block;
margin-top: 10rpx;
font-size: $font-base;
line-height: $line-height-relaxed;
color: $text-secondary;
text-align: center;
}
.checkin-celebration-chip-row {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14rpx;
margin-top: 26rpx;
}
.checkin-celebration-chip {
padding: 18rpx $spacing-md;
border-radius: $radius-lg;
background: rgba(255, 255, 255, 0.76);
border: 1rpx solid $border-light;
text-align: center;
&-label {
display: block;
font-size: $font-xs;
color: $text-muted;
}
&-value {
display: block;
margin-top: $spacing-xs;
font-size: $font-lg;
font-weight: $font-weight-heavy;
color: $color-primary-deeper;
}
}
@keyframes breathBubble {
0% {
transform: translateY(0) scale(0.88);
opacity: 0;
}
20% {
opacity: 0.95;
}
100% {
transform: translateY(-170rpx) scale(1.2);
opacity: 0;
}
}
@keyframes recordFabFloat {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6rpx); }
}
@keyframes celebrateEnter {
0% {
opacity: 0;
transform: translateY(24rpx) scale(0.94);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes celebrateGlow {
0%, 100% {
transform: translate(-50%, -50%) scale(0.96);
opacity: 0.9;
}
50% {
transform: translate(-50%, -50%) scale(1.04);
opacity: 1;
}
}
@keyframes pulseCheck {
0% {
transform: scale(0.82);
}
55% {
transform: scale(1.08);
}
100% {
transform: scale(1);
}
}
</style>