feat(nsti): add nicotine personality test flow (#36)

* fix: polish logs filter and stats money display

* fix: align floating tabs on logs and stats

* feat: enhance user profile and achievement features

- Add functionality for users to update their profile picture and nickname
- Implement achievement theme selection in onboarding
- Update API integration for profile updates and achievement themes
- Refine UI elements for better user interaction and experience

* feat: 梦想清单页与戒烟相关 API

- 梦想清单:系统导航栏、浮动添加、图标来自后台预设
- dream-presets API、pages.json 导航样式

Made-with: Cursor

* feat(nsti): add nicotine personality test flow
This commit is contained in:
hello-dd-code
2026-04-11 01:49:19 +08:00
committed by GitHub
parent ef36ca072b
commit ec87a9fc55
22 changed files with 4157 additions and 508 deletions
+498 -113
View File
@@ -21,8 +21,7 @@
<view class="quit-hero-top">
<view class="quit-hero-copy">
<text class="quit-hero-eyebrow">无烟旅程</text>
<text class="quit-hero-title">今天也在慢慢变好</text>
<text class="quit-hero-subtitle">{{ quitEncouragement }}</text>
<text class="quit-hero-title">{{ quitEncouragement }}</text>
</view>
<text class="quit-hero-chip">{{ todayChecked ? '今日已完成' : '等待打卡' }}</text>
</view>
@@ -53,6 +52,25 @@
</view>
</view>
</view>
<view v-if="achievementData" class="quit-ach-inline">
<view class="quit-ach-left">
<text class="quit-ach-icon">{{ achievementData.theme_icon }}</text>
<view class="quit-ach-info">
<text class="quit-ach-rank">{{ achievementData.current?.name || '--' }}</text>
<text class="quit-ach-theme">{{ achievementData.theme_name }}</text>
</view>
</view>
<view class="quit-ach-right">
<view v-if="achievementData.next" class="quit-ach-progress-area">
<view class="quit-ach-bar">
<view class="quit-ach-fill" :style="{ width: (achievementData.progress * 100) + '%' }"></view>
</view>
<text class="quit-ach-hint">{{ achievementData.next.name }}还需 {{ achievementData.next.required_days - achievementData.days }} </text>
</view>
<text v-else class="quit-ach-max">已达最高等级</text>
</view>
</view>
</view>
<view
@@ -78,30 +96,47 @@
<view class="quit-health-card">
<view class="quit-health-header">
<view>
<text class="quit-health-title">健康恢复</text>
<text class="quit-health-subtitle">身体正在按自己的节奏修复</text>
<text class="quit-health-title">健康恢复里程碑</text>
</view>
<view class="quit-health-badge" :class="{ 'quit-health-badge-done': healthProgress >= 100 }">
{{ healthProgress >= 100 ? '已达成' : `${healthProgress}%` }}
</view>
<text class="quit-health-value">{{ healthProgress }}%</text>
</view>
<view class="quit-health-bar">
<view class="quit-health-bar-fill" :style="{ width: healthProgress + '%' }"></view>
</view>
<view class="quit-health-milestones">
<view class="quit-milestone-list">
<view
v-for="(milestone, index) in healthMilestones"
v-for="(ms, index) in healthMilestoneItems"
:key="index"
class="quit-milestone-item"
:class="{ 'quit-milestone-done': quitDays >= milestone.days }"
class="quit-ms-item"
>
<view class="quit-milestone-dot"></view>
<text class="quit-milestone-text">{{ milestone.label }}</text>
<view class="quit-ms-top">
<text class="quit-ms-name">{{ ms.name }}</text>
<text class="quit-ms-pct" :class="{ 'quit-ms-pct-done': ms.percent >= 100 }">{{ ms.percent }}%</text>
</view>
<view class="quit-ms-bar">
<view
class="quit-ms-fill"
:class="ms.percent >= 100 ? 'quit-ms-fill-done' : 'quit-ms-fill-pending'"
:style="{ width: ms.percent + '%' }"
></view>
</view>
</view>
</view>
</view>
<view class="quit-motivation-card">
<text class="quit-motivation-label">今日提醒</text>
<text class="quit-motivation-text">{{ healthTip }}</text>
<view class="quit-dream-entry" @tap="gotoDreamGoals">
<view class="quit-dream-left">
<text class="quit-dream-icon">🎯</text>
<view class="quit-dream-info">
<text class="quit-dream-title">梦想清单</text>
<text class="quit-dream-desc">{{ activeGoalText }}</text>
</view>
</view>
<text class="quit-dream-arrow"></text>
</view>
<view class="quit-tip-bar">
<text class="quit-tip-icon">💡</text>
<text class="quit-tip-text">{{ healthTip }}</text>
</view>
</view>
@@ -212,6 +247,28 @@
<text class="health-tip-icon">💡</text>
<text class="health-tip-text">{{ recordHealthTip }}</text>
</view>
<view v-if="achievementData" class="record-achievement-card">
<view class="ach-header">
<text class="ach-title">{{ achievementData.theme_icon }} 成就称号</text>
<text class="ach-theme-name">{{ achievementData.theme_name }}</text>
</view>
<view class="ach-body">
<view class="ach-current">
<text class="ach-rank">{{ achievementData.current?.name || '--' }}</text>
<text class="ach-days"> {{ achievementData.days }} </text>
</view>
<view v-if="achievementData.next" class="ach-progress-wrap">
<view class="ach-progress-bar">
<view class="ach-progress-fill" :style="{ width: (achievementData.progress * 100) + '%' }"></view>
</view>
<text class="ach-next-hint">距下一级{{ achievementData.next.name }}还需 {{ achievementData.next.required_days - achievementData.days }} </text>
</view>
<view v-else class="ach-max">
<text class="ach-max-text">已达最高等级</text>
</view>
</view>
</view>
</view>
</view>
@@ -244,6 +301,8 @@ const showDialog = ref(false)
const dialogType = ref('smoke')
const homeData = ref(null)
const pageReady = ref(false)
const achievementData = ref(null)
const quitHomeData = ref(null)
const quitState = ref(defaultQuitState())
let timerInterval = null
@@ -288,35 +347,59 @@ const nextSmokeTimeText = computed(() => {
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
})
// 戒烟模式计算
// 戒烟模式计算 — 优先使用 V2 API 数据
const quitSummary = computed(() => quitHomeData.value?.summary || {})
const quitDailyStatus = computed(() => quitHomeData.value?.daily_status || {})
const baselineCigsPerDay = computed(() => profileStore.profile?.baseline_cigs_per_day || 10)
const packPriceYuan = computed(() => (profileStore.profile?.pack_price_cent || 2500) / 100)
const quitDays = computed(() => {
if (quitSummary.value.current_streak_days !== undefined) {
return quitSummary.value.current_streak_days
}
if (!quitState.value.lastCheckinDate) return 0
const gap = diffDays(quitState.value.lastCheckinDate, formatDate(new Date()))
if (gap > 1) return 0
return Number(quitState.value.streakDays || 0)
})
const todayChecked = computed(() => quitState.value.lastCheckinDate === formatDate(new Date()))
const todayCheckinTime = computed(() => formatClock(quitState.value.lastCheckinAt))
const todayChecked = computed(() => {
if (quitDailyStatus.value.status) {
return quitDailyStatus.value.status === 'checked_in'
}
return quitState.value.lastCheckinDate === formatDate(new Date())
})
const todayCheckinTime = computed(() => {
if (quitDailyStatus.value.checkin_at) {
return formatClock(quitDailyStatus.value.checkin_at)
}
return formatClock(quitState.value.lastCheckinAt)
})
const savedMoney = computed(() => {
const total = (quitDays.value * baselineCigsPerDay.value / 20) * packPriceYuan.value
return Math.round(total)
if (quitSummary.value.saved_money_cent !== undefined) {
return Math.round(quitSummary.value.saved_money_cent / 100)
}
return Math.round((quitDays.value * baselineCigsPerDay.value / 20) * packPriceYuan.value)
})
const avoidedCigs = computed(() => {
if (quitSummary.value.avoided_cigs !== undefined) {
return quitSummary.value.avoided_cigs
}
return quitDays.value * baselineCigsPerDay.value
})
const lifeSaved = computed(() => {
// 每支烟减少约11分钟生命,换算成小时
return Math.round(quitDays.value * baselineCigsPerDay.value * 11 / 60)
return Math.round(avoidedCigs.value * 11 / 60)
})
const healthProgress = computed(() => {
if (quitSummary.value.health_recovery_percent !== undefined) {
return quitSummary.value.health_recovery_percent
}
if (quitDays.value >= 365) return 100
if (quitDays.value >= 180) return 85
if (quitDays.value >= 90) return 70
@@ -361,6 +444,30 @@ const healthMilestones = computed(() => [
{ days: 365, label: '1年' }
])
const healthMilestoneItems = computed(() => {
const milestones = [
{ name: '血压心率恢复', minutes: 20 },
{ name: '一氧化碳排出', minutes: 480 },
{ name: '尼古丁代谢完', minutes: 4320 },
{ name: '味觉嗅觉恢复', minutes: 43200 },
{ name: '血液循环改善', minutes: 129600 },
{ name: '肺功能提升', minutes: 525600 },
]
const minutes = quitDays.value * 1440
return milestones.map(m => ({
name: m.name,
percent: Math.min(Math.round((minutes / m.minutes) * 100), 100)
}))
})
const activeGoalText = computed(() => {
const goal = quitHomeData.value?.goal
if (!goal) return '设定一个小目标,攒钱实现它'
const remaining = goal.target_amount_cent - (goal.current_amount_cent || 0)
if (remaining <= 0) return `${goal.title}」已攒够!`
return `${goal.title}」还差 ¥${Math.round(remaining / 100)}`
})
const todayCountPercent = computed(() => {
if (!dailyTarget.value || dailyTarget.value <= 0) return todayCount.value > 0 ? 100 : 0
const percent = Math.round((todayCount.value / dailyTarget.value) * 100)
@@ -518,21 +625,73 @@ async function handleSubmit(submitData) {
}
}
function handleQuitCheckin() {
async function handleQuitCheckin() {
if (todayChecked.value) {
uni.showToast({ title: '今天已经打过卡', icon: 'none' })
return
}
const today = formatDate(new Date())
const previousDate = quitState.value.lastCheckinDate
let streakDays = 1
if (previousDate) {
const gap = diffDays(previousDate, today)
if (gap === 1) streakDays = Number(quitState.value.streakDays || 0) + 1
else if (gap === 0) streakDays = Number(quitState.value.streakDays || 0)
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
})
uni.showToast({ title: '打卡成功', icon: 'success' })
} catch (e) {
console.error('handleQuitCheckin error:', e)
uni.showToast({ title: '打卡失败,请重试', icon: 'none' })
}
saveQuitState({ lastCheckinDate: today, lastCheckinAt: new Date().toISOString(), streakDays })
uni.showToast({ title: '打卡成功', icon: 'success' })
}
function gotoDreamGoals() {
uni.navigateTo({ url: '/pages/dream-goals/index' })
}
function applyQuitHomeData(data) {
if (!data) return
quitHomeData.value = data
if (data.daily_status?.status === 'checked_in' && data.daily_status?.checkin_at) {
saveQuitState({
lastCheckinDate: data.daily_status.date,
lastCheckinAt: data.daily_status.checkin_at,
streakDays: data.summary?.current_streak_days || 0
})
}
}
async function fetchQuitHomeData() {
try {
const res = await api.getQuitCheckinHome()
applyQuitHomeData(res.data)
} catch (e) {
const msg = e?.message || ''
if (msg.includes('基础资料')) {
await ensureQuitProfile()
try {
const res = await api.getQuitCheckinHome()
applyQuitHomeData(res.data)
} catch (retryErr) {
console.error('fetchQuitHomeData retry error:', retryErr)
loadQuitState()
}
} else {
console.error('fetchQuitHomeData error:', e)
loadQuitState()
}
}
}
async function ensureQuitProfile() {
const profile = profileStore.profile
if (!profile) return
await api.upsertQuitCheckinProfile({
quit_start_date: formatDate(new Date()),
pack_price_cent: profile.pack_price_cent || 2500,
baseline_cigs_per_day: profile.baseline_cigs_per_day || 10
})
}
async function ensureProfileReady() {
@@ -548,13 +707,23 @@ async function ensureProfileReady() {
return true
}
async function fetchAchievement() {
try {
const res = await api.getAchievement()
achievementData.value = res.data?.achievement || null
} catch (e) {
console.error('fetchAchievement error:', e)
}
}
async function refreshCurrentMode() {
if (!userStore.mode) return
const profileReady = await ensureProfileReady()
if (!profileReady) return
fetchAchievement()
if (isQuitMode.value) {
stopTimer()
loadQuitState()
await fetchQuitHomeData()
return
}
await fetchRecordHomeData()
@@ -698,8 +867,7 @@ onShareAppMessage(() => ({
.quit-hero-card,
.quit-checkin-card,
.quit-health-card,
.quit-motivation-card {
.quit-health-card {
position: relative;
overflow: hidden;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(251, 253, 252, 0.94) 100%);
@@ -714,8 +882,7 @@ onShareAppMessage(() => ({
}
.quit-hero-card::before,
.quit-health-card::before,
.quit-motivation-card::before {
.quit-health-card::before {
content: '';
position: absolute;
width: 240rpx;
@@ -744,7 +911,7 @@ onShareAppMessage(() => ({
align-items: flex-start;
justify-content: space-between;
gap: 16rpx;
margin-bottom: 26rpx;
margin-bottom: 20rpx;
}
.quit-hero-copy {
@@ -765,20 +932,12 @@ onShareAppMessage(() => ({
}
.quit-hero-title {
display: block;
margin-top: 16rpx;
font-size: 42rpx;
line-height: 1.2;
font-weight: 800;
color: #111827;
}
.quit-hero-subtitle {
display: block;
margin-top: 10rpx;
font-size: 24rpx;
line-height: 1.6;
color: #6b7280;
font-size: 28rpx;
line-height: 1.5;
font-weight: 700;
color: #14936d;
}
.quit-hero-chip {
@@ -966,10 +1125,10 @@ onShareAppMessage(() => ({
.quit-health-header {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin-bottom: 18rpx;
margin-bottom: 20rpx;
}
.quit-health-title {
@@ -979,101 +1138,238 @@ onShareAppMessage(() => ({
color: #111827;
}
.quit-health-subtitle {
display: block;
margin-top: 8rpx;
font-size: 22rpx;
line-height: 1.5;
color: #6b7280;
.quit-health-badge {
font-size: 21rpx;
padding: 8rpx 16rpx;
border-radius: 999rpx;
background: rgba(240, 252, 248, 0.94);
border: 1rpx solid rgba(15, 23, 42, 0.05);
color: #14936d;
font-weight: 600;
}
.quit-health-value {
font-size: 34rpx;
font-weight: 800;
.quit-health-badge-done {
background: linear-gradient(135deg, #10B981, #14936d);
color: #fff;
border-color: transparent;
}
.quit-milestone-list {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.quit-ms-item {
position: relative;
}
.quit-ms-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8rpx;
}
.quit-ms-name {
font-size: 24rpx;
color: #374151;
font-weight: 500;
}
.quit-ms-pct {
font-size: 22rpx;
color: #9ca3af;
font-weight: 600;
}
.quit-ms-pct-done {
color: #14936d;
}
.quit-health-bar {
position: relative;
z-index: 1;
height: 14rpx;
.quit-ms-bar {
height: 10rpx;
background: rgba(15, 23, 42, 0.06);
border-radius: 999rpx;
overflow: hidden;
}
.quit-health-bar-fill {
.quit-ms-fill {
height: 100%;
background: linear-gradient(90deg, #31c18b 0%, #14936d 100%);
border-radius: 999rpx;
transition: width 0.5s ease;
}
.quit-health-milestones {
display: flex;
justify-content: space-between;
gap: 8rpx;
margin-top: 22rpx;
.quit-ms-fill-done {
background: linear-gradient(90deg, #31c18b, #14936d);
}
.quit-milestone-item {
.quit-ms-fill-pending {
background: rgba(52, 200, 160, 0.3);
}
.quit-ach-inline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin-top: 20rpx;
padding: 18rpx 20rpx;
border-radius: 22rpx;
background: linear-gradient(180deg, #f5fbf9 0%, #eef6f2 100%);
border: 1rpx solid rgba(15, 23, 42, 0.05);
position: relative;
z-index: 1;
}
.quit-ach-left {
display: flex;
align-items: center;
gap: 12rpx;
flex-shrink: 0;
}
.quit-ach-icon {
font-size: 36rpx;
line-height: 1;
}
.quit-ach-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
flex: 1;
}
.quit-milestone-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: rgba(15, 23, 42, 0.08);
border: 2rpx solid rgba(15, 23, 42, 0.1);
.quit-ach-rank {
font-size: 28rpx;
font-weight: 800;
color: #14936d;
line-height: 1.2;
}
.quit-milestone-done .quit-milestone-dot {
background: #31c18b;
border-color: #14936d;
}
.quit-milestone-text {
.quit-ach-theme {
font-size: 20rpx;
color: #9ca3af;
margin-top: 2rpx;
}
.quit-milestone-done .quit-milestone-text {
.quit-ach-right {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
justify-content: flex-end;
}
.quit-ach-progress-area {
width: 100%;
max-width: 280rpx;
}
.quit-ach-bar {
height: 8rpx;
background: rgba(16, 185, 129, 0.12);
border-radius: 999rpx;
overflow: hidden;
}
.quit-ach-fill {
height: 100%;
background: linear-gradient(90deg, #10B981, #34D399);
border-radius: 999rpx;
transition: width 0.5s ease;
}
.quit-ach-hint {
display: block;
margin-top: 6rpx;
font-size: 20rpx;
color: #9ca3af;
text-align: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.quit-ach-max {
font-size: 20rpx;
color: #14936d;
font-weight: 600;
}
.quit-motivation-card {
padding: 24rpx;
}
.quit-motivation-label {
position: relative;
z-index: 1;
display: inline-flex;
.quit-dream-entry {
display: flex;
align-items: center;
padding: 8rpx 18rpx;
border-radius: 999rpx;
background: rgba(240, 252, 248, 0.94);
border: 1rpx solid rgba(15, 23, 42, 0.05);
font-size: 20rpx;
font-weight: 600;
color: #6b7280;
justify-content: space-between;
padding: 22rpx 24rpx;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(251, 253, 252, 0.94) 100%);
border-radius: 24rpx;
border: 1rpx solid rgba(15, 23, 42, 0.06);
box-shadow: 0 8rpx 20rpx rgba(15, 23, 42, 0.04);
}
.quit-motivation-text {
position: relative;
z-index: 1;
.quit-dream-left {
display: flex;
align-items: center;
gap: 16rpx;
flex: 1;
min-width: 0;
}
.quit-dream-icon {
font-size: 36rpx;
flex-shrink: 0;
}
.quit-dream-info {
flex: 1;
min-width: 0;
}
.quit-dream-title {
display: block;
margin-top: 16rpx;
font-size: 28rpx;
line-height: 1.7;
font-weight: 600;
color: #374151;
font-weight: 700;
color: #111827;
}
.quit-dream-desc {
display: block;
margin-top: 4rpx;
font-size: 22rpx;
color: #9ca3af;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.quit-dream-arrow {
font-size: 36rpx;
color: #d1d5db;
flex-shrink: 0;
font-weight: 300;
}
.quit-tip-bar {
display: flex;
align-items: center;
gap: 8rpx;
padding: 14rpx 18rpx;
border-radius: 16rpx;
background: rgba(240, 252, 248, 0.6);
border: 1rpx solid rgba(15, 23, 42, 0.03);
}
.quit-tip-icon {
font-size: 22rpx;
flex-shrink: 0;
}
.quit-tip-text {
font-size: 20rpx;
line-height: 1.5;
color: #9ca3af;
}
/* ===== 记录模式 ===== */
@@ -1497,4 +1793,93 @@ onShareAppMessage(() => ({
color: #4b5563;
line-height: 1.5;
}
.record-achievement-card {
margin-top: 20rpx;
padding: 24rpx;
border-radius: 24rpx;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(248, 251, 249, 0.94) 100%);
border: 1rpx solid rgba(15, 23, 42, 0.06);
box-shadow: 0 6rpx 14rpx rgba(15, 23, 42, 0.03);
}
.ach-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.ach-title {
font-size: 26rpx;
font-weight: 700;
color: #111827;
}
.ach-theme-name {
font-size: 22rpx;
color: #6b7280;
padding: 6rpx 14rpx;
background: rgba(240, 252, 248, 0.94);
border-radius: 999rpx;
border: 1rpx solid rgba(15, 23, 42, 0.05);
}
.ach-body {
position: relative;
z-index: 1;
}
.ach-current {
display: flex;
align-items: baseline;
gap: 12rpx;
margin-bottom: 16rpx;
}
.ach-rank {
font-size: 40rpx;
font-weight: 800;
color: #14936d;
font-family: 'DIN Alternate', -apple-system, sans-serif;
}
.ach-days {
font-size: 24rpx;
color: #6b7280;
}
.ach-progress-wrap {
margin-bottom: 4rpx;
}
.ach-progress-bar {
height: 12rpx;
background: rgba(16, 185, 129, 0.12);
border-radius: 6rpx;
overflow: hidden;
margin-bottom: 10rpx;
}
.ach-progress-fill {
height: 100%;
background: linear-gradient(90deg, #10B981, #34D399);
border-radius: 6rpx;
transition: width 0.5s ease;
}
.ach-next-hint {
font-size: 22rpx;
color: #6b7280;
}
.ach-max {
margin-top: 4rpx;
}
.ach-max-text {
font-size: 22rpx;
color: #14936d;
font-weight: 600;
}
</style>