Files
smt/pages/quit-plan/index.vue
T
nepiedg 525266afaf feat: 添加戒烟计划前端页面
- 新增 pages/quit-plan/index.vue 戒烟计划页面
- 展示30天戒烟计划总览和进度
- 显示当前阶段(记录期/减量期/巩固期)
- 展示每日目标和建议
- 支持生成计划和重置计划功能
- 在 api/smoke.js 添加相关 API 调用
- 在 pages.json 注册路由
2026-03-13 15:08:57 +08:00

824 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>