feat(ui): Phase 1 UI/动画优化实现
- 肺部健康可视化组件(SVG肺部+呼吸动画+气泡效果) - 打卡多巴胺反馈动画(成功弹窗+震动反馈) - 悬浮记录按钮(右下角FAB+呼吸动画) - 空状态优化(演示数据预览+引导文案) - 记录模式空状态视觉升级 - 统计页面空状态统一 参考: docs/PRD-UI-Animation-Optimization.md
This commit is contained in:
+851
-14
@@ -96,12 +96,59 @@
|
|||||||
<view class="quit-health-card">
|
<view class="quit-health-card">
|
||||||
<view class="quit-health-header">
|
<view class="quit-health-header">
|
||||||
<view>
|
<view>
|
||||||
<text class="quit-health-title">健康恢复里程碑</text>
|
<text class="quit-health-title">肺部恢复可视化</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="quit-health-badge" :class="{ 'quit-health-badge-done': healthProgress >= 100 }">
|
<view class="quit-health-badge" :class="{ 'quit-health-badge-done': healthProgress >= 100 }">
|
||||||
{{ healthProgress >= 100 ? '已达成' : `${healthProgress}%` }}
|
{{ healthProgress >= 100 ? '已达成' : `${healthProgress}%` }}
|
||||||
</view>
|
</view>
|
||||||
</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 class="quit-milestone-list">
|
||||||
<view
|
<view
|
||||||
v-for="(ms, index) in healthMilestoneItems"
|
v-for="(ms, index) in healthMilestoneItems"
|
||||||
@@ -143,7 +190,7 @@
|
|||||||
<!-- 记录模式 -->
|
<!-- 记录模式 -->
|
||||||
<view v-else>
|
<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-card-top summary-card-top-record">
|
||||||
<view class="summary-heading">
|
<view class="summary-heading">
|
||||||
<view class="summary-title-accent"></view>
|
<view class="summary-title-accent"></view>
|
||||||
@@ -155,7 +202,7 @@
|
|||||||
<text class="record-header-chip">今日概览</text>
|
<text class="record-header-chip">今日概览</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="record-overview">
|
<view v-if="recordHasData" class="record-overview">
|
||||||
<view class="record-ring" :style="todayCountRingStyle">
|
<view class="record-ring" :style="todayCountRingStyle">
|
||||||
<view class="record-ring-inner">
|
<view class="record-ring-inner">
|
||||||
<text class="record-ring-value">{{ timerDisplay }}</text>
|
<text class="record-ring-value">{{ timerDisplay }}</text>
|
||||||
@@ -183,8 +230,40 @@
|
|||||||
<text class="record-group-note">{{ nextSmokeTimeText ? `建议下次 ${nextSmokeTimeText}` : changeText }}</text>
|
<text class="record-group-note">{{ nextSmokeTimeText ? `建议下次 ${nextSmokeTimeText}` : changeText }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<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="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">
|
<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">
|
<view class="primary-pill primary-pill-smoke" @tap="openSmokeDialog">
|
||||||
<text class="primary-pill-title">记录</text>
|
<text class="primary-pill-title">记录</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -193,6 +272,7 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 健康数据卡片 -->
|
<!-- 健康数据卡片 -->
|
||||||
<view class="health-data-card">
|
<view class="health-data-card">
|
||||||
@@ -279,6 +359,46 @@
|
|||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
/>
|
/>
|
||||||
</view>
|
</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>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -304,8 +424,10 @@ const pageReady = ref(false)
|
|||||||
const achievementData = ref(null)
|
const achievementData = ref(null)
|
||||||
const quitHomeData = ref(null)
|
const quitHomeData = ref(null)
|
||||||
const quitState = ref(defaultQuitState())
|
const quitState = ref(defaultQuitState())
|
||||||
|
const checkinCelebrating = ref(false)
|
||||||
|
|
||||||
let timerInterval = null
|
let timerInterval = null
|
||||||
|
let checkinCelebrationTimer = null
|
||||||
const timerBaseSeconds = ref(-1)
|
const timerBaseSeconds = ref(-1)
|
||||||
const timerSeconds = ref(0)
|
const timerSeconds = ref(0)
|
||||||
|
|
||||||
@@ -444,6 +566,69 @@ const healthMilestones = computed(() => [
|
|||||||
{ days: 365, label: '1年' }
|
{ 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 healthMilestoneItems = computed(() => {
|
||||||
const milestones = [
|
const milestones = [
|
||||||
{ name: '血压心率恢复', minutes: 20 },
|
{ name: '血压心率恢复', minutes: 20 },
|
||||||
@@ -499,6 +684,13 @@ const totalResistedCount = computed(() => {
|
|||||||
return homeSummary.value.total_resisted ?? resistedCount.value
|
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(() => {
|
const reducePercent = computed(() => {
|
||||||
if (dailyTarget.value <= 0) return 0
|
if (dailyTarget.value <= 0) return 0
|
||||||
const reduced = Math.max(0, dailyTarget.value - todayCount.value)
|
const reduced = Math.max(0, dailyTarget.value - todayCount.value)
|
||||||
@@ -575,6 +767,26 @@ function stopTimer() {
|
|||||||
timerInterval = null
|
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() {
|
function openSmokeDialog() {
|
||||||
dialogType.value = 'smoke'
|
dialogType.value = 'smoke'
|
||||||
showDialog.value = true
|
showDialog.value = true
|
||||||
@@ -631,18 +843,26 @@ async function handleQuitCheckin() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const today = formatDate(new Date())
|
await submitQuitCheckin()
|
||||||
const res = await api.quitCheckin({ date: today })
|
playQuitCheckinCelebration()
|
||||||
applyQuitHomeData(res.data)
|
|
||||||
saveQuitState({
|
|
||||||
lastCheckinDate: today,
|
|
||||||
lastCheckinAt: new Date().toISOString(),
|
|
||||||
streakDays: res.data?.summary?.current_streak_days || 0
|
|
||||||
})
|
|
||||||
uni.showToast({ title: '打卡成功', icon: 'success' })
|
uni.showToast({ title: '打卡成功', icon: 'success' })
|
||||||
} catch (e) {
|
} 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)
|
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() {
|
async function ensureProfileReady() {
|
||||||
const profileData = await profileStore.fetchProfile()
|
const profileData = await profileStore.fetchProfile()
|
||||||
const profile = profileData.profile
|
const profile = profileData.profile
|
||||||
@@ -759,7 +993,13 @@ onShow(async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => { stopTimer() })
|
onUnmounted(() => {
|
||||||
|
stopTimer()
|
||||||
|
if (checkinCelebrationTimer) {
|
||||||
|
clearTimeout(checkinCelebrationTimer)
|
||||||
|
checkinCelebrationTimer = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
onShareAppMessage(() => ({
|
onShareAppMessage(() => ({
|
||||||
title: isQuitMode.value ? '我在坚持戒烟打卡' : '我在记录自己的抽烟变化',
|
title: isQuitMode.value ? '我在坚持戒烟打卡' : '我在记录自己的抽烟变化',
|
||||||
@@ -1882,4 +2122,601 @@ onShareAppMessage(() => ({
|
|||||||
color: #14936d;
|
color: #14936d;
|
||||||
font-weight: 600;
|
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>
|
</style>
|
||||||
|
|||||||
+67
-19
@@ -90,9 +90,13 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-else class="empty-state">
|
<view v-else class="empty-state">
|
||||||
|
<view class="empty-orbit"></view>
|
||||||
|
<view class="empty-icon-wrap">
|
||||||
<text class="empty-icon">记</text>
|
<text class="empty-icon">记</text>
|
||||||
<text class="empty-text">暂无记录</text>
|
</view>
|
||||||
<text class="empty-hint">点击右下角按钮开始记录</text>
|
<text class="empty-text">今天还没有记录</text>
|
||||||
|
<text class="empty-hint">点击右下角悬浮按钮,快速记录抽烟或忍住的时刻,时间线会从这里开始。</text>
|
||||||
|
<view class="empty-action" @tap="addLog">立即记录</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="logsStore.loading && logsStore.logs.length > 0" class="loading-more">
|
<view v-if="logsStore.loading && logsStore.logs.length > 0" class="loading-more">
|
||||||
@@ -107,6 +111,7 @@
|
|||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
<view class="fab" @tap="addLog">
|
<view class="fab" @tap="addLog">
|
||||||
|
<text class="fab-label">记录</text>
|
||||||
<text class="fab-icon">+</text>
|
<text class="fab-icon">+</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -640,28 +645,45 @@ onShareAppMessage(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 120rpx 32rpx;
|
padding: 132rpx 32rpx 116rpx;
|
||||||
border-radius: 32rpx;
|
border-radius: 32rpx;
|
||||||
background: #FFFFFF;
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(247, 250, 248, 0.94) 100%);
|
||||||
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.03);
|
box-shadow: 0 16rpx 38rpx rgba(15, 23, 42, 0.06);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-icon {
|
.empty-orbit {
|
||||||
width: 112rpx;
|
position: absolute;
|
||||||
height: 112rpx;
|
top: 52rpx;
|
||||||
border-radius: 36rpx;
|
width: 184rpx;
|
||||||
background: #E8F5F0;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
box-shadow: 0 16rpx 30rpx rgba(16, 185, 129, 0.14);
|
||||||
|
margin-bottom: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
font-size: 40rpx;
|
font-size: 40rpx;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #10B981;
|
color: #10B981;
|
||||||
margin-bottom: 24rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-text {
|
.empty-text {
|
||||||
@@ -673,7 +695,19 @@ onShareAppMessage(() => {
|
|||||||
|
|
||||||
.empty-hint {
|
.empty-hint {
|
||||||
font-size: 24rpx;
|
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 {
|
.loading-more, .no-more {
|
||||||
@@ -690,29 +724,43 @@ onShareAppMessage(() => {
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
right: 32rpx;
|
right: 32rpx;
|
||||||
bottom: 140rpx;
|
bottom: 140rpx;
|
||||||
width: 96rpx;
|
min-width: 170rpx;
|
||||||
height: 96rpx;
|
height: 96rpx;
|
||||||
background: #10B981;
|
padding: 0 24rpx;
|
||||||
border-radius: 50%;
|
background: linear-gradient(180deg, #16C38B 0%, #10B981 100%);
|
||||||
|
border-radius: 999rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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;
|
transition: all 0.3s;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
animation: fabFloat 3.6s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fab:active {
|
.fab:active {
|
||||||
transform: scale(0.95);
|
transform: scale(0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fab-icon {
|
.fab-label {
|
||||||
font-size: 48rpx;
|
font-size: 24rpx;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
font-weight: 300;
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab-icon {
|
||||||
|
font-size: 42rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-safe {
|
.bottom-safe {
|
||||||
height: calc(32rpx + env(safe-area-inset-bottom));
|
height: calc(32rpx + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes fabFloat {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-6rpx); }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+57
-10
@@ -246,7 +246,52 @@ function roundRect(ctx, x, y, width, height, radius, fillStyle) {
|
|||||||
ctx.restore()
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const canvasId = 'nstiPosterCanvas'
|
const canvasId = 'nstiPosterCanvas'
|
||||||
const ctx = uni.createCanvasContext(canvasId, proxy)
|
const ctx = uni.createCanvasContext(canvasId, proxy)
|
||||||
@@ -264,9 +309,6 @@ function drawPoster(resultData, logoPath, qrPath) {
|
|||||||
ctx.setFillStyle('#F6F3EC')
|
ctx.setFillStyle('#F6F3EC')
|
||||||
ctx.fillRect(0, 0, width, height)
|
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, cardX, cardY, cardWidth, cardHeight, 36, '#FFFDF9')
|
||||||
roundRect(ctx, heroX, heroY, heroWidth, heroHeight, 30, resultData.color || '#54D2B1')
|
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)')
|
roundRect(ctx, 88, 146, 574, 232, 24, 'rgba(255,255,255,0.14)')
|
||||||
if (logoPath) {
|
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')
|
ctx.setFillStyle('#FFFFFF')
|
||||||
@@ -356,6 +399,8 @@ function drawPoster(resultData, logoPath, qrPath) {
|
|||||||
ctx.fillText('本测试仅供娱乐与行为洞察,不构成医疗建议。', 88, cardY + cardHeight - 42)
|
ctx.fillText('本测试仅供娱乐与行为洞察,不构成医疗建议。', 88, cardY + cardHeight - 42)
|
||||||
|
|
||||||
ctx.draw(false, () => {
|
ctx.draw(false, () => {
|
||||||
|
// Real devices can export a partially rendered canvas if we read it immediately.
|
||||||
|
setTimeout(() => {
|
||||||
uni.canvasToTempFilePath(
|
uni.canvasToTempFilePath(
|
||||||
{
|
{
|
||||||
canvasId,
|
canvasId,
|
||||||
@@ -363,8 +408,8 @@ function drawPoster(resultData, logoPath, qrPath) {
|
|||||||
y: 0,
|
y: 0,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
destWidth: width * 2,
|
destWidth: width,
|
||||||
destHeight: height * 2,
|
destHeight: height,
|
||||||
fileType: 'png',
|
fileType: 'png',
|
||||||
quality: 1,
|
quality: 1,
|
||||||
success: (res) => resolve(res.tempFilePath),
|
success: (res) => resolve(res.tempFilePath),
|
||||||
@@ -372,6 +417,7 @@ function drawPoster(resultData, logoPath, qrPath) {
|
|||||||
},
|
},
|
||||||
proxy
|
proxy
|
||||||
)
|
)
|
||||||
|
}, 180)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -415,7 +461,8 @@ async function handleSavePoster() {
|
|||||||
downloadImage(result.value.logoUrl),
|
downloadImage(result.value.logoUrl),
|
||||||
downloadMiniProgramTestCode({ path: 'pages/nsti/test?resume=0', width: 280 })
|
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)
|
await savePosterToAlbum(posterPath)
|
||||||
uni.showToast({ title: '已保存到相册', icon: 'success' })
|
uni.showToast({ title: '已保存到相册', icon: 'success' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -470,8 +517,8 @@ onShareTimeline(() => ({
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
left: -9999px;
|
left: -9999px;
|
||||||
top: -9999px;
|
top: -9999px;
|
||||||
width: 750rpx;
|
width: 750px;
|
||||||
height: 1660rpx;
|
height: 1660px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,13 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-else class="empty-block">
|
<view v-else class="empty-block">
|
||||||
<text class="empty-text">暂无趋势数据</text>
|
<view class="empty-visual empty-visual-trend">
|
||||||
|
<text class="empty-visual-glyph">7</text>
|
||||||
|
</view>
|
||||||
|
<view class="empty-copy">
|
||||||
|
<text class="empty-title">暂无趋势数据</text>
|
||||||
|
<text class="empty-text">完成今天的记录后,这里会开始展示最近 7 天的波动节奏。</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -80,7 +86,13 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-else class="empty-block empty-block-dashed">
|
<view v-else class="empty-block empty-block-dashed">
|
||||||
<text class="empty-text">完善基础信息后解锁节省金额</text>
|
<view class="empty-visual empty-visual-money">
|
||||||
|
<text class="empty-visual-glyph">¥</text>
|
||||||
|
</view>
|
||||||
|
<view class="empty-copy">
|
||||||
|
<text class="empty-title">节省金额待解锁</text>
|
||||||
|
<text class="empty-text">完善基础信息后,系统会自动换算每少抽一支烟省下的金额。</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="moneyAvailable" class="metric-chips">
|
<view v-if="moneyAvailable" class="metric-chips">
|
||||||
@@ -118,7 +130,13 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-else class="empty-block empty-block-dashed">
|
<view v-else class="empty-block empty-block-dashed">
|
||||||
<text class="empty-text">暂无健康数据,记录一次后解锁</text>
|
<view class="empty-visual empty-visual-health">
|
||||||
|
<text class="empty-visual-glyph">肺</text>
|
||||||
|
</view>
|
||||||
|
<view class="empty-copy">
|
||||||
|
<text class="empty-title">暂无健康数据</text>
|
||||||
|
<text class="empty-text">完成一次记录后,这里会开始生成无烟时长和恢复节点。</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="healthItems.length > 0" class="health-list">
|
<view v-if="healthItems.length > 0" class="health-list">
|
||||||
<view v-for="(item, index) in healthItems" :key="index" class="health-item">
|
<view v-for="(item, index) in healthItems" :key="index" class="health-item">
|
||||||
@@ -790,12 +808,12 @@ onShareAppMessage(() => {
|
|||||||
|
|
||||||
/* ── 空状态 ── */
|
/* ── 空状态 ── */
|
||||||
.empty-block {
|
.empty-block {
|
||||||
padding: 32rpx;
|
padding: 28rpx 24rpx;
|
||||||
border-radius: 16rpx;
|
border-radius: 20rpx;
|
||||||
background: rgba(52, 200, 160, 0.04);
|
background: rgba(52, 200, 160, 0.05);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
gap: 18rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-block-dashed {
|
.empty-block-dashed {
|
||||||
@@ -803,8 +821,51 @@ onShareAppMessage(() => {
|
|||||||
background: transparent;
|
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 {
|
.empty-text {
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
|
line-height: 1.6;
|
||||||
color: #7aA898;
|
color: #7aA898;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user