feat: redesign onboarding and homepage

- Simplify onboarding to single-page form with default values
- Redesign quit mode homepage with hero card, check-in section
- Add health progress milestones visualization
- Add health data card for record mode showing savings metrics

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nepiedg
2026-03-30 17:35:13 +08:00
parent cf62ed1a3f
commit a55614c04f
2 changed files with 868 additions and 657 deletions
+702 -401
View File
File diff suppressed because it is too large Load Diff
+166 -256
View File
@@ -1,23 +1,20 @@
<template> <template>
<view class="page"> <view class="page">
<view class="nav-area" :style="{ paddingTop: navBarHeight + 'px' }"> <view class="nav-area" :style="{ paddingTop: navBarHeight + 'px' }">
<view class="nav-row"> <text class="nav-title">完善你的信息</text>
<text class="step-indicator">{{ step }} / {{ totalSteps }}</text> <text class="nav-subtitle">帮助我们更好地为你服务</text>
</view>
<view class="progress-bar">
<view class="progress-fill" :style="{ width: progressWidth }"></view>
</view>
</view> </view>
<view class="content"> <scroll-view class="content" scroll-y>
<view class="mode-section"> <!-- 使用模式 -->
<text class="mode-section-label">使用模式</text> <view class="form-section">
<text class="section-label">使用模式</text>
<view class="mode-switch"> <view class="mode-switch">
<view <view
v-for="item in modeOptions" v-for="item in modeOptions"
:key="item.value" :key="item.value"
class="mode-switch-item" class="mode-switch-item"
:class="{ 'mode-switch-item-active': currentMode === item.value }" :class="{ 'mode-switch-item-active': formData.mode === item.value }"
@tap="selectMode(item.value)" @tap="selectMode(item.value)"
> >
<text class="mode-switch-title">{{ item.label }}</text> <text class="mode-switch-title">{{ item.label }}</text>
@@ -26,28 +23,28 @@
</view> </view>
</view> </view>
<view v-if="step === 1" class="step"> <!-- 每天抽烟数量 -->
<text class="step-title">你每天抽多少支烟</text> <view class="form-section">
<text class="step-desc">{{ baselineDesc }}</text> <view class="section-row">
<view class="input-group"> <text class="section-label">每天抽烟</text>
<view class="input-row"> <view class="inline-input">
<view class="input-btn" @tap="decreaseCigs">-</view> <view class="inline-btn" @tap="decreaseCigs">-</view>
<text class="input-value">{{ formData.baseline_cigs_per_day }}</text> <text class="inline-value">{{ formData.baseline_cigs_per_day }}</text>
<view class="input-btn" @tap="increaseCigs">+</view> <view class="inline-btn" @tap="increaseCigs">+</view>
<text class="inline-unit">/</text>
</view> </view>
<text class="input-unit">/</text>
</view> </view>
</view> </view>
<view v-if="step === 2" class="step"> <!-- 烟龄 -->
<text class="step-title">你的烟龄是多久</text> <view class="form-section">
<text class="step-desc">了解你的吸烟历史有助于更好地帮助你</text> <text class="section-label">烟龄</text>
<view class="options"> <view class="options-row">
<view <view
v-for="option in smokingYearsOptions" v-for="option in smokingYearsOptions"
:key="option.value" :key="option.value"
class="option" class="option-tag"
:class="{ 'option-active': formData.smoking_years === option.value }" :class="{ 'option-tag-active': formData.smoking_years === option.value }"
@tap="formData.smoking_years = option.value" @tap="formData.smoking_years = option.value"
> >
{{ option.label }} {{ option.label }}
@@ -55,15 +52,15 @@
</view> </view>
</view> </view>
<view v-if="step === 3" class="step"> <!-- 戒烟动机 -->
<text class="step-title">{{ motivationTitle }}</text> <view class="form-section">
<text class="step-desc">{{ motivationDesc }}</text> <text class="section-label">{{ formData.mode === 'quit' ? '戒烟原因' : '记录原因' }}可多选</text>
<view class="options options-wrap"> <view class="options-row">
<view <view
v-for="option in quitMotivationOptions" v-for="option in quitMotivationOptions"
:key="option" :key="option"
class="option option-tag" class="option-tag"
:class="{ 'option-active': formData.quit_motivations.includes(option) }" :class="{ 'option-tag-active': formData.quit_motivations.includes(option) }"
@tap="toggleMotivation(option)" @tap="toggleMotivation(option)"
> >
{{ option }} {{ option }}
@@ -71,18 +68,18 @@
</view> </view>
</view> </view>
<view v-if="step === 4" class="step"> <!-- 作息时间 -->
<text class="step-title">你通常什么时候起床和睡觉</text> <view class="form-section">
<text class="step-desc">我们会在你的休息时间避免打扰你</text> <text class="section-label">作息时间</text>
<view class="time-row"> <view class="time-row">
<view class="time-item"> <view class="time-item">
<text class="time-label">起床时间</text> <text class="time-label">起床</text>
<picker mode="time" :value="formData.wake_up_time" @change="onWakeTimeChange"> <picker mode="time" :value="formData.wake_up_time" @change="onWakeTimeChange">
<view class="time-picker">{{ formData.wake_up_time }}</view> <view class="time-picker">{{ formData.wake_up_time }}</view>
</picker> </picker>
</view> </view>
<view class="time-item"> <view class="time-item">
<text class="time-label">睡觉时间</text> <text class="time-label">睡觉</text>
<picker mode="time" :value="formData.sleep_time" @change="onSleepTimeChange"> <picker mode="time" :value="formData.sleep_time" @change="onSleepTimeChange">
<view class="time-picker">{{ formData.sleep_time }}</view> <view class="time-picker">{{ formData.sleep_time }}</view>
</picker> </picker>
@@ -90,29 +87,30 @@
</view> </view>
</view> </view>
<view v-if="step === 5" class="step"> <!-- 烟价 -->
<text class="step-title">每包烟多少钱</text> <view class="form-section">
<text class="step-desc">我们会帮你计算省下的钱</text> <view class="section-row">
<view class="input-group"> <text class="section-label">每包烟价格</text>
<view class="price-input"> <view class="inline-input">
<text class="price-prefix">¥</text> <text class="inline-unit">¥</text>
<input <input
type="digit" type="digit"
v-model="priceYuan" v-model="priceYuan"
class="price-field" class="inline-field"
placeholder="0" placeholder="25"
placeholder-style="color: #6B7280" placeholder-style="color: #9CA3AF"
/> />
<text class="inline-unit">/</text>
</view> </view>
<text class="input-unit">/</text>
</view> </view>
</view> </view>
</view>
<view class="bottom-space"></view>
</scroll-view>
<view class="footer"> <view class="footer">
<view v-if="step > 1" class="btn-secondary" @tap="prevStep">上一步</view> <view class="btn-primary" @tap="handleSubmit">
<view class="btn-primary" :class="{ 'btn-full': step === 1 }" @tap="nextStep"> <text class="btn-text">{{ submitButtonText }}</text>
{{ step === 5 ? finishButtonText : '下一步' }}
</view> </view>
</view> </view>
</view> </view>
@@ -130,9 +128,8 @@ const userStore = useUserStore()
const { waitForLogin } = useLogin() const { waitForLogin } = useLogin()
const navBarHeight = ref(0) const navBarHeight = ref(0)
const step = ref(1)
const totalSteps = 5
const modeSaving = ref(false) const modeSaving = ref(false)
const modeOptions = [ const modeOptions = [
{ value: 'quit', label: '戒烟打卡', desc: '按天记录今天没抽' }, { value: 'quit', label: '戒烟打卡', desc: '按天记录今天没抽' },
{ value: 'record', label: '记录抽烟', desc: '按支数记录变化' } { value: 'record', label: '记录抽烟', desc: '按支数记录变化' }
@@ -142,7 +139,7 @@ const formData = ref({
mode: 'record', mode: 'record',
baseline_cigs_per_day: 10, baseline_cigs_per_day: 10,
smoking_years: 5, smoking_years: 5,
quit_motivations: [], quit_motivations: ['身体健康'],
smoke_motivations: [], smoke_motivations: [],
wake_up_time: '07:30', wake_up_time: '07:30',
sleep_time: '23:00', sleep_time: '23:00',
@@ -151,20 +148,14 @@ const formData = ref({
const priceYuan = ref('25') const priceYuan = ref('25')
const progressWidth = computed(() => `${(step.value / totalSteps) * 100}%`) const submitButtonText = computed(() => formData.value.mode === 'quit' ? '开始戒烟之旅' : '开始记录之旅')
const currentMode = computed(() => formData.value.mode || userStore.mode || 'record')
const isRecordMode = computed(() => currentMode.value === 'record')
const baselineDesc = computed(() => isRecordMode.value ? '这会成为你后续记录和统计的基线' : '这将帮助我们为你制定更合适的戒烟节奏')
const motivationTitle = computed(() => isRecordMode.value ? '你为什么想先开始记录抽烟?' : '你为什么想戒烟?')
const motivationDesc = computed(() => isRecordMode.value ? '选择最符合你当前状态的原因(可多选)' : '选择对你最重要的原因(可多选)')
const finishButtonText = computed(() => isRecordMode.value ? '开始记录之旅' : '开始戒烟之旅')
const smokingYearsOptions = [ const smokingYearsOptions = [
{ label: '少于1年', value: 1 }, { label: '<1年', value: 1 },
{ label: '1-3年', value: 2 }, { label: '1-3年', value: 2 },
{ label: '3-5年', value: 4 }, { label: '3-5年', value: 4 },
{ label: '5-10年', value: 7 }, { label: '5-10年', value: 7 },
{ label: '10年以上', value: 15 } { label: '>10年', value: 15 }
] ]
const quitMotivationOptions = [ const quitMotivationOptions = [
@@ -217,18 +208,7 @@ function onSleepTimeChange(e) {
formData.value.sleep_time = e.detail.value formData.value.sleep_time = e.detail.value
} }
function prevStep() { async function handleSubmit() {
if (step.value > 1) {
step.value--
}
}
async function nextStep() {
if (step.value < totalSteps) {
step.value++
return
}
formData.value.pack_price_cent = Math.round(parseFloat(priceYuan.value || '0') * 100) formData.value.pack_price_cent = Math.round(parseFloat(priceYuan.value || '0') * 100)
try { try {
@@ -255,6 +235,7 @@ onMounted(async () => {
} catch (e) { } catch (e) {
navBarHeight.value = statusBarH + 44 navBarHeight.value = statusBarH + 44
} }
await waitForLogin() await waitForLogin()
try { try {
const profileData = await profileStore.fetchProfile() const profileData = await profileStore.fetchProfile()
@@ -265,7 +246,9 @@ onMounted(async () => {
mode: profile.mode || userStore.mode || formData.value.mode, mode: profile.mode || userStore.mode || formData.value.mode,
baseline_cigs_per_day: profile.baseline_cigs_per_day || formData.value.baseline_cigs_per_day, baseline_cigs_per_day: profile.baseline_cigs_per_day || formData.value.baseline_cigs_per_day,
smoking_years: profile.smoking_years || formData.value.smoking_years, smoking_years: profile.smoking_years || formData.value.smoking_years,
quit_motivations: Array.isArray(profile.quit_motivations) ? profile.quit_motivations : formData.value.quit_motivations, quit_motivations: Array.isArray(profile.quit_motivations) && profile.quit_motivations.length > 0
? profile.quit_motivations
: formData.value.quit_motivations,
smoke_motivations: Array.isArray(profile.smoke_motivations) ? profile.smoke_motivations : formData.value.smoke_motivations, smoke_motivations: Array.isArray(profile.smoke_motivations) ? profile.smoke_motivations : formData.value.smoke_motivations,
wake_up_time: profile.wake_up_time || formData.value.wake_up_time, wake_up_time: profile.wake_up_time || formData.value.wake_up_time,
sleep_time: profile.sleep_time || formData.value.sleep_time, sleep_time: profile.sleep_time || formData.value.sleep_time,
@@ -302,61 +285,48 @@ onShareAppMessage(() => {
} }
.nav-area { .nav-area {
padding-left: 32rpx; padding: 24rpx 32rpx;
padding-right: 32rpx;
background: transparent; background: transparent;
} }
.nav-row { .nav-title {
display: flex; display: block;
justify-content: center; font-size: 40rpx;
align-items: center; font-weight: 700;
padding: 12rpx 0; color: #111827;
} }
.step-indicator { .nav-subtitle {
font-size: 24rpx; display: block;
font-weight: 600;
color: #667085;
background-color: rgba(255, 255, 255, 0.76);
padding: 6rpx 24rpx;
border-radius: 999rpx;
}
.progress-bar {
height: 6rpx;
background-color: rgba(255, 255, 255, 0.5);
border-radius: 999rpx;
margin-top: 8rpx; margin-top: 8rpx;
} font-size: 26rpx;
color: #6B7280;
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #32c59d, #1aa37a);
border-radius: 999rpx;
transition: width 0.3s ease;
} }
.content { .content {
flex: 1; flex: 1;
padding: 0 48rpx; padding: 0 32rpx;
display: flex;
flex-direction: column;
justify-content: center;
} }
.mode-section { .form-section {
margin-bottom: 40rpx; margin-bottom: 40rpx;
} }
.mode-section-label { .section-label {
display: block; display: block;
margin-bottom: 16rpx; margin-bottom: 16rpx;
font-size: 24rpx; font-size: 28rpx;
font-weight: 600; font-weight: 600;
color: #667085; color: #374151;
} }
.section-row {
display: flex;
justify-content: space-between;
align-items: center;
}
/* Mode switch */
.mode-switch { .mode-switch {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -365,219 +335,159 @@ onShareAppMessage(() => {
.mode-switch-item { .mode-switch-item {
padding: 24rpx; padding: 24rpx;
border-radius: 24rpx; border-radius: 20rpx;
background: rgba(255, 255, 255, 0.8); background: rgba(255, 255, 255, 0.8);
border: 2rpx solid rgba(255, 255, 255, 0.66); border: 2rpx solid rgba(255, 255, 255, 0.66);
box-shadow: 0 12rpx 28rpx rgba(15, 23, 42, 0.06); box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.05);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
} }
.mode-switch-item-active { .mode-switch-item-active {
background: rgba(255, 255, 255, 0.92); background: rgba(255, 255, 255, 0.96);
border-color: rgba(255, 255, 255, 0.78); border-color: rgba(26, 163, 122, 0.3);
box-shadow: 0 8rpx 24rpx rgba(26, 163, 122, 0.1);
} }
.mode-switch-title { .mode-switch-title {
display: block; display: block;
font-size: 28rpx; font-size: 28rpx;
font-weight: 700; font-weight: 600;
color: #111827; color: #111827;
} }
.mode-switch-desc { .mode-switch-desc {
display: block; display: block;
margin-top: 10rpx; margin-top: 8rpx;
font-size: 22rpx; font-size: 22rpx;
line-height: 1.5; line-height: 1.4;
color: #6b7280; color: #6b7280;
} }
.step { /* Inline input */
animation: fadeIn 0.3s ease; .inline-input {
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.step-title {
font-size: 44rpx;
font-weight: 700;
color: #111827;
display: block;
margin-bottom: 16rpx;
line-height: 1.3;
}
.step-desc {
font-size: 28rpx;
color: #6B7280;
display: block;
margin-bottom: 56rpx;
}
.input-group {
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
}
.input-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 48rpx;
}
.input-btn {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.86);
display: flex;
align-items: center;
justify-content: center;
font-size: 48rpx;
color: #1aa37a;
border: 2rpx solid rgba(255, 255, 255, 0.72);
box-shadow: 0 10rpx 24rpx rgba(15, 23, 42, 0.06);
}
.input-value {
font-size: 96rpx;
font-weight: 700;
color: #111827;
min-width: 160rpx;
text-align: center;
}
.input-unit {
font-size: 28rpx;
color: #6B7280;
}
.options {
display: flex;
flex-direction: column;
gap: 16rpx; gap: 16rpx;
} }
.options-wrap { .inline-btn {
flex-direction: row; width: 56rpx;
flex-wrap: wrap; height: 56rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
color: #1aa37a;
border: 2rpx solid rgba(255, 255, 255, 0.72);
box-shadow: 0 4rpx 12rpx rgba(15, 23, 42, 0.06);
} }
.option { .inline-value {
padding: 28rpx 36rpx; font-size: 36rpx;
background-color: rgba(255, 255, 255, 0.84); font-weight: 700;
border-radius: 16rpx;
font-size: 30rpx;
color: #111827; color: #111827;
min-width: 48rpx;
text-align: center;
}
.inline-unit {
font-size: 26rpx;
color: #6B7280;
}
.inline-field {
width: 100rpx;
font-size: 32rpx;
font-weight: 600;
color: #111827;
text-align: center;
background: rgba(255, 255, 255, 0.9);
padding: 12rpx 16rpx;
border-radius: 12rpx;
border: 2rpx solid rgba(255, 255, 255, 0.72); border: 2rpx solid rgba(255, 255, 255, 0.72);
box-shadow: 0 8rpx 20rpx rgba(15, 23, 42, 0.05); }
/* Options row */
.options-row {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
} }
.option-tag { .option-tag {
padding: 20rpx 28rpx; padding: 16rpx 28rpx;
border-radius: 32rpx; border-radius: 999rpx;
background: rgba(255, 255, 255, 0.84);
font-size: 26rpx;
color: #374151;
border: 2rpx solid rgba(255, 255, 255, 0.72);
box-shadow: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
} }
.option-active { .option-tag-active {
background-color: rgba(255, 255, 255, 0.96); background: rgba(26, 163, 122, 0.12);
border-color: rgba(255, 255, 255, 0.82); border-color: rgba(26, 163, 122, 0.3);
color: #1a7f61; color: #1a7f61;
} }
/* Time row */
.time-row { .time-row {
display: flex; display: flex;
gap: 32rpx; gap: 24rpx;
} }
.time-item { flex: 1; } .time-item {
flex: 1;
}
.time-label { .time-label {
font-size: 26rpx; font-size: 24rpx;
color: #6B7280; color: #6B7280;
display: block; display: block;
margin-bottom: 12rpx; margin-bottom: 8rpx;
} }
.time-picker { .time-picker {
background-color: rgba(255, 255, 255, 0.86); background: rgba(255, 255, 255, 0.9);
padding: 32rpx; padding: 20rpx 24rpx;
border-radius: 16rpx; border-radius: 12rpx;
font-size: 40rpx; font-size: 32rpx;
font-weight: 600;
color: #111827; color: #111827;
text-align: center; text-align: center;
border: 2rpx solid rgba(255, 255, 255, 0.72); border: 2rpx solid rgba(255, 255, 255, 0.72);
box-shadow: 0 8rpx 20rpx rgba(15, 23, 42, 0.05); box-shadow: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
} }
.price-input { .bottom-space {
display: flex; height: 160rpx;
align-items: center;
background-color: rgba(255, 255, 255, 0.86);
padding: 24rpx 32rpx;
border-radius: 16rpx;
gap: 8rpx;
border: 2rpx solid rgba(255, 255, 255, 0.72);
box-shadow: 0 8rpx 20rpx rgba(15, 23, 42, 0.05);
}
.price-prefix {
font-size: 48rpx;
color: #9CA3AF;
}
.price-field {
font-size: 64rpx;
font-weight: 700;
color: #111827;
width: 200rpx;
text-align: center;
} }
/* Footer */
.footer { .footer {
display: flex; position: fixed;
gap: 24rpx; left: 0;
padding: 32rpx 48rpx; right: 0;
padding-bottom: 64rpx; bottom: 0;
padding: 24rpx 32rpx;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
background: linear-gradient(180deg, transparent 0%, rgba(248, 250, 252, 0.95) 40%);
} }
.btn-primary { .btn-primary {
flex: 1;
height: 96rpx; height: 96rpx;
background: linear-gradient(180deg, #32c59d 0%, #1aa37a 100%); background: linear-gradient(180deg, #32c59d 0%, #1aa37a 100%);
border-radius: 48rpx; border-radius: 48rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 32rpx;
font-weight: 600;
color: #FFFFFF;
box-shadow: 0 12rpx 28rpx rgba(26, 163, 122, 0.22); box-shadow: 0 12rpx 28rpx rgba(26, 163, 122, 0.22);
} }
.btn-full { flex: 1; } .btn-text {
.btn-secondary {
height: 96rpx;
padding: 0 48rpx;
background-color: rgba(255, 255, 255, 0.86);
border-radius: 48rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx; font-size: 32rpx;
color: #111827; font-weight: 600;
border: 2rpx solid rgba(15, 23, 42, 0.08); color: #FFFFFF;
} }
</style> </style>