diff --git a/src/components/smoke-record-dialog/smoke-record-dialog.vue b/src/components/smoke-record-dialog/smoke-record-dialog.vue index 5380f0d..d1ce649 100644 --- a/src/components/smoke-record-dialog/smoke-record-dialog.vue +++ b/src/components/smoke-record-dialog/smoke-record-dialog.vue @@ -20,7 +20,30 @@ 默认数量 {{ formData.num }} 支 - 先选原因就能快速保存,需要时再展开高级项。 + 选择原因和烟瘾等级即可快速保存,需要时再展开高级项。 + + + + + 烟瘾等级 + Level {{ formData.level }} + + + + 无感 + 强烈 + @@ -59,7 +82,7 @@ {{ showAdvanced ? '收起高级设置' : '展开高级设置' }} - 修改时间、数量和强度 + 修改时间和数量 {{ showAdvanced ? '⌃' : '⌄' }} @@ -98,7 +121,7 @@ - + {{ type === 'smoke' ? '烟瘾程度' : '忍住强度' }} Level {{ formData.level }} diff --git a/src/pages/index/index.vue b/src/pages/index/index.vue index 4abf3d8..a08b4c2 100644 --- a/src/pages/index/index.vue +++ b/src/pages/index/index.vue @@ -218,166 +218,118 @@ - - - - - - - - 记录 - 今天的节奏与变化 - + + + + + + + + 今日控烟节奏 + {{ recordHeroTitle }} + + + {{ todayCount }} + /{{ dailyTarget || '-' }} 根 - 今日概览 - - - - {{ timerDisplay }} - 距上次抽烟 - - - - - - - - {{ resistedCount }} - - - 今日忍住 - - - - {{ todayCount }} - - - 今日已抽 + + + + + 距上次 + {{ timerDisplay }} + 抽烟间隔 - {{ nextSmokeTimeText ? `建议下次 ${nextSmokeTimeText}` : changeText }} - - - - - - - 0 - 今日记录 + + + 今日目标进度 + {{ todayCountPercent }}% + + + + + {{ recordRhythmText }} - - 今天还没有新记录 - 用悬浮按钮快速记下抽烟或忍住的瞬间,系统会开始生成节奏变化和健康反馈。 - - - - {{ dailyTarget }} - 今日目标 - - - {{ baselineCigsPerDay }} - 基础支数 - - - 立即开始 - 首条记录 + + + + {{ todayCount }} + 今日已抽 - - - 快速动作 - 右下角悬浮按钮支持快速记录与忍住反馈 + + + 现在发生了什么? + 记录真实瞬间,系统会帮你看见节奏变化 - - - 记录 - - - 忍住 - + + + + + 快捷记录 + + + 详细记录 + + - - - - 健康数据 - 记录带来的改变 + + + + 健康回报 + 少抽带来的即时反馈 + + {{ reducePercent }}% 少抽 - - 💰 - + ¥ - ¥{{ recordSavedMoney }} - 累计节省 + {{ recordSavedMoney }} + 今日节省 - - - ⏱️ - + h - {{ recordLifeSaved }}h - 延长生命 - - - - - - 💪 - - - {{ totalResistedCount }} - 累计忍住 - - - - - - 📉 - - - {{ reducePercent }}% - 减少比例 + {{ recordLifeSaved }} + 生命回收/小时 - 💡 + {{ recordHealthTip }} + - - - {{ achievementData.theme_icon }} 成就称号 + + + + {{ achievementData.theme_icon }} {{ achievementCardTitle }} {{ achievementData.theme_name }} - - - {{ achievementData.current?.name || '--' }} - 第 {{ achievementData.days }} 天 - - - - - - 距下一级「{{ achievementData.next.name }}」还需 {{ achievementData.next.required_days - achievementData.days }} 天 - - - 已达最高等级 + 第 {{ achievementData.days }} 天 + + + {{ achievementData.current?.name || '--' }} + + + + 距下一级「{{ achievementData.next.name }}」还需 {{ achievementData.next.required_days - achievementData.days }} 天 + 已达最高等级 @@ -385,25 +337,11 @@ - - - - 忍住 - - - + - - 记录一根 - 快速添加 - - - - @@ -440,187 +378,192 @@ 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 homeData = ref(null) -const pageReady = ref(false) -const achievementData = ref(null) -const quitHomeData = ref(null) -const quitState = ref(defaultQuitState()) -const checkinCelebrating = ref(false) +const dialogQuickMode = ref(false) +const quickSubmitting = ref(false) -let timerInterval = null +// ---- 数据 ---- +const homeData = ref(null) // 记录模式首页数据 +const quitHomeData = ref(null) // 戒烟模式首页数据 +const achievementData = ref(null) // 成就数据 +const quitState = ref(defaultQuitState()) // 本地缓存的打卡状态(网络失败时兜底) + +// ---- 打卡庆祝动画 ---- +const checkinCelebrating = ref(false) let checkinCelebrationTimer = null -const timerBaseSeconds = ref(-1) -const timerSeconds = ref(0) + +// ---- 记录模式计时器:距上次抽烟的秒数 ---- +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 !== undefined && target !== null) return target + if (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 == 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 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 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) - if (Number.isNaN(date.getTime())) return '' - return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}` + return Number.isNaN(date.getTime()) ? '' : formatTime(date) }) -// 戒烟模式计算 — 优先使用 V2 API 数据 -const quitSummary = computed(() => quitHomeData.value?.summary || {}) -const quitDailyStatus = computed(() => quitHomeData.value?.daily_status || {}) +// 今日进度环百分比(已抽/目标) +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 baselineCigsPerDay = computed(() => profileStore.profile?.baseline_cigs_per_day || 10) -const packPriceYuan = computed(() => (profileStore.profile?.pack_price_cent || 2500) / 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 (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())) - if (gap > 1) return 0 - return Number(quitState.value.streakDays || 0) + return gap > 1 ? 0 : Number(quitState.value.streakDays || 0) }) const todayChecked = computed(() => { - if (quitDailyStatus.value.status) { - return quitDailyStatus.value.status === 'checked_in' - } + if (quitDailyStatus.value.status) return quitDailyStatus.value.status === 'checked_in' return quitState.value.lastCheckinDate === formatDate(new Date()) }) const todayCheckinTime = computed(() => { - if (quitDailyStatus.value.checkin_at) { - return formatClock(quitDailyStatus.value.checkin_at) - } - return formatClock(quitState.value.lastCheckinAt) + 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) - } + 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(() => { - if (quitSummary.value.avoided_cigs !== undefined) { - return quitSummary.value.avoided_cigs - } - return quitDays.value * baselineCigsPerDay.value -}) - -const lifeSaved = computed(() => { - return Math.round(avoidedCigs.value * 11 / 60) -}) +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 - } - if (quitDays.value >= 365) return 100 - if (quitDays.value >= 180) return 85 - if (quitDays.value >= 90) return 70 - if (quitDays.value >= 30) return 55 - if (quitDays.value >= 14) return 35 - if (quitDays.value >= 7) return 20 - if (quitDays.value >= 3) return 10 - if (quitDays.value >= 1) return 5 - return 0 + 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 raw = quitSummary.value.hp_current ?? healthProgress.value - const value = Number(raw) - if (Number.isNaN(value)) return 0 - return Math.min(Math.max(Math.round(value), 0), 100) + 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(() => { - if (hpValue.value <= 20) { - return { - label: '危险期', - title: '先保住今天的底线', - description: '身体恢复动力偏弱,先把眼前这一波烟瘾稳住最重要。', - accent: '#64748b', - soft: '#e2e8f0', - deep: '#334155' - } - } - if (hpValue.value <= 40) { - return { - label: '恢复中', - title: '状态正在往回拉', - description: '已经开始脱离最低谷,再多守住几次关键时刻,HP 会明显回升。', - accent: '#0f766e', - soft: '#ccfbf1', - deep: '#115e59' - } - } - if (hpValue.value <= 60) { - return { - label: '稳定期', - title: '节奏开始站稳', - description: '你已经进入可持续恢复阶段,规律打卡会让状态越来越稳。', - accent: '#0891b2', - soft: '#cffafe', - deep: '#155e75' - } - } - if (hpValue.value <= 80) { - return { - label: '强韧期', - title: '恢复势头很不错', - description: '肺部状态正在加速恢复,继续把高风险场景提前处理掉。', - accent: '#16a34a', - soft: '#dcfce7', - deep: '#166534' - } - } - return { - label: '高能期', - title: '你已经进入高能状态', - description: '当前状态非常好,保持日常节奏和打卡,就能继续巩固恢复成果。', - accent: '#ea580c', - soft: '#ffedd5', - deep: '#9a3412' - } + 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, @@ -629,54 +572,44 @@ const hpVisualStyle = computed(() => ({ 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)` - } + return { background: `conic-gradient(var(--hp-accent) 0deg ${angle}deg, rgba(226, 232, 240, 0.88) ${angle}deg 360deg)` } }) const hpChangeText = computed(() => { - const serverDelta = Number(quitSummary.value.hp_change_today ?? quitSummary.value.hp_delta_today ?? quitSummary.value.hp_change) - if (!Number.isNaN(serverDelta) && serverDelta !== 0) { - return serverDelta > 0 ? `今日 +${serverDelta}` : `今日 ${serverDelta}` - } + 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 '今日已保住状态' - if (quitDays.value > 0) return '先打卡再继续回血' - return '从今天开始积累' + return quitDays.value > 0 ? '先打卡再继续回血' : '从今天开始积累' }) const healthTip = computed(() => { - if (quitDays.value >= 365) return '肺部功能显著改善,心血管疾病风险大幅降低' - if (quitDays.value >= 180) return '血液循环持续改善,肺功能逐步恢复' - if (quitDays.value >= 90) return '味觉嗅觉恢复,呼吸更加顺畅' - if (quitDays.value >= 30) return '咳嗽减少,体能开始恢复' - if (quitDays.value >= 14) return '尼古丁戒断症状明显减轻' - if (quitDays.value >= 7) return '一氧化碳水平恢复正常' - if (quitDays.value >= 3) return '呼吸开始变得顺畅' - if (quitDays.value >= 1) return '身体开始自我修复' + 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 (quitDays.value >= 30) return '太棒了!坚持一个月以上,你已经战胜了最难的部分' - if (quitDays.value >= 7) return '一周没抽了!身体正在快速恢复中' - if (quitDays.value >= 3) return '三天没抽了,最难的时期正在过去' + if (d >= 30) return '太棒了!坚持一个月以上,你已经战胜了最难的部分' + if (d >= 7) return '一周没抽了!身体正在快速恢复中' + if (d >= 3) return '三天没抽了,最难的时期正在过去' return '今天打卡成功,继续保持!' } - if (quitDays.value === 0) return '迈出第一步,从今天开始无烟生活' - if (quitDays.value < 3) return '坚持住,前三天是最关键的' - if (quitDays.value < 7) return '你已经走了很远,继续加油' - return `已坚持 ${quitDays.value} 天,你很了不起` + if (d === 0) return '迈出第一步,从今天开始无烟生活' + if (d < 3) return '坚持住,前三天是最关键的' + if (d < 7) return '你已经走了很远,继续加油' + return `已坚持 ${d} 天,你很了不起` }) -const healthMilestones = computed(() => [ - { days: 1, label: '1天' }, - { days: 7, label: '1周' }, - { days: 30, label: '1月' }, - { days: 90, label: '3月' }, - { days: 365, label: '1年' } -]) - +// 静态数据:肺泡动画气泡配置 const lungBubbleItems = [ { id: 1, left: '18rpx', delay: '0s', duration: '4.6s' }, { id: 2, left: '64rpx', delay: '1.2s', duration: '5.2s' }, @@ -684,23 +617,18 @@ const lungBubbleItems = [ { id: 4, left: '158rpx', delay: '1.8s', duration: '5.5s' } ] -const celebrationBursts = [ - { id: 1, rotate: 0 }, - { id: 2, rotate: 45 }, - { id: 3, rotate: 90 }, - { id: 4, rotate: 135 }, - { id: 5, rotate: 180 }, - { id: 6, rotate: 225 }, - { id: 7, rotate: 270 }, - { id: 8, rotate: 315 } -] +// 静态数据:打卡庆祝放射线(8 根均匀分布) +const celebrationBursts = Array.from({ length: 8 }, (_, i) => ({ id: i + 1, rotate: i * 45 })) -const nextHealthMilestone = computed(() => healthMilestones.value.find(item => quitDays.value < item.days) || null) +// 健康里程碑(天数节点) +const healthMilestones = computed(() => [ + { days: 1, label: '1天' }, { days: 7, label: '1周' }, + { days: 30, label: '1月' }, { days: 90, label: '3月' }, { days: 365, label: '1年' } +]) -const smokeFreeTimeLabel = computed(() => { - if (quitDays.value >= 1) return `${quitDays.value} 天` - return '刚开始' -}) +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)}%`, @@ -708,40 +636,36 @@ const lungRecoveryFillStyle = computed(() => ({ })) const lungPhaseLabel = computed(() => { - if (hpValue.value >= 100) return '肺部状态已进入长期巩固阶段' - if (hpValue.value >= 80) return '当前恢复速度很稳定' - if (hpValue.value >= 60) return '肺部恢复节奏正在变强' - if (hpValue.value >= 40) return '身体正在持续找回呼吸效率' - if (hpValue.value >= 20) return '恢复已经启动,别让节奏断掉' + 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(() => { - if (nextHealthMilestone.value) { - return `${healthTip.value},距离下一阶段还差 ${Math.max(nextHealthMilestone.value.days - quitDays.value, 0)} 天。` - } - return `${healthTip.value},已经进入长期巩固阶段。` -}) - -const nextHealthMilestoneText = computed(() => { - if (!nextHealthMilestone.value) return '已完成全年恢复' - return `${nextHealthMilestone.value.label} 里程碑` + const suffix = nextHealthMilestone.value + ? `,距离下一阶段还差 ${Math.max(nextHealthMilestone.value.days - quitDays.value, 0)} 天。` + : ',已经进入长期巩固阶段。' + return healthTip.value + suffix }) const lungMomentumText = computed(() => { - if (hpValue.value >= 100) return '稳定达成' - if (hpValue.value >= 70) return '恢复加速中' - if (hpValue.value >= 35) return '持续回升' + const hp = hpValue.value + if (hp >= 100) return '稳定达成' + if (hp >= 70) return '恢复加速中' + if (hp >= 35) return '持续回升' return '刚刚启动' }) const lungRecoveryCaption = computed(() => { - if (nextHealthMilestone.value) { - return `${Math.max(nextHealthMilestone.value.days - quitDays.value, 0)} 天后解锁 ${nextHealthMilestone.value.label}` - } - return '所有关键节点已完成' + 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 }, @@ -749,7 +673,7 @@ const healthMilestoneItems = computed(() => { { name: '尼古丁代谢完', minutes: 4320 }, { name: '味觉嗅觉恢复', minutes: 43200 }, { name: '血液循环改善', minutes: 129600 }, - { name: '肺功能提升', minutes: 525600 }, + { name: '肺功能提升', minutes: 525600 } ] const minutes = quitDays.value * 1440 return milestones.map(m => ({ @@ -766,86 +690,26 @@ const activeGoalText = computed(() => { return `「${goal.title}」还差 ¥${Math.round(remaining / 100)}` }) -const todayCountPercent = computed(() => { - if (!dailyTarget.value || dailyTarget.value <= 0) return todayCount.value > 0 ? 100 : 0 - const percent = Math.round((todayCount.value / dailyTarget.value) * 100) - return Math.min(Math.max(percent, 0), 100) -}) - -const todayCountRingStyle = computed(() => { - const angle = Math.round(todayCountPercent.value * 3.6) - return { - background: `conic-gradient(#3fcba2 0deg ${angle}deg, #d8eee6 ${angle}deg 360deg)` - } -}) - -// 记录模式健康数据计算 -const recordSavedMoney = computed(() => { - // 基于今日比目标少抽的数量计算 - const saved = Math.max(0, (dailyTarget.value - todayCount.value) * (packPriceYuan.value / 20)) - return saved.toFixed(1) -}) - -const recordLifeSaved = computed(() => { - // 每少抽一支烟延长约11分钟生命 - const minutes = Math.max(0, (dailyTarget.value - todayCount.value) * 11) - return Math.round(minutes / 60 * 10) / 10 -}) - -const totalResistedCount = computed(() => { - // 从首页数据获取累计忍住次数 - return homeSummary.value.total_resisted ?? resistedCount.value -}) - -const recordHasData = computed(() => { - return todayCount.value > 0 || - resistedCount.value > 0 || - totalResistedCount.value > 0 || - timerBaseSeconds.value >= 0 -}) - -const reducePercent = computed(() => { - if (dailyTarget.value <= 0) return 0 - const reduced = Math.max(0, dailyTarget.value - todayCount.value) - return Math.round((reduced / 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 '今天超标了,明天继续努力' -}) +// ========== 工具函数 ========== 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}` -} - +/** 两日期之间的天数差(有符号,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`) - return Math.floor((to.getTime() - from.getTime()) / (24 * 60 * 60 * 1000)) + 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) - if (Number.isNaN(date.getTime())) return '--:--' - return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}` + return Number.isNaN(date.getTime()) ? '--:--' : formatTime(date) } function setupNavBar() { @@ -854,11 +718,13 @@ function setupNavBar() { try { const menuBtn = uni.getMenuButtonBoundingClientRect() navBarHeight.value = menuBtn.bottom + (menuBtn.top - statusBarH) - } catch (e) { + } catch { navBarHeight.value = statusBarH + 44 } } +// ========== 本地状态持久化 ========== + function loadQuitState() { quitState.value = { ...defaultQuitState(), ...(storage.get(QUIT_CHECKIN_KEY) || {}) } } @@ -868,6 +734,8 @@ function saveQuitState(nextState) { storage.set(QUIT_CHECKIN_KEY, quitState.value) } +// ========== 计时器 ========== + function startTimer() { stopTimer() if (timerBaseSeconds.value < 0) return @@ -880,19 +748,15 @@ function stopTimer() { timerInterval = null } +// ========== 交互 ========== + function playQuitCheckinCelebration() { + // 先重置再触发,避免重复动画 checkinCelebrating.value = false - if (checkinCelebrationTimer) { - clearTimeout(checkinCelebrationTimer) - checkinCelebrationTimer = null - } + if (checkinCelebrationTimer) { clearTimeout(checkinCelebrationTimer); checkinCelebrationTimer = null } setTimeout(() => { checkinCelebrating.value = true - try { - uni.vibrateShort() - } catch (e) { - console.error('vibrateShort error:', e) - } + try { uni.vibrateShort() } catch { /* 部分平台不支持震动,忽略 */ } checkinCelebrationTimer = setTimeout(() => { checkinCelebrating.value = false checkinCelebrationTimer = null @@ -900,15 +764,25 @@ function playQuitCheckinCelebration() { }, 20) } -function openSmokeDialog() { - dialogType.value = 'smoke' - showDialog.value = true +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 + } } -function openResistedDialog() { - dialogType.value = 'resisted' - showDialog.value = true -} +// ========== API 调用 ========== function applyHomeData(data) { homeData.value = data @@ -920,79 +794,18 @@ function applyHomeData(data) { async function fetchRecordHomeData() { const res = await api.getHome() - const data = res.data || {} - applyHomeData(data) - return data + applyHomeData(res.data || {}) } -async function handleSubmit(submitData) { +async function fetchAchievement() { try { - if (dialogType.value === 'smoke') { - 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' }) - } - } else { - await api.createResistedLog({ - smoke_time: submitData.smoke_time, - smoke_at: submitData.smoke_at, - remark: submitData.remark, - reason_tags: submitData.reason_tags, - level: submitData.level, - num: submitData.num - }) - uni.showToast({ title: '已记下这次忍住', icon: 'success' }) - } - if (isQuitMode.value) { - await fetchAchievement() - return - } - await fetchRecordHomeData() + const res = await api.getAchievement() + achievementData.value = res.data?.achievement || null } catch (e) { - console.error('handleSubmit error:', e) - uni.showToast({ title: '保存失败', icon: 'none' }) + console.error('fetchAchievement error:', e) } } -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' }) - return - } 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' }) - } -} - -function gotoDreamGoals() { - uni.navigateTo({ url: '/pages/dream-goals/index' }) -} - function applyQuitHomeData(data) { if (!data) return quitHomeData.value = data @@ -1012,6 +825,7 @@ async function fetchQuitHomeData() { } catch (e) { const msg = e?.message || '' if (msg.includes('基础资料')) { + // 首次使用戒烟模式时后端要求先补填档案,自动补填后重试一次 await ensureQuitProfile() try { const res = await api.getQuitCheckinHome() @@ -1051,39 +865,82 @@ async function submitQuitCheckin() { 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 = profileData.profile - const isCompleted = profileData.is_completed || - (profile && profile.onboarding_completed_at) || - (profile && profile.baseline_cigs_per_day > 0) - if (!profileData.exists || !isCompleted) { + 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 fetchAchievement() { - try { - const res = await api.getAchievement() - achievementData.value = res.data?.achievement || null - } catch (e) { - console.error('fetchAchievement error:', e) - } -} - async function refreshCurrentMode() { if (!userStore.mode) return - const profileReady = await ensureProfileReady() - if (!profileReady) return - fetchAchievement() + if (!await ensureProfileReady()) return if (isQuitMode.value) { stopTimer() - await fetchQuitHomeData() + await Promise.all([fetchQuitHomeData(), fetchAchievement()]) return } - await fetchRecordHomeData() + await Promise.all([fetchRecordHomeData(), fetchAchievement()]) } async function initPage() { @@ -1093,7 +950,7 @@ async function initPage() { await waitForLogin() await profileStore.fetchProfile() if (!userStore.mode) { - uni.navigateTo({ url: '/pages/mode-select/index' }) + uni.reLaunch({ url: '/pages/mode-select/index' }) return } await refreshCurrentMode() @@ -1109,19 +966,12 @@ onMounted(() => { initPage() }) onShow(async () => { if (!pageReady.value) return - try { - await refreshCurrentMode() - } catch (e) { - console.error('home onShow error:', e) - } + try { await refreshCurrentMode() } catch (e) { console.error('home onShow error:', e) } }) onUnmounted(() => { stopTimer() - if (checkinCelebrationTimer) { - clearTimeout(checkinCelebrationTimer) - checkinCelebrationTimer = null - } + if (checkinCelebrationTimer) { clearTimeout(checkinCelebrationTimer); checkinCelebrationTimer = null } }) onShareAppMessage(() => ({ @@ -1130,12 +980,12 @@ onShareAppMessage(() => ({ })) -