feat: 添加戒烟计划前端页面

- 新增 pages/quit-plan/index.vue 戒烟计划页面
- 展示30天戒烟计划总览和进度
- 显示当前阶段(记录期/减量期/巩固期)
- 展示每日目标和建议
- 支持生成计划和重置计划功能
- 在 api/smoke.js 添加相关 API 调用
- 在 pages.json 注册路由
This commit is contained in:
nepiedg
2026-03-13 15:08:57 +08:00
parent bdb8d425eb
commit 525266afaf
3 changed files with 846 additions and 0 deletions
+17
View File
@@ -63,3 +63,20 @@ export function getShareData(shareToken, params = {}) {
export function revokeShare(shareToken) { export function revokeShare(shareToken) {
return request.post(`/smoke/share/${shareToken}/revoke`) return request.post(`/smoke/share/${shareToken}/revoke`)
} }
// 戒烟计划 API
export function generateQuitPlan() {
return request.post('/smoke/quit-plan/generate')
}
export function getQuitPlan(params = {}) {
return request.get('/smoke/quit-plan', params)
}
export function getQuitPlanDays(planId) {
return request.get('/smoke/quit-plan/days', { plan_id: planId })
}
export function resetQuitPlan() {
return request.post('/smoke/quit-plan/reset')
}
+6
View File
@@ -42,6 +42,12 @@
"navigationBarTitleText": "个人中心" "navigationBarTitleText": "个人中心"
} }
}, },
{
"path": "pages/quit-plan/index",
"style": {
"navigationBarTitleText": "戒烟计划"
}
},
{ {
"path": "pages/onboarding/index", "path": "pages/onboarding/index",
"style": { "style": {
+823
View File
@@ -0,0 +1,823 @@
<template>
<view class="page">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="container">
<view v-if="pageLoading" class="skeleton">
<view class="skeleton-card"></view>
<view class="skeleton-card"></view>
<view class="skeleton-list">
<view v-for="i in 3" :key="i" class="skeleton-row"></view>
</view>
</view>
<view v-else>
<!-- 无计划状态 -->
<view v-if="!planData" class="no-plan-card">
<view class="no-plan-icon">📋</view>
<text class="no-plan-title">暂无戒烟计划</text>
<text class="no-plan-desc">生成专属30天戒烟计划按阶段轻松戒烟</text>
<view class="generate-btn" @tap="handleGenerate">
<text class="generate-btn-text">生成戒烟计划</text>
</view>
</view>
<!-- 有计划状态 -->
<view v-else>
<!-- 计划总览卡片 -->
<view class="stage-card">
<view class="stage-badge"> {{ currentDay }}/30 </view>
<text class="stage-label">戒烟计划进度</text>
<text class="stage-name">{{ stageName }}</text>
<text class="stage-days">{{ stageDesc }}</text>
<view class="stage-progress-row">
<text class="stage-progress-label">计划进度</text>
<text class="stage-progress-value">{{ Math.round(planProgress * 100) }}%</text>
</view>
<view class="stage-progress-bar">
<view class="stage-progress-fill" :style="{ width: planProgress * 100 + '%' }"></view>
</view>
</view>
<!-- 阶段说明卡片 -->
<view class="section">
<view class="section-header">
<text class="section-title">当前阶段</text>
</view>
<view class="stage-info-card">
<view class="stage-item" :class="{ 'stage-item-active': planData.current_stage === 'recording' }">
<view class="stage-number">1</view>
<view class="stage-content">
<text class="stage-item-title">记录期</text>
<text class="stage-item-desc">记录每日吸烟情况了解习惯</text>
</view>
</view>
<view class="stage-line"></view>
<view class="stage-item" :class="{ 'stage-item-active': planData.current_stage === 'reducing' }">
<view class="stage-number">2</view>
<view class="stage-content">
<text class="stage-item-title">减量期</text>
<text class="stage-item-desc">逐步减少吸烟数量</text>
</view>
</view>
<view class="stage-line"></view>
<view class="stage-item" :class="{ 'stage-item-active': planData.current_stage === 'consolidating' }">
<view class="stage-number">3</view>
<view class="stage-content">
<text class="stage-item-title">巩固期</text>
<text class="stage-item-desc">保持成果彻底戒烟</text>
</view>
</view>
</view>
</view>
<!-- 每日目标和建议 -->
<view class="section">
<view class="section-header">
<text class="section-title">每日目标</text>
<text class="section-badge">{{ todayTarget }}</text>
</view>
<view class="daily-tips-card">
<text class="daily-tips-title">今日建议</text>
<text class="daily-tips-text">{{ dailyTip }}</text>
</view>
</view>
<!-- 每日计划列表 -->
<view class="section">
<view class="section-header">
<text class="section-title">每日计划详情</text>
</view>
<view v-if="daysLoading" class="days-loading">
<text class="days-loading-text">加载中...</text>
</view>
<view v-else-if="daysList.length > 0" class="days-list">
<view
v-for="day in daysList"
:key="day.day"
class="day-item"
:class="{ 'day-item-today': day.isToday, 'day-item-past': day.isPast }"
@tap="showDayDetail(day)"
>
<view class="day-header">
<text class="day-number"> {{ day.day }} </text>
<text v-if="day.isToday" class="day-today-badge">今天</text>
<text v-else-if="day.isPast" class="day-past-badge">已完成</text>
</view>
<view class="day-target">
<text class="day-target-label">目标:</text>
<text class="day-target-value">{{ day.target_cigs }} </text>
</view>
<view v-if="day.tip" class="day-tip">
<text class="day-tip-text">{{ day.tip }}</text>
</view>
</view>
</view>
<view v-else class="days-empty">
<text class="days-empty-text">暂无计划详情</text>
</view>
</view>
<!-- 操作按钮 -->
<view class="actions">
<view class="reset-btn" @tap="handleReset">
<text class="reset-btn-text">重置计划</text>
</view>
</view>
</view>
</view>
</view>
<!-- 每日详情弹窗 -->
<view v-if="showDayModal" class="modal-mask" @tap="closeDayModal">
<view class="modal-content" @tap.stop>
<view class="modal-header">
<text class="modal-title"> {{ selectedDay.day }} 天计划</text>
<text class="modal-close" @tap="closeDayModal">×</text>
</view>
<view class="modal-body">
<view class="modal-item">
<text class="modal-label">目标吸烟量</text>
<text class="modal-value">{{ selectedDay.target_cigs }} </text>
</view>
<view v-if="selectedDay.tip" class="modal-item">
<text class="modal-label">建议</text>
<text class="modal-value">{{ selectedDay.tip }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onShareAppMessage } from '@dcloudio/uni-app'
import { useLogin } from '@/hooks/useLogin'
import * as api from '@/api'
const { waitForLogin } = useLogin()
const statusBarHeight = ref(0)
const pageLoading = ref(true)
const daysLoading = ref(false)
const generating = ref(false)
const planData = ref(null)
const daysList = ref([])
const showDayModal = ref(false)
const selectedDay = ref({})
// 阶段名称映射
const stageNames = {
recording: '记录期',
reducing: '减量期',
consolidating: '巩固期'
}
const stageDescs = {
recording: '记录每日吸烟情况,了解您的吸烟习惯',
reducing: '按计划逐步减少吸烟数量',
consolidating: '保持戒烟成果,彻底摆脱烟瘾'
}
// 计算当前是第几天
const currentDay = computed(() => {
if (!planData.value?.plan_start_date) return 1
const start = new Date(planData.value.plan_start_date)
const now = new Date()
const diff = Math.floor((now - start) / (24 * 60 * 60 * 1000))
return Math.min(Math.max(diff + 1, 1), 30)
})
// 计划进度
const planProgress = computed(() => {
return currentDay.value / 30
})
// 当前阶段名称
const stageName = computed(() => {
if (!planData.value) return ''
return stageNames[planData.value.current_stage] || '记录期'
})
// 阶段描述
const stageDesc = computed(() => {
if (!planData.value) return ''
return stageDescs[planData.value.current_stage] || ''
})
// 今日目标
const todayTarget = computed(() => {
const today = daysList.value.find(d => d.isToday)
return today ? `${today.target_cigs}` : '--'
})
// 每日建议
const dailyTip = computed(() => {
const today = daysList.value.find(d => d.isToday)
return today?.tip || '按计划执行,保持决心!'
})
// 获取戒烟计划
async function fetchQuitPlan() {
try {
const res = await api.getQuitPlan()
planData.value = res?.data || null
if (planData.value?.id) {
await fetchDays()
}
} catch (e) {
console.error('fetchQuitPlan error:', e)
planData.value = null
}
}
// 获取每日计划
async function fetchDays() {
if (!planData.value?.id) return
daysLoading.value = true
try {
const res = await api.getQuitPlanDays(planData.value.id)
const days = res?.data || []
// 计算今天的日期
const today = new Date()
today.setHours(0, 0, 0, 0)
daysList.value = days.map(day => {
const dayDate = new Date(planData.value.plan_start_date)
dayDate.setDate(dayDate.getDate() + day.day - 1)
dayDate.setHours(0, 0, 0, 0)
return {
...day,
isToday: dayDate.getTime() === today.getTime(),
isPast: dayDate.getTime() < today.getTime()
}
})
} catch (e) {
console.error('fetchDays error:', e)
daysList.value = []
} finally {
daysLoading.value = false
}
}
// 生成计划
async function handleGenerate() {
if (generating.value) return
generating.value = true
try {
await api.generateQuitPlan()
uni.showToast({
title: '计划生成成功',
icon: 'success'
})
await fetchQuitPlan()
} catch (e) {
uni.showToast({
title: e?.message || '生成失败',
icon: 'none'
})
} finally {
generating.value = false
}
}
// 重置计划
function handleReset() {
uni.showModal({
title: '确认重置',
content: '重置后将清除当前计划,重新开始,确定要重置吗?',
success: async (res) => {
if (res.confirm) {
try {
await api.resetQuitPlan()
uni.showToast({
title: '计划已重置',
icon: 'success'
})
planData.value = null
daysList.value = []
} catch (e) {
uni.showToast({
title: e?.message || '重置失败',
icon: 'none'
})
}
}
}
})
}
// 显示每日详情
function showDayDetail(day) {
selectedDay.value = day
showDayModal.value = true
}
// 关闭弹窗
function closeDayModal() {
showDayModal.value = false
}
// 初始化页面
async function initPage() {
pageLoading.value = true
try {
const sys = uni.getSystemInfoSync()
statusBarHeight.value = sys.statusBarHeight || 0
await waitForLogin()
await fetchQuitPlan()
} catch (e) {
console.error('initPage error:', e)
} finally {
pageLoading.value = false
}
}
onMounted(() => {
initPage()
})
onShareAppMessage(() => {
return {
title: '戒烟助手 - 30天戒烟计划',
path: 'pages/index/index'
}
})
</script>
<style scoped>
.page {
min-height: 100vh;
background: linear-gradient(to bottom, #D1FAE5 0%, #F0FDF4 45%, #FFFFFF 100%);
box-sizing: border-box;
}
.status-bar {
background: linear-gradient(to bottom, #D1FAE5, #E9FDF2);
}
.container {
padding: 24rpx 32rpx 180rpx;
}
.skeleton {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.skeleton-card,
.skeleton-row {
background: linear-gradient(90deg, #E5E7EB 25%, #F3F4F6 50%, #E5E7EB 75%);
background-size: 200% 100%;
animation: shimmer 1.6s infinite;
}
.skeleton-card {
height: 260rpx;
border-radius: 24rpx;
}
.skeleton-list {
padding: 24rpx;
background-color: #FFFFFF;
border-radius: 24rpx;
}
.skeleton-row {
height: 92rpx;
border-radius: 18rpx;
margin-bottom: 16rpx;
}
.skeleton-row:last-child {
margin-bottom: 0;
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
/* 无计划状态 */
.no-plan-card {
background-color: #FFFFFF;
border-radius: 28rpx;
padding: 60rpx 40rpx;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 10rpx 28rpx rgba(16, 185, 129, 0.12);
border: 2rpx solid #ECFDF3;
}
.no-plan-icon {
font-size: 80rpx;
margin-bottom: 24rpx;
}
.no-plan-title {
font-size: 36rpx;
font-weight: 700;
color: #111827;
margin-bottom: 16rpx;
}
.no-plan-desc {
font-size: 26rpx;
color: #6B7280;
text-align: center;
margin-bottom: 40rpx;
line-height: 1.6;
}
.generate-btn {
background: linear-gradient(135deg, #10B981, #059669);
padding: 24rpx 60rpx;
border-radius: 48rpx;
box-shadow: 0 8rpx 20rpx rgba(16, 185, 129, 0.3);
}
.generate-btn-text {
font-size: 32rpx;
font-weight: 600;
color: #FFFFFF;
}
/* 阶段卡片 */
.stage-card {
background: #FFFFFF;
border-radius: 28rpx;
padding: 32rpx;
margin-bottom: 32rpx;
position: relative;
box-shadow: 0 10rpx 28rpx rgba(16, 185, 129, 0.12);
border: 2rpx solid #ECFDF3;
}
.stage-badge {
position: absolute;
top: 24rpx;
right: 24rpx;
background-color: #10B981;
color: #FFFFFF;
padding: 8rpx 20rpx;
border-radius: 20rpx;
font-size: 24rpx;
font-weight: 600;
}
.stage-label {
font-size: 24rpx;
color: #059669;
display: block;
margin-bottom: 8rpx;
}
.stage-name {
font-size: 42rpx;
font-weight: 700;
color: #111827;
display: block;
margin-bottom: 8rpx;
}
.stage-days {
font-size: 24rpx;
color: #6B7280;
display: block;
margin-bottom: 24rpx;
}
.stage-progress-row {
display: flex;
justify-content: space-between;
margin-bottom: 12rpx;
}
.stage-progress-label {
font-size: 24rpx;
color: #6B7280;
}
.stage-progress-value {
font-size: 24rpx;
font-weight: 600;
color: #10B981;
}
.stage-progress-bar {
height: 12rpx;
background-color: #E5E7EB;
border-radius: 6rpx;
overflow: hidden;
}
.stage-progress-fill {
height: 100%;
background: linear-gradient(90deg, #10B981, #34D399);
border-radius: 6rpx;
}
/* 阶段信息 */
.section {
margin-bottom: 32rpx;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 600;
color: #111827;
}
.section-badge {
font-size: 24rpx;
color: #059669;
background-color: #ECFDF3;
padding: 8rpx 16rpx;
border-radius: 16rpx;
}
.stage-info-card {
background-color: #FFFFFF;
border-radius: 24rpx;
padding: 28rpx;
border: 2rpx solid #ECFDF3;
box-shadow: 0 8rpx 22rpx rgba(16, 185, 129, 0.08);
}
.stage-item {
display: flex;
align-items: flex-start;
gap: 20rpx;
}
.stage-item-active .stage-number {
background-color: #10B981;
color: #FFFFFF;
border-color: #10B981;
}
.stage-item-active .stage-item-title {
color: #10B981;
}
.stage-number {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
border: 3rpx solid #D1D5DB;
display: flex;
align-items: center;
justify-content: center;
font-size: 26rpx;
font-weight: 600;
color: #6B7280;
flex-shrink: 0;
}
.stage-content {
flex: 1;
}
.stage-item-title {
font-size: 28rpx;
font-weight: 600;
color: #111827;
display: block;
margin-bottom: 6rpx;
}
.stage-item-desc {
font-size: 24rpx;
color: #6B7280;
line-height: 1.4;
}
.stage-line {
width: 2rpx;
height: 32rpx;
background-color: #E5E7EB;
margin: 16rpx 0 16rpx 22rpx;
}
/* 每日目标 */
.daily-tips-card {
background-color: #FFFFFF;
border-radius: 24rpx;
padding: 28rpx;
border: 2rpx solid #ECFDF3;
box-shadow: 0 8rpx 22rpx rgba(16, 185, 129, 0.08);
}
.daily-tips-title {
font-size: 26rpx;
font-weight: 600;
color: #059669;
display: block;
margin-bottom: 12rpx;
}
.daily-tips-text {
font-size: 28rpx;
color: #111827;
line-height: 1.6;
}
/* 每日计划列表 */
.days-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.day-item {
background-color: #FFFFFF;
border-radius: 20rpx;
padding: 24rpx;
border: 2rpx solid #ECFDF3;
box-shadow: 0 6rpx 16rpx rgba(16, 185, 129, 0.08);
}
.day-item-today {
border-color: #10B981;
background: linear-gradient(135deg, #ECFDF5, #F0FDF4);
}
.day-item-past {
opacity: 0.7;
}
.day-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.day-number {
font-size: 28rpx;
font-weight: 600;
color: #111827;
}
.day-today-badge {
font-size: 22rpx;
color: #FFFFFF;
background-color: #10B981;
padding: 4rpx 12rpx;
border-radius: 12rpx;
}
.day-past-badge {
font-size: 22rpx;
color: #6B7280;
background-color: #F3F4F6;
padding: 4rpx 12rpx;
border-radius: 12rpx;
}
.day-target {
display: flex;
align-items: center;
gap: 8rpx;
margin-bottom: 8rpx;
}
.day-target-label {
font-size: 24rpx;
color: #6B7280;
}
.day-target-value {
font-size: 28rpx;
font-weight: 600;
color: #10B981;
}
.day-tip {
padding-top: 12rpx;
border-top: 1rpx solid #F3F4F6;
}
.day-tip-text {
font-size: 24rpx;
color: #6B7280;
line-height: 1.4;
}
.days-loading,
.days-empty {
background-color: #FFFFFF;
border-radius: 24rpx;
padding: 40rpx;
text-align: center;
}
.days-loading-text,
.days-empty-text {
font-size: 26rpx;
color: #6B7280;
}
/* 操作按钮 */
.actions {
margin-top: 32rpx;
padding-bottom: 40rpx;
}
.reset-btn {
background-color: #FFFFFF;
border: 2rpx solid #EF4444;
padding: 24rpx;
border-radius: 24rpx;
text-align: center;
}
.reset-btn-text {
font-size: 28rpx;
color: #EF4444;
}
/* 弹窗 */
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background-color: #FFFFFF;
border-radius: 28rpx;
width: 600rpx;
max-height: 70vh;
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx 28rpx 24rpx;
border-bottom: 1rpx solid #F3F4F6;
}
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: #111827;
}
.modal-close {
font-size: 40rpx;
color: #9CA3AF;
line-height: 1;
}
.modal-body {
padding: 28rpx;
}
.modal-item {
margin-bottom: 24rpx;
}
.modal-item:last-child {
margin-bottom: 0;
}
.modal-label {
font-size: 24rpx;
color: #6B7280;
display: block;
margin-bottom: 8rpx;
}
.modal-value {
font-size: 28rpx;
color: #111827;
line-height: 1.5;
}
</style>