feat: 添加模式选择功能与页面更新

- 在 onboarding 页面中新增使用模式选择功能,用户可选择“戒烟打卡”或“记录抽烟”模式
- 更新个人资料页面以显示当前模式并允许用户切换模式
- 在 pages.json 中注册新的模式选择页面
- 优化首页和其他相关页面以适应新模式功能
This commit is contained in:
你çšnepiedg
2026-03-18 00:06:01 +08:00
parent d101515d8d
commit 31e504a997
10 changed files with 1818 additions and 465 deletions
+532
View File
@@ -0,0 +1,532 @@
# smt 双模式改造方案
> 创建时间:2026-03-17
> 状态:待开发
> 相关文档:[[戒烟产品分析-smt-vs-quit-checkin]]
---
## 📁 现有项目结构
```
smt/
├── api/ # 接口封装
│ ├── auth.js # 登录认证
│ ├── smoke.js # 抽烟记录 API
│ ├── profile.js # 用户资料 API
│ ├── request.js # 请求封装
│ └── index.js # 统一导出
├── components/ # 业务组件
│ └── smoke-record-dialog/ # 记录弹框组件
├── config/ # 环境配置
│ └── index.js # BASE_URL 配置
├── hooks/ # 组合式逻辑
│ └── useLogin.js # 登录相关 hook
├── pages/ # 页面
│ ├── index/ # 首页 ⭐ 需要改造
│ ├── stats/ # 统计页
│ ├── logs/ # 历史记录页
│ ├── ai/ # AI 建议
│ ├── ai_summary/ # AI 总结
│ ├── share/ # 分享页
│ ├── profile/ # 个人中心
│ ├── quit-plan/ # 戒烟计划
│ └── onboarding/ # 新用户引导 ⭐ 需要改造
├── stores/ # Pinia 状态管理
│ ├── user.js # 用户状态 ⭐ 需要扩展
│ ├── profile.js # 用户资料 ⭐ 需要扩展
│ ├── logs.js # 记录状态
│ ├── dashboard.js # 首页数据
│ └── index.js # 统一导出
├── utils/ # 工具函数
│ ├── storage.js # 本地存储 ⭐ 需要扩展
│ ├── time.js # 时间处理
│ └── format.js # 格式化
├── static/ # 静态资源
├── App.vue # 应用入口
├── main.js # 主入口
├── pages.json # 页面配置 ⭐ 需要修改
└── manifest.json # 小程序配置
```
---
## 🔧 需要修改/新增的文件
### 新增文件
| 文件路径 | 说明 |
|----------|------|
| `pages/mode-select/index.vue` | **新增** - 模式选择引导页 |
| `pages/quit-home/index.vue` | **新增** - 戒烟打卡模式首页 |
| `pages/record-home/index.vue` | **新增** - 记录抽烟模式首页 |
| `components/quit-checkin-btn/index.vue` | **新增** - 打卡按钮组件 |
| `components/simple-counter/index.vue` | **新增** - 简易计数器组件 |
### 需要修改的文件
| 文件路径 | 修改内容 |
|----------|----------|
| `stores/user.js` | 添加 `mode` 字段存储用户模式 |
| `stores/profile.js` | 添加 `quitDays` 戒烟天数计算 |
| `utils/storage.js` | 添加 `USER_MODE_KEY` 常量 |
| `pages.json` | 添加新页面路由,修改首页逻辑 |
| `pages/onboarding/index.vue` | 完成引导后跳转到模式选择页 |
| `pages/profile/index.vue` | 添加模式切换入口 |
| `pages/index/index.vue` | 根据模式渲染不同首页(可选方案) |
---
## 📝 详细代码修改
### 1. `utils/storage.js` - 新增常量
```javascript
export const USER_MODE_KEY = 'user_mode' // 新增
```
---
### 2. `stores/user.js` - 扩展用户模式
```javascript
import { defineStore } from 'pinia'
import { storage, USER_KEY, SESSION_KEY, USER_MODE_KEY } from '@/utils/storage'
export const useUserStore = defineStore('user', {
state: () => ({
user: storage.get(USER_KEY),
sessionKey: storage.get(SESSION_KEY),
isLoggedIn: !!storage.get(SESSION_KEY),
mode: storage.get(USER_MODE_KEY) || null // 'quit' | 'record' | null
}),
actions: {
setUser(user, sessionKey) {
this.user = user
this.sessionKey = sessionKey
this.isLoggedIn = true
storage.set(USER_KEY, user)
storage.set(SESSION_KEY, sessionKey)
},
setMode(mode) {
this.mode = mode
storage.set(USER_MODE_KEY, mode)
},
logout() {
this.user = null
this.sessionKey = null
this.isLoggedIn = false
storage.remove(USER_KEY)
storage.remove(SESSION_KEY)
}
}
})
```
---
### 3. `pages.json` - 添加新页面
```json
{
"pages": [
{
"path": "pages/mode-select/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/quit-home/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/record-home/index",
"style": {
"navigationBarTitleText": "记录抽烟"
}
},
{
"path": "pages/index/index",
"style": {
"navigationStyle": "custom"
}
}
]
}
```
---
### 4. `pages/mode-select/index.vue` - 模式选择页
```vue
<template>
<view class="page">
<view class="content">
<text class="title">你想怎么戒烟</text>
<text class="subtitle">选择适合你的方式</text>
<view class="options">
<view class="option option-quit" @tap="selectMode('quit')">
<text class="option-icon">🌟</text>
<view class="option-text">
<text class="option-title">我要戒烟打卡</text>
<text class="option-desc">记录坚持的天数获得成就感</text>
</view>
</view>
<view class="option option-record" @tap="selectMode('record')">
<text class="option-icon">📊</text>
<view class="option-text">
<text class="option-title">我要记录抽烟</text>
<text class="option-desc">跟踪抽烟频率分析戒烟进度</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
function selectMode(mode) {
userStore.setMode(mode)
if (mode === 'quit') {
uni.redirectTo({ url: '/pages/quit-home/index' })
} else {
uni.redirectTo({ url: '/pages/record-home/index' })
}
}
</script>
```
---
### 5. `pages/quit-home/index.vue` - 戒烟打卡首页
**界面设计:**
```
┌─────────────────────────┐
│ 🔥 已坚持 23 天 │
│ │
│ ┌─────────┐ │
│ │ 打卡 │ │
│ │ 今天没抽│ │
│ └─────────┘ │
│ │
│ 💰 已省下 184 元 │
│ 🫁 肺部正在恢复中... │
│ │
│ ───────────────────── │
│ 今日打卡 ✓ 08:30 │
└─────────────────────────┘
```
**核心代码:**
```vue
<template>
<view class="page">
<view class="header">
<text class="days-label">已坚持</text>
<text class="days-value">{{ quitDays }}</text>
<text class="days-unit"></text>
</view>
<view class="checkin-section">
<view class="checkin-btn" :class="{ 'checked': todayChecked }" @tap="checkin">
<text class="checkin-icon">{{ todayChecked ? '✓' : '🔥' }}</text>
<text class="checkin-text">{{ todayChecked ? '今日已打卡' : '打卡' }}</text>
</view>
</view>
<view class="stats-row">
<view class="stat-item">
<text class="stat-value">¥{{ savedMoney }}</text>
<text class="stat-label">已省下</text>
</view>
<view class="stat-item">
<text class="stat-value">{{ healthProgress }}%</text>
<text class="stat-label">健康恢复</text>
</view>
</view>
<view class="health-tips">
<text class="tip-icon">🫁</text>
<text class="tip-text">{{ healthTip }}</text>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
import { useProfileStore } from '@/stores/profile'
const userStore = useUserStore()
const profileStore = useProfileStore()
const todayChecked = ref(false)
const quitStartDate = ref(null)
const quitDays = computed(() => {
if (!quitStartDate.value) return 0
const diff = Date.now() - new Date(quitStartDate.value).getTime()
return Math.floor(diff / (1000 * 60 * 60 * 24))
})
const savedMoney = computed(() => {
const pricePerPack = profileStore.profile?.pack_price_cent || 2500
const cigsPerDay = profileStore.profile?.baseline_cigs_per_day || 10
const savedCigs = quitDays.value * cigsPerDay
return ((savedCigs / 20) * (pricePerPack / 100)).toFixed(0)
})
const healthProgress = computed(() => {
if (quitDays.value >= 365) return 100
if (quitDays.value >= 180) return 85
if (quitDays.value >= 90) return 70
if (quitDays.value >= 30) return 50
if (quitDays.value >= 14) return 30
if (quitDays.value >= 7) return 15
return 5
})
const healthTip = computed(() => {
if (quitDays.value >= 365) return '恭喜!你的肺部功能已基本恢复'
if (quitDays.value >= 180) return '你的血液循环已显著改善'
if (quitDays.value >= 30) return '你的味觉和嗅觉正在恢复'
if (quitDays.value >= 7) return '你的肺活量开始增加'
return '坚持下去,身体正在恢复中'
})
function checkin() {
if (todayChecked.value) return
todayChecked.value = true
// TODO: 调用 API 保存打卡记录
uni.showToast({ title: '打卡成功!', icon: 'success' })
}
onMounted(() => {
// 获取戒烟开始日期
// TODO: 从 API 或本地存储获取
})
</script>
```
---
### 6. `pages/record-home/index.vue` - 记录抽烟首页
**界面设计:**
```
┌─────────────────────────┐
│ 今天抽了 3 根 │
│ │
│ ┌─────────┐ │
│ │ +1 根 │ │
│ │ 点击记录│ │
│ └─────────┘ │
│ │
│ 昨日:4 根 │
│ 本周:18 根 │
│ │
│ ───────────────────── │
│ 历史记录 > │
└─────────────────────────┘
```
**核心代码:**
```vue
<template>
<view class="page">
<view class="counter-section">
<text class="counter-label">今天抽了</text>
<text class="counter-value">{{ todayCount }}</text>
<text class="counter-unit"></text>
</view>
<view class="add-btn" @tap="addOne">
<text class="add-icon">+</text>
<text class="add-text">记录一根</text>
</view>
<view class="summary">
<view class="summary-item">
<text class="summary-label">昨日</text>
<text class="summary-value">{{ yesterdayCount }} </text>
</view>
<view class="summary-item">
<text class="summary-label">本周</text>
<text class="summary-value">{{ weekCount }} </text>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import * as api from '@/api'
const todayCount = ref(0)
const yesterdayCount = ref(0)
const weekCount = ref(0)
async function addOne() {
todayCount.value++
// TODO: 调用 API 记录
try {
await api.createLog({
num: 1,
smoke_time: new Date().toISOString(),
smoke_at: new Date().toISOString()
})
uni.showToast({ title: '已记录', icon: 'success', duration: 1000 })
} catch (e) {
console.error('记录失败:', e)
}
}
</script>
<style scoped>
.add-btn {
width: 300rpx;
height: 300rpx;
border-radius: 50%;
background: linear-gradient(135deg, #EF4444, #F87171);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 60rpx auto;
box-shadow: 0 20rpx 40rpx rgba(239, 68, 68, 0.3);
}
.add-icon {
font-size: 80rpx;
color: #FFF;
font-weight: 300;
}
.add-text {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
margin-top: 8rpx;
}
</style>
```
---
### 7. `pages/profile/index.vue` - 添加模式切换
在个人中心添加:
```vue
<view class="menu-item" @tap="showModeSwitch">
<text class="menu-icon"></text>
<text class="menu-text">模式切换</text>
<text class="menu-value">{{ modeText }}</text>
</view>
<script setup>
import { computed } from 'vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const modeText = computed(() => {
return userStore.mode === 'quit' ? '戒烟打卡' : '记录抽烟'
})
function showModeSwitch() {
uni.showActionSheet({
itemList: ['戒烟打卡模式', '记录抽烟模式'],
success: (res) => {
const modes = ['quit', 'record']
userStore.setMode(modes[res.tapIndex])
uni.showToast({ title: '已切换', icon: 'success' })
}
})
}
</script>
```
---
## 📋 开发顺序
| 阶段 | 任务 | 预估时间 | 状态 |
|------|------|----------|------|
| **第一阶段** | 1. 修改 `utils/storage.js`<br>2. 修改 `stores/user.js`<br>3. 创建 `pages/mode-select/index.vue`<br>4. 修改 `pages.json` | 1-2h | ⬜ 待开始 |
| **第二阶段** | 1. 创建 `pages/quit-home/index.vue`<br>2. 打卡 API 对接<br>3. 省钱金额计算 | 2-3h | ⬜ 待开始 |
| **第三阶段** | 1. 创建 `pages/record-home/index.vue`<br>2. 一键记录 API 对接 | 1-2h | ⬜ 待开始 |
| **第四阶段** | 1. 修改 `pages/profile/index.vue` 模式切换<br>2. 修改引导流程跳转逻辑 | 1h | ⬜ 待开始 |
**总预估:5-8 小时**
---
## ⚠️ 注意事项
### 1. TabBar 问题
如果要让两种模式共用 TabBar,首页需要根据 mode 动态渲染:
```vue
<!-- pages/index/index.vue -->
<template>
<QuitHome v-if="userStore.mode === 'quit'" />
<RecordHome v-else />
</template>
```
### 2. 数据兼容
老用户没有 mode 字段,需要引导选择模式:
```javascript
// App.vue 或首页
if (!userStore.mode) {
uni.redirectTo({ url: '/pages/mode-select/index' })
}
```
### 3. API 对接
打卡功能需要后端新增接口:
```
POST /api/checkin
{
"date": "2026-03-17",
"quit_days": 23
}
```
或复用现有 resisted 接口。
---
## 🔗 相关文档
- [[戒烟产品分析-smt-vs-quit-checkin]]
- [[smt - 技术文档]]
- [[quit-checkin - 技术文档]]
---
#技术开发 #smt #戒烟 #小程序 #双模式
@@ -0,0 +1,303 @@
# 戒烟产品分析 - smt vs quit-checkin
> 创建时间:2026-03-17
> 状态:进行中
> 相关项目:[[smt]]、[[quit-checkin]]
---
## 📊 产品对比
### 基本信息
| 维度 | smt(戒烟助手) | quit-checkin(无烟打卡) |
|------|----------------|------------------------|
| **定位** | 戒烟助手(完整版) | 无烟打卡(轻量版) |
| **技术栈** | uni-app + Vue 3 + Pinia | uni-app + Vue 3 + Vite |
| **功能丰富度** | 高 | 低 |
| **当前问题** | 日活少、推广难、记录违背人性 | 功能单一 |
### 核心功能对比
| 功能 | smt | quit-checkin |
|------|-----|--------------|
| 记录抽烟 | ✅ 详细记录(数量、等级、备注) | ❌ |
| 记录忍住 | ✅ | ❌ |
| 打卡 | ❌ | ✅ 核心功能 |
| 统计分析 | ✅ 周/月/年趋势 | ✅ 基础统计 |
| AI 建议 | ✅ | ❌ |
| 健康恢复指标 | ✅ | ❌ |
| 省钱计算 | ✅ | ❌ |
| 戒烟计划 | ✅ | ❌ |
| 梦想激励 | ❌ | ✅ |
| 分享海报 | ❌ | ✅ |
---
## 🎯 产品定位差异
### smt(戒烟助手)
- **核心理念**:数据驱动戒烟
- **用户行为**:主动记录抽烟/忍住
- **心理感受**:审计失败(每次记录都是承认失败)
- **价值点**:数据分析、AI建议、健康指标
### quit-checkin(无烟打卡)
- **核心理念**:正向激励戒烟
- **用户行为**:每日打卡
- **心理感受**:奖励成功(每次打卡都是成就积累)
- **价值点**:天数累计、省钱金额、分享炫耀
---
## 👥 目标用户分析
### smt 用户画像
- 25-45岁,有一定烟龄
- 想系统化戒烟,需要数据和指导
- 愿意投入时间精力管理戒烟过程
- 对数据分析有需求
### quit-checkin 用户画像
- 年轻用户,轻度戒烟需求
- 想简单打卡记录
- 注重社交分享和成就感
- 使用场景:每日打卡、简单记录
---
## 📈 获取用户难度对比
| 维度 | smt | quit-checkin | 评分 |
|------|-----|--------------|------|
| **获客门槛** | 中等(需引导流程) | 低(即开即用) | QC胜 ⭐⭐⭐⭐ |
| **分享裂变** | 弱(展示失败) | 强(海报分享) | QC胜 ⭐⭐⭐⭐ |
| **首次体验** | 需完成引导问卷 | 直接打卡 | QC胜 ⭐⭐⭐⭐ |
| **推广成本** | 高 | 低(自带裂变) | QC胜 ⭐⭐⭐⭐⭐ |
**结论:quit-checkin 更容易获取用户**
---
## 💪 用户活跃度/留存潜力对比
| 维度 | smt | quit-checkin | 评分 |
|------|-----|--------------|------|
| **核心钩子** | AI建议、健康指标 | 打卡成就感 | smt胜 ⭐⭐⭐⭐ |
| **回访理由** | 统计、省钱金额 | 天数累计 | smt胜 ⭐⭐⭐⭐ |
| **社交属性** | 弱 | 海报分享,较强 | QC胜 ⭐⭐⭐ |
| **激励体系** | 数据驱动 | 正向激励 | 平手 ⭐⭐⭐ |
| **长期留存** | 高(功能丰富) | 中(功能单一) | smt胜 ⭐⭐⭐⭐⭐ |
**结论:smt 活跃度潜力更高**
---
## 💰 商业化潜力对比
| 维度 | smt | quit-checkin | 评分 |
|------|-----|--------------|------|
| **付费场景** | 高级AI、戒烟计划、专家咨询 | 会员特权、定制海报 | smt胜 ⭐⭐⭐⭐⭐ |
| **广告场景** | 戒烟产品、健康产品 | 戒烟产品 | 平手 ⭐⭐⭐ |
| **增值服务** | 数据报告、专家咨询 | 较少 | smt胜 ⭐⭐⭐⭐ |
**结论:smt 商业化潜力更高**
---
## 🔍 核心问题诊断
### 为什么 smt 日活少?
#### 人性因素分析
| 人性因素 | 具体表现 |
|----------|----------|
| **懒惰** | 每次抽烟都要打开小程序记录,太麻烦 |
| **遗忘** | 抽烟是无意识行为,抽完就忘了记录 |
| **逃避心理** | 记录=承认自己又抽了,心理负担重 |
| **即时反馈弱** | 记录后没有即时奖励,动力不足 |
| **挫败感** | 越记录越发现自己抽得多,想放弃 |
**本质问题:产品要求用户"主动做额外的事",而人是厌恶"额外负担"的。**
---
## 💡 PM 建议(2026-03-17
### 🎯 决策:全力转向 quit-checkin
**核心理由:戒烟产品本质是"情绪按摩",不是"数据监控"**
| 维度 | smt | quit-checkin |
|------|-----|--------------|
| **心理契合** | 记录=审计失败 ❌ | 打卡=奖励成功 ✅ |
| **传播力** | 没人分享抽烟记录 ❌ | 愿意分享"戒烟100天" ✅ |
| **维护成本** | AI+统计,成本高 ❌ | 逻辑简单,好维护 ✅ |
| **获客成本** | 高 ❌ | 自带裂变,0成本 ✅ |
### 具体操作建议
#### 1. 不要废弃 smt,而是"取其精华"
把 smt 的高价值功能**降维**嵌入 quit-checkin
| smt 功能 | 如何嵌入 quit-checkin |
|----------|----------------------|
| 健康恢复指标 | 打卡满X天解锁视图 |
| AI 建议 | 只在"快忍不住了"时弹出应急 |
| 省钱账单 | 打卡页实时显示"已省XX元" |
#### 2. 运营迁移
- smt 首页加提示:**"轻量版上线,试试更简单的无烟打卡"**
- 老用户可将 smt 数据同步到 quit-checkin(保留成就感)
#### 3. quit-checkin 迭代重点
- **视觉溢价**:把分享海报做得高级,让用户有面子
- **梦想激励**:打卡省下的钱填补梦想进度条(如:给女儿买钢琴)
- **仪式感**:打卡动效、勋章音效,让用户对"点一下"上瘾
---
## 🚀 改造方案:smt 双模式
### 核心思路
在现有 smt 基础上快速迭代,而不是推倒重来:
1. **首次启动引导**:让用户选择"戒烟打卡"或"记录抽烟"
2. **根据选择切换首页**:不同模式展示不同界面
3. **保留历史数据**:用户历史数据兼容
### 优势
| 维度 | 说明 |
|------|------|
| **用户不流失** | 现有用户平滑过渡,无需迁移 |
| **开发成本低** | 复用现有代码,增量修改 |
| **数据保留** | 用户历史数据不丢失 |
| **快速验证** | 改动小,可以快速上线测试 |
---
### 首次启动引导设计
```
┌─────────────────────────┐
│ 你想怎么戒烟? │
│ │
│ ┌───────────────────┐ │
│ │ 🌟 我要戒烟打卡 │ │
│ │ 记录坚持的天数 │ │
│ └───────────────────┘ │
│ │
│ ┌───────────────────┐ │
│ │ 📊 我要记录抽烟 │ │
│ │ 跟踪抽烟频率 │ │
│ └───────────────────┘ │
│ │
└─────────────────────────┘
```
---
### 模式一:戒烟打卡(quit-checkin 风格)
**首页改造:**
```
┌─────────────────────────┐
│ 🔥 已坚持 23 天 │
│ │
│ ┌─────────┐ │
│ │ 打卡 │ │
│ │ 今天没抽│ │
│ └─────────┘ │
│ │
│ 💰 已省下 184 元 │
│ 🫁 肺部正在恢复中... │
│ │
│ ───────────────────── │
│ 今日打卡 ✓ 08:30 │
└─────────────────────────┘
```
---
### 模式二:记录抽烟(精简版)
**首页改造:**
```
┌─────────────────────────┐
│ 今天抽了 3 根 │
│ │
│ ┌─────────┐ │
│ │ +1 根 │ │
│ │ 点击记录│ │
│ └─────────┘ │
│ │
│ 昨日:4 根 │
│ 本周:18 根 │
│ │
│ ───────────────────── │
│ 历史记录 > │
└─────────────────────────┘
```
**点击按钮 → 直接 +1,无需弹框**
---
## 📋 开发清单
### 第一阶段:引导 + 模式切换
| 任务 | 预估时间 | 状态 |
|------|----------|------|
| 新增引导页 | 2h | ⬜ 待开始 |
| 用户模式存储 | 0.5h | ⬜ 待开始 |
| 首页条件渲染 | 1h | ⬜ 待开始 |
| 模式切换入口(个人中心) | 1h | ⬜ 待开始 |
### 第二阶段:戒烟打卡模式
| 任务 | 预估时间 | 状态 |
|------|----------|------|
| 打卡按钮 + 动效 | 2h | ⬜ 待开始 |
| 天数累计展示 | 1h | ⬜ 待开始 |
| 省钱金额计算 | 0.5h | ⬜ 待开始 |
| 健康恢复指标(从 smt 搬运) | 2h | ⬜ 待开始 |
### 第三阶段:记录抽烟模式精简
| 任务 | 预估时间 | 状态 |
|------|----------|------|
| 首页大按钮 +1 | 1h | ⬜ 待开始 |
| 精简历史记录页 | 1h | ⬜ 待开始 |
| 统计页精简 | 1h | ⬜ 待开始 |
---
## 🗓️ 下一步行动
| 时间 | 行动 | 状态 |
|------|------|------|
| **本周** | 停止 smt 新功能开发,仅维持运行 | ⬜ 待开始 |
| **下周** | 优化 quit-checkin 分享海报,搬运 smt 健康指标 | ⬜ 待开始 |
| **下个月** | 小红书/即刻以"自律打卡"推广 quit-checkin | ⬜ 待开始 |
---
## 📎 相关文档
- [[smt - 技术文档]]
- [[quit-checkin - 技术文档]]
- [[戒烟产品市场调研]]
- [[微信小程序运营策略]]
---
#产品分析 #戒烟 #小程序 #smt #quit-checkin
+6
View File
@@ -6,6 +6,12 @@
}
},
"pages": [
{
"path": "pages/mode-select/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/index/index",
"style": {
+481 -417
View File
File diff suppressed because it is too large Load Diff
+219
View File
@@ -0,0 +1,219 @@
<template>
<view class="page">
<view class="nav-placeholder" :style="{ height: navBarHeight + 'px' }"></view>
<view class="content">
<view class="hero">
<text class="eyebrow">首次进入先选模式</text>
<text class="title">你现在想怎么用这个小程序</text>
<text class="subtitle">先选戒烟打卡记录抽烟后续可以在个人中心随时切换</text>
</view>
<view
class="mode-card"
:class="{ 'mode-card-active': currentMode === 'quit' }"
@tap="selectMode('quit')"
>
<view class="mode-icon mode-icon-quit">🔥</view>
<view class="mode-main">
<text class="mode-title">戒烟打卡</text>
<text class="mode-desc">按天记录今天没抽用连续天数驱动坚持</text>
</view>
<text class="mode-arrow"></text>
</view>
<view
class="mode-card"
:class="{ 'mode-card-active': currentMode === 'record' }"
@tap="selectMode('record')"
>
<view class="mode-icon mode-icon-record">🚬</view>
<view class="mode-main">
<text class="mode-title">记录抽烟</text>
<text class="mode-desc">继续按支数记录观察自己的频率和变化趋势</text>
</view>
<text class="mode-arrow"></text>
</view>
<text class="footer-tip">当前选择{{ currentModeText }}</text>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useLogin } from '@/hooks/useLogin'
import { useUserStore } from '@/stores/user'
import { useProfileStore } from '@/stores/profile'
const userStore = useUserStore()
const profileStore = useProfileStore()
const { waitForLogin } = useLogin()
const navBarHeight = ref(0)
const submitting = ref(false)
const currentMode = computed(() => userStore.mode)
const currentModeText = computed(() => {
if (userStore.mode === 'quit') return '戒烟打卡'
if (userStore.mode === 'record') return '记录抽烟'
return '未选择'
})
function setupNavBar() {
const sys = uni.getSystemInfoSync()
const statusBarH = sys.statusBarHeight || 0
try {
const menuBtn = uni.getMenuButtonBoundingClientRect()
navBarHeight.value = menuBtn.bottom + (menuBtn.top - statusBarH)
} catch (e) {
navBarHeight.value = statusBarH + 44
}
}
async function selectMode(mode) {
if (submitting.value) return
submitting.value = true
userStore.setMode(mode)
try {
const profileData = await profileStore.saveProfile({ mode })
const profile = profileData.profile
const isCompleted = profileData.is_completed ||
(profile && profile.onboarding_completed_at) ||
(profile && profile.baseline_cigs_per_day > 0)
if (!profileData.exists || !isCompleted) {
uni.redirectTo({ url: '/pages/onboarding/index' })
return
}
uni.switchTab({ url: '/pages/index/index' })
} catch (e) {
console.error('selectMode error:', e)
uni.switchTab({ url: '/pages/index/index' })
} finally {
submitting.value = false
}
}
onMounted(async () => {
setupNavBar()
await waitForLogin()
})
</script>
<style scoped>
.page {
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(16, 185, 129, 0.18), transparent 34%),
linear-gradient(180deg, #ecfdf5 0%, #f7fee7 42%, #ffffff 100%);
}
.nav-placeholder {
background: transparent;
}
.content {
padding: 40rpx 32rpx 56rpx;
}
.hero {
margin-bottom: 40rpx;
}
.eyebrow {
display: inline-flex;
padding: 8rpx 18rpx;
border-radius: 999rpx;
background: rgba(16, 185, 129, 0.12);
color: #047857;
font-size: 22rpx;
margin-bottom: 20rpx;
}
.title {
display: block;
font-size: 48rpx;
font-weight: 700;
color: #111827;
line-height: 1.28;
}
.subtitle {
display: block;
margin-top: 16rpx;
font-size: 28rpx;
line-height: 1.6;
color: #4b5563;
}
.mode-card {
display: flex;
align-items: center;
gap: 24rpx;
padding: 30rpx 28rpx;
margin-bottom: 24rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.82);
border: 2rpx solid rgba(255, 255, 255, 0.6);
box-shadow: 0 16rpx 44rpx rgba(15, 23, 42, 0.08);
}
.mode-card-active {
border-color: rgba(16, 185, 129, 0.35);
box-shadow: 0 20rpx 52rpx rgba(16, 185, 129, 0.14);
}
.mode-icon {
width: 92rpx;
height: 92rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 42rpx;
flex-shrink: 0;
}
.mode-icon-quit {
background: linear-gradient(135deg, #d1fae5 0%, #86efac 100%);
}
.mode-icon-record {
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
}
.mode-main {
flex: 1;
}
.mode-title {
display: block;
font-size: 34rpx;
font-weight: 700;
color: #111827;
}
.mode-desc {
display: block;
margin-top: 10rpx;
font-size: 25rpx;
line-height: 1.5;
color: #6b7280;
}
.mode-arrow {
font-size: 42rpx;
color: #9ca3af;
}
.footer-tip {
display: block;
margin-top: 20rpx;
font-size: 24rpx;
color: #6b7280;
text-align: center;
}
</style>
+122 -4
View File
@@ -10,9 +10,25 @@
</view>
<view class="content">
<view class="mode-section">
<text class="mode-section-label">使用模式</text>
<view class="mode-switch">
<view
v-for="item in modeOptions"
:key="item.value"
class="mode-switch-item"
:class="{ 'mode-switch-item-active': currentMode === item.value }"
@tap="selectMode(item.value)"
>
<text class="mode-switch-title">{{ item.label }}</text>
<text class="mode-switch-desc">{{ item.desc }}</text>
</view>
</view>
</view>
<view v-if="step === 1" class="step">
<text class="step-title">你每天抽多少支烟</text>
<text class="step-desc">这将帮助我们为你制定个性化的戒烟计划</text>
<text class="step-desc">{{ baselineDesc }}</text>
<view class="input-group">
<view class="input-row">
<view class="input-btn" @tap="decreaseCigs">-</view>
@@ -40,8 +56,8 @@
</view>
<view v-if="step === 3" class="step">
<text class="step-title">你为什么想戒烟</text>
<text class="step-desc">选择对你最重要的原因可多选</text>
<text class="step-title">{{ motivationTitle }}</text>
<text class="step-desc">{{ motivationDesc }}</text>
<view class="options options-wrap">
<view
v-for="option in quitMotivationOptions"
@@ -96,7 +112,7 @@
<view class="footer">
<view v-if="step > 1" class="btn-secondary" @tap="prevStep">上一步</view>
<view class="btn-primary" :class="{ 'btn-full': step === 1 }" @tap="nextStep">
{{ step === 5 ? '开始戒烟之旅 🚀' : '下一步' }}
{{ step === 5 ? finishButtonText + ' 🚀' : '下一步' }}
</view>
</view>
</view>
@@ -106,16 +122,24 @@
import { ref, computed, onMounted } from 'vue'
import { onShareAppMessage } from '@dcloudio/uni-app'
import { useProfileStore } from '@/stores/profile'
import { useUserStore } from '@/stores/user'
import { useLogin } from '@/hooks/useLogin'
const profileStore = useProfileStore()
const userStore = useUserStore()
const { waitForLogin } = useLogin()
const navBarHeight = ref(0)
const step = ref(1)
const totalSteps = 5
const modeSaving = ref(false)
const modeOptions = [
{ value: 'quit', label: '戒烟打卡', desc: '按天记录今天没抽' },
{ value: 'record', label: '记录抽烟', desc: '按支数记录变化' }
]
const formData = ref({
mode: 'record',
baseline_cigs_per_day: 10,
smoking_years: 5,
quit_motivations: [],
@@ -128,6 +152,12 @@ const formData = ref({
const priceYuan = ref('25')
const progressWidth = computed(() => `${(step.value / totalSteps) * 100}%`)
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 = [
{ label: '少于1年', value: 1 },
@@ -165,6 +195,20 @@ function toggleMotivation(option) {
}
}
async function selectMode(mode) {
formData.value.mode = mode
userStore.setMode(mode)
if (!profileStore.exists || modeSaving.value) return
modeSaving.value = true
try {
await profileStore.saveProfile({ mode })
} catch (e) {
console.error('saveModeInOnboarding error:', e)
} finally {
modeSaving.value = false
}
}
function onWakeTimeChange(e) {
formData.value.wake_up_time = e.detail.value
}
@@ -191,6 +235,10 @@ async function nextStep() {
uni.showLoading({ title: '保存中...' })
await profileStore.saveProfile(formData.value)
uni.hideLoading()
if (!formData.value.mode) {
uni.redirectTo({ url: '/pages/mode-select/index' })
return
}
uni.switchTab({ url: '/pages/index/index' })
} catch (e) {
uni.hideLoading()
@@ -208,6 +256,30 @@ onMounted(async () => {
navBarHeight.value = statusBarH + 44
}
await waitForLogin()
try {
const profileData = await profileStore.fetchProfile()
if (profileData?.profile) {
const profile = profileData.profile
formData.value = {
...formData.value,
mode: profile.mode || userStore.mode || formData.value.mode,
baseline_cigs_per_day: profile.baseline_cigs_per_day || formData.value.baseline_cigs_per_day,
smoking_years: profile.smoking_years || formData.value.smoking_years,
quit_motivations: Array.isArray(profile.quit_motivations) ? profile.quit_motivations : formData.value.quit_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,
sleep_time: profile.sleep_time || formData.value.sleep_time,
pack_price_cent: profile.pack_price_cent || formData.value.pack_price_cent
}
if (profile.pack_price_cent) {
priceYuan.value = String((profile.pack_price_cent / 100).toFixed(2)).replace(/\.00$/, '')
}
} else if (userStore.mode) {
formData.value.mode = userStore.mode
}
} catch (e) {
console.error('loadProfileForOnboarding error:', e)
}
})
onShareAppMessage(() => {
@@ -270,6 +342,52 @@ onShareAppMessage(() => {
justify-content: center;
}
.mode-section {
margin-bottom: 40rpx;
}
.mode-section-label {
display: block;
margin-bottom: 16rpx;
font-size: 24rpx;
font-weight: 600;
color: #047857;
}
.mode-switch {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16rpx;
}
.mode-switch-item {
padding: 24rpx;
border-radius: 20rpx;
background: rgba(255, 255, 255, 0.82);
border: 2rpx solid #d1fae5;
box-shadow: 0 8rpx 20rpx rgba(16, 185, 129, 0.08);
}
.mode-switch-item-active {
background: #ecfdf5;
border-color: #10b981;
}
.mode-switch-title {
display: block;
font-size: 28rpx;
font-weight: 700;
color: #111827;
}
.mode-switch-desc {
display: block;
margin-top: 10rpx;
font-size: 22rpx;
line-height: 1.5;
color: #6b7280;
}
.step {
animation: fadeIn 0.3s ease;
}
+114 -23
View File
@@ -6,25 +6,30 @@
</view>
<view class="section">
<view class="mode-card">
<view class="mode-card-header">
<view class="menu-icon menu-icon-green">🧭</view>
<view class="menu-content">
<text class="menu-label">打卡模式</text>
<text class="menu-desc">直接切换成戒烟打卡记录抽烟</text>
</view>
</view>
<view class="mode-switch">
<view
v-for="item in modeOptions"
:key="item.value"
class="mode-switch-item"
:class="{ 'mode-switch-item-active': userStore.mode === item.value }"
@tap="changeMode(item.value)"
>
<text class="mode-switch-title">{{ item.label }}</text>
<text class="mode-switch-desc">{{ item.desc }}</text>
</view>
</view>
<text class="mode-hint">当前{{ modeText }}</text>
</view>
<view class="menu-list">
<view class="menu-item" @tap="goAISuggest">
<view class="menu-icon menu-icon-green">🤖</view>
<view class="menu-content">
<text class="menu-label">AI 建议</text>
<text class="menu-desc">查看今日 AI 控烟节奏与建议节点</text>
</view>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @tap="goAISummary">
<view class="menu-icon menu-icon-green">📝</view>
<view class="menu-content">
<text class="menu-label">AI 总结</text>
<text class="menu-desc">按日期生成抽烟总结和明日建议</text>
</view>
<text class="menu-arrow"></text>
</view>
<view class="menu-item">
<view class="menu-icon menu-icon-green">🔗</view>
<view class="menu-content">
@@ -79,18 +84,30 @@
import { computed, ref } from 'vue'
import { onShareAppMessage, onShow } from '@dcloudio/uni-app'
import * as api from '@/api'
import { useProfileStore } from '@/stores/profile'
import { useUserStore } from '@/stores/user'
import { useLogin } from '@/hooks/useLogin'
const profileStore = useProfileStore()
const userStore = useUserStore()
const { waitForLogin } = useLogin()
const shareToken = ref('')
const shareExpireAt = ref('')
const shareLoading = ref(false)
const modeSaving = ref(false)
const modeOptions = [
{ value: 'quit', label: '戒烟打卡', desc: '按天记录今天没抽' },
{ value: 'record', label: '记录抽烟', desc: '按支数记录变化' }
]
const userName = computed(() => userStore.user?.nickname || '戒烟用户')
const userAvatar = computed(() => userStore.user?.avatar_url || 'https://linghu-wmr.oss-cn-beijing.aliyuncs.com/smt/avatar.png')
const modeText = computed(() => {
if (userStore.mode === 'quit') return '戒烟打卡'
if (userStore.mode === 'record') return '记录抽烟'
return '未选择'
})
const shareDesc = computed(() => {
if (!shareToken.value) {
@@ -152,12 +169,23 @@ function previewSharePage() {
})
}
function goAISuggest() {
uni.navigateTo({ url: '/pages/ai/index' })
async function changeMode(nextMode) {
if (!nextMode || nextMode === userStore.mode || modeSaving.value) return
modeSaving.value = true
try {
uni.showLoading({ title: '切换中...' })
await profileStore.saveProfile({ mode: nextMode })
uni.hideLoading()
uni.showToast({ title: '模式已切换', icon: 'success' })
setTimeout(() => {
uni.switchTab({ url: '/pages/index/index' })
}, 250)
} catch (e) {
uni.hideLoading()
uni.showToast({ title: '切换失败', icon: 'none' })
} finally {
modeSaving.value = false
}
function goAISummary() {
uni.navigateTo({ url: '/pages/ai_summary/index' })
}
function goOnboarding() {
@@ -199,6 +227,7 @@ onShareAppMessage(() => {
onShow(async () => {
await waitForLogin()
await profileStore.fetchProfile()
await prepareShareToken(false)
})
</script>
@@ -238,6 +267,22 @@ onShow(async () => {
margin-bottom: 24rpx;
}
.mode-card {
background-color: #FFFFFF;
border-radius: 24rpx;
padding: 28rpx 24rpx;
border: 2rpx solid #ECFDF3;
box-shadow: 0 8rpx 20rpx rgba(16, 185, 129, 0.08);
margin-bottom: 16rpx;
}
.mode-card-header {
display: flex;
align-items: center;
gap: 24rpx;
margin-bottom: 20rpx;
}
.menu-list {
display: flex;
flex-direction: column;
@@ -313,6 +358,52 @@ onShow(async () => {
color: #9CA3AF;
}
.menu-value {
font-size: 24rpx;
font-weight: 600;
color: #10B981;
}
.mode-switch {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16rpx;
}
.mode-switch-item {
padding: 22rpx 20rpx;
border-radius: 18rpx;
background: #F9FAFB;
border: 2rpx solid #E5E7EB;
}
.mode-switch-item-active {
background: #ECFDF5;
border-color: #10B981;
}
.mode-switch-title {
display: block;
font-size: 28rpx;
font-weight: 700;
color: #111827;
}
.mode-switch-desc {
display: block;
margin-top: 8rpx;
font-size: 22rpx;
line-height: 1.5;
color: #6B7280;
}
.mode-hint {
display: block;
margin-top: 16rpx;
font-size: 22rpx;
color: #10B981;
}
.share-btn {
margin: 0;
padding: 10rpx 20rpx;
+9
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { storage, PROFILE_KEY } from '@/utils/storage'
import { getProfile, updateProfile } from '@/api/profile'
import { useUserStore } from '@/stores/user'
export const useProfileStore = defineStore('profile', {
state: () => ({
@@ -19,6 +20,7 @@ export const useProfileStore = defineStore('profile', {
async fetchProfile() {
try {
const res = await getProfile()
const userStore = useUserStore()
this.exists = res.data.exists
this.awakeMinutes = res.data.awake_minutes || 960
this.baselineIntervalMinutes = res.data.baseline_interval_minutes || 60
@@ -26,6 +28,9 @@ export const useProfileStore = defineStore('profile', {
if (res.data.profile) {
this.profile = res.data.profile
storage.set(PROFILE_KEY, res.data.profile)
if (res.data.profile.mode) {
userStore.setMode(res.data.profile.mode)
}
this.isCompleted = res.data.is_completed ||
!!res.data.profile.onboarding_completed_at ||
res.data.profile.baseline_cigs_per_day > 0
@@ -43,10 +48,14 @@ export const useProfileStore = defineStore('profile', {
async saveProfile(data) {
try {
const res = await updateProfile(data)
const userStore = useUserStore()
this.exists = res.data.exists
this.isCompleted = res.data.is_completed
this.profile = res.data.profile
storage.set(PROFILE_KEY, res.data.profile)
if (res.data.profile?.mode) {
userStore.setMode(res.data.profile.mode)
}
return res.data
} catch (e) {
console.error('saveProfile error:', e)
+11 -2
View File
@@ -1,11 +1,12 @@
import { defineStore } from 'pinia'
import { storage, USER_KEY, SESSION_KEY } from '@/utils/storage'
import { storage, USER_KEY, SESSION_KEY, USER_MODE_KEY, QUIT_CHECKIN_KEY } from '@/utils/storage'
export const useUserStore = defineStore('user', {
state: () => ({
user: storage.get(USER_KEY),
sessionKey: storage.get(SESSION_KEY),
isLoggedIn: !!storage.get(SESSION_KEY)
isLoggedIn: !!storage.get(SESSION_KEY),
mode: storage.get(USER_MODE_KEY)
}),
actions: {
@@ -17,12 +18,20 @@ export const useUserStore = defineStore('user', {
storage.set(SESSION_KEY, sessionKey)
},
setMode(mode) {
this.mode = mode
storage.set(USER_MODE_KEY, mode)
},
logout() {
this.user = null
this.sessionKey = null
this.isLoggedIn = false
this.mode = null
storage.remove(USER_KEY)
storage.remove(SESSION_KEY)
storage.remove(USER_MODE_KEY)
storage.remove(QUIT_CHECKIN_KEY)
}
}
})
+2
View File
@@ -39,3 +39,5 @@ export const storage = {
export const SESSION_KEY = 'session_key'
export const USER_KEY = 'user'
export const PROFILE_KEY = 'profile'
export const USER_MODE_KEY = 'user_mode'
export const QUIT_CHECKIN_KEY = 'quit_checkin'