feat(ui): Phase 1 UI/动画优化实现

- 肺部健康可视化组件(SVG肺部+呼吸动画+气泡效果)
- 打卡多巴胺反馈动画(成功弹窗+震动反馈)
- 悬浮记录按钮(右下角FAB+呼吸动画)
- 空状态优化(演示数据预览+引导文案)
- 记录模式空状态视觉升级
- 统计页面空状态统一

参考: docs/PRD-UI-Animation-Optimization.md
This commit is contained in:
nepiedg
2026-04-13 14:00:36 +08:00
parent ec87a9fc55
commit 7ef21ad39d
4 changed files with 1063 additions and 70 deletions
+856 -19
View File
@@ -96,12 +96,59 @@
<view class="quit-health-card">
<view class="quit-health-header">
<view>
<text class="quit-health-title">健康恢复里程碑</text>
<text class="quit-health-title">肺部恢复可视化</text>
</view>
<view class="quit-health-badge" :class="{ 'quit-health-badge-done': healthProgress >= 100 }">
{{ healthProgress >= 100 ? '已达成' : `${healthProgress}%` }}
</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"
@@ -143,7 +190,7 @@
<!-- 记录模式 -->
<view v-else>
<!-- 记录卡片 -->
<view class="summary-card summary-card-record">
<view class="summary-card summary-card-record" :class="{ 'summary-card-record-empty': !recordHasData }">
<view class="summary-card-top summary-card-top-record">
<view class="summary-heading">
<view class="summary-title-accent"></view>
@@ -155,7 +202,7 @@
<text class="record-header-chip">今日概览</text>
</view>
<view class="record-overview">
<view v-if="recordHasData" class="record-overview">
<view class="record-ring" :style="todayCountRingStyle">
<view class="record-ring-inner">
<text class="record-ring-value">{{ timerDisplay }}</text>
@@ -183,13 +230,46 @@
<text class="record-group-note">{{ nextSmokeTimeText ? `建议下次 ${nextSmokeTimeText}` : changeText }}</text>
</view>
</view>
<view class="record-action-row">
<view class="primary-pill primary-pill-smoke" @tap="openSmokeDialog">
<text class="primary-pill-title">记录</text>
<view v-else class="record-empty-state">
<view class="record-empty-visual">
<view class="record-empty-orbit"></view>
<view class="record-empty-core">
<text class="record-empty-core-text">0</text>
<text class="record-empty-core-label">今日记录</text>
</view>
</view>
<view class="primary-pill primary-pill-resist" @tap="openResistedDialog">
<text class="primary-pill-title">忍住</text>
<view class="record-empty-copy">
<text class="record-empty-title">今天还没有新记录</text>
<text class="record-empty-desc">用悬浮按钮快速记下抽烟或忍住的瞬间系统会开始生成节奏变化和健康反馈</text>
</view>
<view class="record-empty-tag-row">
<view class="record-empty-tag">
<text class="record-empty-tag-value">{{ dailyTarget }}</text>
<text class="record-empty-tag-label">今日目标</text>
</view>
<view class="record-empty-tag">
<text class="record-empty-tag-value">{{ baselineCigsPerDay }}</text>
<text class="record-empty-tag-label">基础支数</text>
</view>
<view class="record-empty-tag">
<text class="record-empty-tag-value">立即开始</text>
<text class="record-empty-tag-label">首条记录</text>
</view>
</view>
</view>
<view class="record-action-row record-action-row-inline">
<view class="record-action-hint">
<text class="record-action-hint-title">快速动作</text>
<text class="record-action-hint-text">右下角悬浮按钮支持快速记录与忍住反馈</text>
</view>
<view class="record-action-pills">
<view class="primary-pill primary-pill-smoke" @tap="openSmokeDialog">
<text class="primary-pill-title">记录</text>
</view>
<view class="primary-pill primary-pill-resist" @tap="openResistedDialog">
<text class="primary-pill-title">忍住</text>
</view>
</view>
</view>
</view>
@@ -279,6 +359,46 @@
@submit="handleSubmit"
/>
</view>
<view v-if="!loading && !isQuitMode" class="record-fab-stack">
<view class="record-fab-secondary" @tap="openResistedDialog">
<text class="record-fab-secondary-icon"></text>
<text class="record-fab-secondary-text">忍住</text>
</view>
<view class="record-fab-primary" @tap="openSmokeDialog">
<view class="record-fab-primary-icon">+</view>
<view class="record-fab-primary-copy">
<text class="record-fab-primary-title">记录一根</text>
<text class="record-fab-primary-desc">快速添加</text>
</view>
</view>
</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">肺部恢复</text>
<text class="checkin-celebration-chip-value">{{ healthProgress }}%</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>
@@ -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);
}
}
</style>