diff --git a/src/pages/index/index.vue b/src/pages/index/index.vue index 3d687d2..07734e6 100644 --- a/src/pages/index/index.vue +++ b/src/pages/index/index.vue @@ -96,12 +96,59 @@ - 健康恢复里程碑 + 肺部恢复可视化 {{ healthProgress >= 100 ? '已达成' : `${healthProgress}%` }} + + + + + + + + + + + + + + + + + + + + 无烟 {{ smokeFreeTimeLabel }} + {{ lungPhaseLabel }} + {{ lungRecoverySummary }} + + + 下一节点 + {{ nextHealthMilestoneText }} + + + 恢复节奏 + {{ lungMomentumText }} + + + + + + 恢复节点 + {{ lungRecoveryCaption }} + - + @@ -155,7 +202,7 @@ 今日概览 - + {{ timerDisplay }} @@ -183,13 +230,46 @@ {{ nextSmokeTimeText ? `建议下次 ${nextSmokeTimeText}` : changeText }} - - - - 记录 + + + + + 0 + 今日记录 + - - 忍住 + + 今天还没有新记录 + 用悬浮按钮快速记下抽烟或忍住的瞬间,系统会开始生成节奏变化和健康反馈。 + + + + {{ dailyTarget }} + 今日目标 + + + {{ baselineCigsPerDay }} + 基础支数 + + + 立即开始 + 首条记录 + + + + + + + 快速动作 + 右下角悬浮按钮支持快速记录与忍住反馈 + + + + 记录 + + + 忍住 + @@ -279,6 +359,46 @@ @submit="handleSubmit" /> + + + + + 忍住 + + + + + + 记录一根 + 快速添加 + + + + + + + + + + + 今日打卡完成 + 已连续无烟 {{ quitDays }} 天,{{ lungPhaseLabel }} + + + 肺部恢复 + {{ healthProgress }}% + + + 已省下 + ¥{{ savedMoney }} + + + + @@ -304,8 +424,10 @@ const pageReady = ref(false) const achievementData = ref(null) const quitHomeData = ref(null) const quitState = ref(defaultQuitState()) +const checkinCelebrating = ref(false) let timerInterval = null +let checkinCelebrationTimer = null const timerBaseSeconds = ref(-1) const timerSeconds = ref(0) @@ -444,6 +566,69 @@ const healthMilestones = computed(() => [ { 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' }, + { id: 3, left: '116rpx', delay: '0.8s', duration: '4.9s' }, + { 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 } +] + +const nextHealthMilestone = computed(() => healthMilestones.value.find(item => quitDays.value < item.days) || null) + +const smokeFreeTimeLabel = computed(() => { + if (quitDays.value >= 1) return `${quitDays.value} 天` + return '刚开始' +}) + +const lungRecoveryFillStyle = computed(() => ({ + height: `${Math.max(healthProgress.value, 6)}%` +})) + +const lungPhaseLabel = computed(() => { + if (healthProgress.value >= 100) return '肺功能已接近长期恢复水平' + if (healthProgress.value >= 70) return '肺部纤毛清理能力持续增强' + if (healthProgress.value >= 40) return '呼吸效率进入稳定恢复期' + if (healthProgress.value >= 15) return '肺部正在排出残留刺激物' + return '身体已启动自我修复' +}) + +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 lungMomentumText = computed(() => { + if (healthProgress.value >= 100) return '稳定达成' + if (healthProgress.value >= 60) return '恢复加速中' + if (healthProgress.value >= 20) return '持续上升' + return '刚刚启动' +}) + +const lungRecoveryCaption = computed(() => { + if (nextHealthMilestone.value) { + return `${Math.max(nextHealthMilestone.value.days - quitDays.value, 0)} 天后解锁 ${nextHealthMilestone.value.label}` + } + return '所有关键节点已完成' +}) + const healthMilestoneItems = computed(() => { const milestones = [ { name: '血压心率恢复', minutes: 20 }, @@ -499,6 +684,13 @@ 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) @@ -575,6 +767,26 @@ function stopTimer() { timerInterval = null } +function playQuitCheckinCelebration() { + checkinCelebrating.value = false + if (checkinCelebrationTimer) { + clearTimeout(checkinCelebrationTimer) + checkinCelebrationTimer = null + } + setTimeout(() => { + checkinCelebrating.value = true + try { + uni.vibrateShort() + } catch (e) { + console.error('vibrateShort error:', e) + } + checkinCelebrationTimer = setTimeout(() => { + checkinCelebrating.value = false + checkinCelebrationTimer = null + }, 1800) + }, 20) +} + function openSmokeDialog() { dialogType.value = 'smoke' showDialog.value = true @@ -631,18 +843,26 @@ async function handleQuitCheckin() { return } try { - const today = formatDate(new Date()) - const res = await api.quitCheckin({ date: today }) - applyQuitHomeData(res.data) - saveQuitState({ - lastCheckinDate: today, - lastCheckinAt: new Date().toISOString(), - streakDays: res.data?.summary?.current_streak_days || 0 - }) + 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: '打卡失败,请重试', icon: 'none' }) + uni.showToast({ title: message || '打卡失败,请重试', icon: 'none' }) } } @@ -694,6 +914,20 @@ async function ensureQuitProfile() { }) } +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 ensureProfileReady() { const profileData = await profileStore.fetchProfile() const profile = profileData.profile @@ -759,7 +993,13 @@ onShow(async () => { } }) -onUnmounted(() => { stopTimer() }) +onUnmounted(() => { + stopTimer() + if (checkinCelebrationTimer) { + clearTimeout(checkinCelebrationTimer) + checkinCelebrationTimer = null + } +}) onShareAppMessage(() => ({ title: isQuitMode.value ? '我在坚持戒烟打卡' : '我在记录自己的抽烟变化', @@ -1882,4 +2122,601 @@ onShareAppMessage(() => ({ color: #14936d; font-weight: 600; } + +.content { + padding: 24rpx 24rpx 280rpx; +} + +.quit-health-subheader { + position: relative; + z-index: 1; + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12rpx; + margin: 22rpx 0 18rpx; +} + +.quit-health-subtitle { + font-size: 24rpx; + font-weight: 700; + color: #1f2937; +} + +.quit-health-subhint { + font-size: 20rpx; + color: #9ca3af; +} + +.quit-lung-visual { + position: relative; + z-index: 1; + display: flex; + gap: 22rpx; + padding: 24rpx 22rpx; + border-radius: 28rpx; + background: linear-gradient(135deg, rgba(238, 250, 245, 0.96) 0%, rgba(246, 251, 249, 0.92) 100%); + border: 1rpx solid rgba(20, 147, 109, 0.08); + box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.95); +} + +.quit-lung-stage { + position: relative; + width: 200rpx; + height: 232rpx; + flex-shrink: 0; +} + +.quit-lung-glow { + position: absolute; + left: 50%; + top: 50%; + width: 188rpx; + height: 188rpx; + border-radius: 50%; + transform: translate(-50%, -50%); + background: radial-gradient(circle, rgba(49, 193, 139, 0.22) 0%, rgba(49, 193, 139, 0.02) 70%, rgba(49, 193, 139, 0) 100%); + animation: celebrateGlow 3.6s ease-in-out infinite; +} + +.quit-lung-bubbles { + position: absolute; + inset: 0; + pointer-events: none; +} + +.quit-lung-bubble { + position: absolute; + bottom: 28rpx; + width: 14rpx; + height: 14rpx; + border-radius: 50%; + background: rgba(49, 193, 139, 0.34); + box-shadow: 0 0 12rpx rgba(49, 193, 139, 0.2); + animation: breathBubble 5s linear infinite; +} + +.quit-lung-figure { + position: absolute; + left: 50%; + top: 50%; + width: 176rpx; + height: 208rpx; + transform: translate(-50%, -50%); +} + +.quit-lung-trachea { + position: absolute; + left: 50%; + top: 0; + width: 24rpx; + height: 76rpx; + transform: translateX(-50%); + border-radius: 999rpx; + background: linear-gradient(180deg, #d8f3e6 0%, #93e0bf 100%); + box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.72); +} + +.quit-lung-branch { + position: absolute; + top: 62rpx; + width: 58rpx; + height: 10rpx; + border-radius: 999rpx; + background: linear-gradient(90deg, #aee9cf 0%, #7fd7b2 100%); +} + +.quit-lung-branch-left { + left: 38rpx; + transform: rotate(-24deg); + transform-origin: right center; +} + +.quit-lung-branch-right { + right: 38rpx; + transform: rotate(24deg); + transform-origin: left center; +} + +.quit-lung-lobe { + position: absolute; + bottom: 8rpx; + width: 78rpx; + height: 126rpx; + overflow: hidden; + background: linear-gradient(180deg, rgba(233, 248, 241, 0.96) 0%, rgba(215, 240, 229, 0.98) 100%); + border: 2rpx solid rgba(20, 147, 109, 0.08); + box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.88); +} + +.quit-lung-lobe-left { + left: 10rpx; + border-radius: 68rpx 52rpx 84rpx 70rpx; + transform: rotate(-6deg); +} + +.quit-lung-lobe-right { + right: 10rpx; + border-radius: 52rpx 68rpx 70rpx 84rpx; + transform: rotate(6deg); +} + +.quit-lung-lobe-fill { + position: absolute; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(180deg, rgba(110, 231, 190, 0.78) 0%, rgba(20, 147, 109, 0.92) 100%); + border-radius: inherit; + transition: height 0.7s ease; +} + +.quit-lung-copy { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; +} + +.quit-lung-kicker { + font-size: 21rpx; + font-weight: 600; + color: #6b7280; +} + +.quit-lung-phase { + display: block; + margin-top: 8rpx; + font-size: 30rpx; + line-height: 1.4; + font-weight: 800; + color: #0f766e; +} + +.quit-lung-desc { + display: block; + margin-top: 10rpx; + font-size: 22rpx; + line-height: 1.7; + color: #4b5563; +} + +.quit-lung-tag-row { + display: flex; + gap: 10rpx; + margin-top: 18rpx; +} + +.quit-lung-tag { + flex: 1; + min-width: 0; + padding: 12rpx 14rpx; + border-radius: 18rpx; + background: rgba(255, 255, 255, 0.68); + border: 1rpx solid rgba(15, 23, 42, 0.05); +} + +.quit-lung-tag-strong { + background: linear-gradient(180deg, rgba(229, 248, 240, 0.96) 0%, rgba(235, 249, 243, 0.96) 100%); +} + +.quit-lung-tag-label { + display: block; + font-size: 18rpx; + color: #9ca3af; +} + +.quit-lung-tag-value { + display: block; + margin-top: 6rpx; + font-size: 22rpx; + font-weight: 700; + color: #111827; +} + +.summary-card-record-empty::before { + background: radial-gradient(circle, rgba(52, 200, 160, 0.18) 0%, rgba(52, 200, 160, 0) 72%); +} + +.record-empty-state { + display: flex; + flex-direction: column; + align-items: center; + padding: 12rpx 0 0; +} + +.record-empty-visual { + position: relative; + width: 232rpx; + height: 232rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.record-empty-orbit { + position: absolute; + inset: 12rpx; + border-radius: 50%; + border: 2rpx dashed rgba(20, 147, 109, 0.22); + animation: recordFabFloat 8s linear infinite; +} + +.record-empty-core { + position: relative; + width: 156rpx; + height: 156rpx; + border-radius: 50%; + background: linear-gradient(180deg, #ffffff 0%, #f3faf6 100%); + border: 1rpx solid rgba(15, 23, 42, 0.05); + box-shadow: + 0 18rpx 36rpx rgba(15, 23, 42, 0.08), + inset 0 1rpx 0 rgba(255, 255, 255, 0.95); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.record-empty-core-text { + font-size: 52rpx; + line-height: 1; + font-weight: 800; + color: #14936d; + font-family: 'DIN Alternate', -apple-system, sans-serif; +} + +.record-empty-core-label { + margin-top: 10rpx; + font-size: 22rpx; + color: #6b7280; +} + +.record-empty-copy { + margin-top: 16rpx; + text-align: center; +} + +.record-empty-title { + display: block; + font-size: 30rpx; + font-weight: 800; + color: #111827; +} + +.record-empty-desc { + display: block; + margin-top: 10rpx; + font-size: 23rpx; + line-height: 1.7; + color: #6b7280; +} + +.record-empty-tag-row { + width: 100%; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12rpx; + margin-top: 22rpx; +} + +.record-empty-tag { + padding: 16rpx 12rpx; + border-radius: 20rpx; + background: linear-gradient(180deg, #fbfcfc 0%, #f7faf8 100%); + border: 1rpx solid rgba(15, 23, 42, 0.05); + text-align: center; +} + +.record-empty-tag-value { + display: block; + font-size: 24rpx; + font-weight: 800; + color: #111827; +} + +.record-empty-tag-label { + display: block; + margin-top: 6rpx; + font-size: 20rpx; + color: #9ca3af; +} + +.record-action-row-inline { + grid-template-columns: 1fr; + gap: 16rpx; + margin-top: 28rpx; +} + +.record-action-hint { + padding: 16rpx 18rpx; + border-radius: 18rpx; + background: rgba(248, 250, 249, 0.94); + border: 1rpx solid rgba(15, 23, 42, 0.05); +} + +.record-action-hint-title { + display: block; + font-size: 22rpx; + font-weight: 700; + color: #111827; +} + +.record-action-hint-text { + display: block; + margin-top: 6rpx; + font-size: 21rpx; + line-height: 1.6; + color: #6b7280; +} + +.record-action-pills { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20rpx; +} + +.record-fab-stack { + position: fixed; + right: 24rpx; + bottom: calc(138rpx + env(safe-area-inset-bottom)); + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 16rpx; + z-index: 30; +} + +.record-fab-secondary { + min-width: 128rpx; + height: 76rpx; + padding: 0 22rpx; + border-radius: 999rpx; + background: rgba(255, 255, 255, 0.92); + border: 1rpx solid rgba(15, 23, 42, 0.05); + box-shadow: 0 18rpx 34rpx rgba(15, 23, 42, 0.1); + display: flex; + align-items: center; + justify-content: center; + gap: 10rpx; + animation: recordFabFloat 3.8s ease-in-out infinite; +} + +.record-fab-secondary-icon { + font-size: 24rpx; + font-weight: 800; + color: #14936d; +} + +.record-fab-secondary-text { + font-size: 24rpx; + font-weight: 700; + color: #111827; +} + +.record-fab-primary { + min-width: 224rpx; + height: 96rpx; + padding: 0 24rpx; + border-radius: 999rpx; + background: linear-gradient(180deg, #31c18b 0%, #14936d 100%); + box-shadow: 0 22rpx 38rpx rgba(20, 147, 109, 0.3); + display: flex; + align-items: center; + gap: 14rpx; + animation: recordFabFloat 3.2s ease-in-out infinite; +} + +.record-fab-primary-icon { + width: 56rpx; + height: 56rpx; + border-radius: 50%; + background: rgba(255, 255, 255, 0.18); + display: flex; + align-items: center; + justify-content: center; + font-size: 36rpx; + line-height: 1; + font-weight: 500; + color: #ffffff; +} + +.record-fab-primary-copy { + display: flex; + flex-direction: column; +} + +.record-fab-primary-title { + font-size: 28rpx; + font-weight: 800; + color: #ffffff; + line-height: 1.2; +} + +.record-fab-primary-desc { + margin-top: 4rpx; + font-size: 20rpx; + color: rgba(255, 255, 255, 0.82); +} + +.checkin-celebration-mask { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.24); + display: flex; + align-items: center; + justify-content: center; + z-index: 40; + padding: 0 40rpx; +} + +.checkin-celebration-card { + position: relative; + width: 100%; + padding: 42rpx 34rpx 34rpx; + border-radius: 32rpx; + 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); + display: flex; + flex-direction: column; + 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: 999rpx; + background: linear-gradient(180deg, rgba(110, 231, 190, 0.2) 0%, rgba(20, 147, 109, 0.92) 100%); +} + +.checkin-celebration-icon { + position: relative; + z-index: 1; + width: 112rpx; + height: 112rpx; + border-radius: 50%; + background: linear-gradient(180deg, #31c18b 0%, #14936d 100%); + box-shadow: 0 18rpx 36rpx rgba(20, 147, 109, 0.3); + display: flex; + align-items: center; + justify-content: center; + font-size: 52rpx; + font-weight: 800; + color: #ffffff; + animation: pulseCheck 0.8s ease-out; +} + +.checkin-celebration-title { + display: block; + margin-top: 26rpx; + font-size: 34rpx; + font-weight: 800; + color: #111827; +} + +.checkin-celebration-desc { + display: block; + margin-top: 10rpx; + font-size: 24rpx; + line-height: 1.7; + color: #4b5563; + 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 16rpx; + border-radius: 20rpx; + background: rgba(255, 255, 255, 0.76); + border: 1rpx solid rgba(15, 23, 42, 0.05); + text-align: center; +} + +.checkin-celebration-chip-label { + display: block; + font-size: 20rpx; + color: #9ca3af; +} + +.checkin-celebration-chip-value { + display: block; + margin-top: 8rpx; + font-size: 28rpx; + font-weight: 800; + color: #14936d; +} + +@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); + } +} diff --git a/src/pages/logs/index.vue b/src/pages/logs/index.vue index 25960cb..4c179d9 100644 --- a/src/pages/logs/index.vue +++ b/src/pages/logs/index.vue @@ -90,9 +90,13 @@ - - 暂无记录 - 点击右下角按钮开始记录 + + + + + 今天还没有记录 + 点击右下角悬浮按钮,快速记录抽烟或忍住的时刻,时间线会从这里开始。 + 立即记录 @@ -107,6 +111,7 @@ + 记录 + @@ -640,28 +645,45 @@ onShareAppMessage(() => { } .empty-state { + position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; - padding: 120rpx 32rpx; + padding: 132rpx 32rpx 116rpx; border-radius: 32rpx; - background: #FFFFFF; - box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.03); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(247, 250, 248, 0.94) 100%); + box-shadow: 0 16rpx 38rpx rgba(15, 23, 42, 0.06); + overflow: hidden; } -.empty-icon { - width: 112rpx; - height: 112rpx; - border-radius: 36rpx; - background: #E8F5F0; +.empty-orbit { + position: absolute; + top: 52rpx; + width: 184rpx; + height: 184rpx; + border-radius: 50%; + border: 2rpx dashed rgba(16, 185, 129, 0.18); + animation: fabFloat 6s ease-in-out infinite; +} + +.empty-icon-wrap { + position: relative; + width: 126rpx; + height: 126rpx; + border-radius: 42rpx; + background: linear-gradient(180deg, #effaf5 0%, #e5f6ef 100%); display: flex; align-items: center; justify-content: center; + box-shadow: 0 16rpx 30rpx rgba(16, 185, 129, 0.14); + margin-bottom: 26rpx; +} + +.empty-icon { font-size: 40rpx; font-weight: 700; color: #10B981; - margin-bottom: 24rpx; } .empty-text { @@ -673,7 +695,19 @@ onShareAppMessage(() => { .empty-hint { font-size: 24rpx; - color: #999999; + line-height: 1.7; + color: #7c8b85; + text-align: center; +} + +.empty-action { + margin-top: 24rpx; + padding: 16rpx 28rpx; + border-radius: 999rpx; + background: rgba(16, 185, 129, 0.1); + color: #0f9a6d; + font-size: 24rpx; + font-weight: 700; } .loading-more, .no-more { @@ -690,29 +724,43 @@ onShareAppMessage(() => { position: fixed; right: 32rpx; bottom: 140rpx; - width: 96rpx; + min-width: 170rpx; height: 96rpx; - background: #10B981; - border-radius: 50%; + padding: 0 24rpx; + background: linear-gradient(180deg, #16C38B 0%, #10B981 100%); + border-radius: 999rpx; display: flex; align-items: center; justify-content: center; - box-shadow: 0 8rpx 24rpx rgba(16, 185, 129, 0.25); + gap: 12rpx; + box-shadow: 0 18rpx 36rpx rgba(16, 185, 129, 0.3); transition: all 0.3s; z-index: 100; + animation: fabFloat 3.6s ease-in-out infinite; } .fab:active { transform: scale(0.95); } -.fab-icon { - font-size: 48rpx; +.fab-label { + font-size: 24rpx; color: #FFFFFF; - font-weight: 300; + font-weight: 700; +} + +.fab-icon { + font-size: 42rpx; + color: #FFFFFF; + font-weight: 400; } .bottom-safe { height: calc(32rpx + env(safe-area-inset-bottom)); } + +@keyframes fabFloat { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-6rpx); } +} diff --git a/src/pages/nsti/result.vue b/src/pages/nsti/result.vue index 6af1a08..5c90eff 100644 --- a/src/pages/nsti/result.vue +++ b/src/pages/nsti/result.vue @@ -246,7 +246,52 @@ function roundRect(ctx, x, y, width, height, radius, fillStyle) { ctx.restore() } -function drawPoster(resultData, logoPath, qrPath) { +function clipRoundRect(ctx, x, y, width, height, radius) { + ctx.beginPath() + ctx.moveTo(x + radius, y) + ctx.lineTo(x + width - radius, y) + ctx.quadraticCurveTo(x + width, y, x + width, y + radius) + ctx.lineTo(x + width, y + height - radius) + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height) + ctx.lineTo(x + radius, y + height) + ctx.quadraticCurveTo(x, y + height, x, y + height - radius) + ctx.lineTo(x, y + radius) + ctx.quadraticCurveTo(x, y, x + radius, y) + ctx.closePath() + ctx.clip() +} + +function getImageInfo(src) { + return new Promise((resolve, reject) => { + if (!src) { + resolve(null) + return + } + uni.getImageInfo({ + src, + success: resolve, + fail: reject + }) + }) +} + +function drawContainImage(ctx, imagePath, imageInfo, x, y, width, height, radius = 0) { + if (!imagePath || !imageInfo?.width || !imageInfo?.height) return + const scale = Math.min(width / imageInfo.width, height / imageInfo.height) + const drawWidth = imageInfo.width * scale + const drawHeight = imageInfo.height * scale + const drawX = x + (width - drawWidth) / 2 + const drawY = y + (height - drawHeight) / 2 + + ctx.save() + if (radius > 0) { + clipRoundRect(ctx, x, y, width, height, radius) + } + ctx.drawImage(imagePath, drawX, drawY, drawWidth, drawHeight) + ctx.restore() +} + +function drawPoster(resultData, logoPath, qrPath, logoInfo) { return new Promise((resolve, reject) => { const canvasId = 'nstiPosterCanvas' const ctx = uni.createCanvasContext(canvasId, proxy) @@ -264,9 +309,6 @@ function drawPoster(resultData, logoPath, qrPath) { ctx.setFillStyle('#F6F3EC') ctx.fillRect(0, 0, width, height) - ctx.setFillStyle('#131A2C') - ctx.fillRect(0, 0, width, 342) - roundRect(ctx, cardX, cardY, cardWidth, cardHeight, 36, '#FFFDF9') roundRect(ctx, heroX, heroY, heroWidth, heroHeight, 30, resultData.color || '#54D2B1') @@ -276,7 +318,8 @@ function drawPoster(resultData, logoPath, qrPath) { roundRect(ctx, 88, 146, 574, 232, 24, 'rgba(255,255,255,0.14)') if (logoPath) { - ctx.drawImage(logoPath, 104, 162, 542, 200) + roundRect(ctx, 104, 162, 542, 200, 18, 'rgba(255,255,255,0.18)') + drawContainImage(ctx, logoPath, logoInfo, 104, 162, 542, 200, 18) } ctx.setFillStyle('#FFFFFF') @@ -356,22 +399,25 @@ function drawPoster(resultData, logoPath, qrPath) { ctx.fillText('本测试仅供娱乐与行为洞察,不构成医疗建议。', 88, cardY + cardHeight - 42) ctx.draw(false, () => { - uni.canvasToTempFilePath( - { - canvasId, - x: 0, - y: 0, - width, - height, - destWidth: width * 2, - destHeight: height * 2, - fileType: 'png', - quality: 1, - success: (res) => resolve(res.tempFilePath), - fail: (err) => reject(err) - }, - proxy - ) + // Real devices can export a partially rendered canvas if we read it immediately. + setTimeout(() => { + uni.canvasToTempFilePath( + { + canvasId, + x: 0, + y: 0, + width, + height, + destWidth: width, + destHeight: height, + fileType: 'png', + quality: 1, + success: (res) => resolve(res.tempFilePath), + fail: (err) => reject(err) + }, + proxy + ) + }, 180) }) }) } @@ -415,7 +461,8 @@ async function handleSavePoster() { downloadImage(result.value.logoUrl), downloadMiniProgramTestCode({ path: 'pages/nsti/test?resume=0', width: 280 }) ]) - const posterPath = await drawPoster(result.value, logoPath, qrPath) + const logoInfo = await getImageInfo(logoPath) + const posterPath = await drawPoster(result.value, logoPath, qrPath, logoInfo) await savePosterToAlbum(posterPath) uni.showToast({ title: '已保存到相册', icon: 'success' }) } catch (error) { @@ -470,8 +517,8 @@ onShareTimeline(() => ({ position: fixed; left: -9999px; top: -9999px; - width: 750rpx; - height: 1660rpx; + width: 750px; + height: 1660px; opacity: 0; pointer-events: none; } diff --git a/src/pages/stats/index.vue b/src/pages/stats/index.vue index fe5c04a..13b42b4 100644 --- a/src/pages/stats/index.vue +++ b/src/pages/stats/index.vue @@ -55,7 +55,13 @@ - 暂无趋势数据 + + 7 + + + 暂无趋势数据 + 完成今天的记录后,这里会开始展示最近 7 天的波动节奏。 + @@ -80,7 +86,13 @@ - 完善基础信息后解锁节省金额 + + ¥ + + + 节省金额待解锁 + 完善基础信息后,系统会自动换算每少抽一支烟省下的金额。 + @@ -118,7 +130,13 @@ - 暂无健康数据,记录一次后解锁 + + + + + 暂无健康数据 + 完成一次记录后,这里会开始生成无烟时长和恢复节点。 + @@ -790,12 +808,12 @@ onShareAppMessage(() => { /* ── 空状态 ── */ .empty-block { - padding: 32rpx; - border-radius: 16rpx; - background: rgba(52, 200, 160, 0.04); + padding: 28rpx 24rpx; + border-radius: 20rpx; + background: rgba(52, 200, 160, 0.05); display: flex; align-items: center; - justify-content: center; + gap: 18rpx; } .empty-block-dashed { @@ -803,8 +821,51 @@ onShareAppMessage(() => { background: transparent; } +.empty-visual { + width: 78rpx; + height: 78rpx; + border-radius: 24rpx; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.8); +} + +.empty-visual-trend { + background: linear-gradient(180deg, rgba(52, 200, 160, 0.16) 0%, rgba(52, 200, 160, 0.08) 100%); +} + +.empty-visual-money { + background: linear-gradient(180deg, rgba(251, 191, 36, 0.18) 0%, rgba(245, 158, 11, 0.08) 100%); +} + +.empty-visual-health { + background: linear-gradient(180deg, rgba(52, 200, 160, 0.18) 0%, rgba(59, 130, 246, 0.08) 100%); +} + +.empty-visual-glyph { + font-size: 28rpx; + font-weight: 800; + color: #0D3D2E; +} + +.empty-copy { + flex: 1; + min-width: 0; +} + +.empty-title { + display: block; + font-size: 24rpx; + font-weight: 700; + color: #0D3D2E; + margin-bottom: 6rpx; +} + .empty-text { font-size: 24rpx; + line-height: 1.6; color: #7aA898; }