Update API endpoints and enhance UI components. Changed appid in manifest.json, modified profile and log update methods from PUT to POST in profile.js and smoke.js, respectively. Added new statistics retrieval function in smoke.js and improved the smoke record dialog with updated styles and validation logic. Updated configuration for development environment and refined documentation for health recovery and savings calculations.

This commit is contained in:
nepiedg
2026-01-26 22:16:20 +08:00
parent 35405efcdf
commit 0d482e3a1c
11 changed files with 1103 additions and 509 deletions
+1 -1
View File
@@ -5,5 +5,5 @@ export function getProfile() {
}
export function updateProfile(data) {
return request.put('/smoke/profile', data)
return request.post('/smoke/profile', data)
}
+5 -1
View File
@@ -25,7 +25,7 @@ export function createLog(data) {
}
export function updateLog(id, data) {
return request.put(`/smoke/logs/${id}`, data)
return request.post(`/smoke/logs/${id}`, data)
}
export function deleteLog(id) {
@@ -43,3 +43,7 @@ export function getAiAdvice(date) {
export function unlockAiAdvice(data) {
return request.post('/smoke/ai/advice_unlocks', data)
}
export function getStats(params = {}) {
return request.get('/smoke/stats', params)
}
@@ -1,62 +1,87 @@
<template>
<view v-if="show" class="dialog-mask" @tap="handleMaskClick">
<view class="dialog-container" :class="{ 'dialog-show': showAnimation }" @tap.stop>
<view class="dialog-handle"></view>
<view class="dialog-header">
<text class="dialog-title">{{ title }}</text>
<view class="dialog-close" @tap="close">×</view>
</view>
<view class="dialog-body">
<view class="form-item">
<text class="form-label">时间</text>
<view class="form-input-row">
<picker mode="date" :value="formData.smoke_time" @change="onDateChange">
<view class="picker-value">{{ formData.smoke_time }}</view>
</picker>
<picker mode="time" :value="formData.smoke_time_only" @change="onTimeChange">
<view class="picker-value">{{ formData.smoke_time_only }}</view>
</picker>
</view>
</view>
<view class="form-item" v-if="type === 'smoke'">
<text class="form-label">数量</text>
<view class="form-number">
<view class="form-number-btn" @tap="decreaseNum">-</view>
<input class="form-number-input" type="number" v-model.number="formData.num" />
<view class="form-number-btn" @tap="increaseNum">+</view>
</view>
</view>
<view class="form-item" v-if="type === 'smoke'">
<text class="form-label">烟瘾等级</text>
<view class="form-level">
<view
v-for="level in 5"
:key="level"
class="level-item"
:class="{ 'level-active': formData.level === level }"
@tap="selectLevel(level)"
>
{{ level }}
<view class="form-row">
<picker class="picker-card" mode="date" :value="formData.smoke_time" @change="onDateChange">
<view class="input-card">
<text class="input-label">日期</text>
<view class="input-value-row">
<view class="input-icon input-icon-date"></view>
<text class="input-value">{{ formData.smoke_time || '选择日期' }}</text>
</view>
</view>
</picker>
<picker class="picker-card" mode="time" :value="formData.smoke_time_only" @change="onTimeChange">
<view class="input-card">
<text class="input-label">时间</text>
<view class="input-value-row">
<view class="input-icon input-icon-time"></view>
<text class="input-value">{{ formData.smoke_time_only || '选择时间' }}</text>
</view>
</view>
</picker>
</view>
<view class="section-card" v-if="type === 'smoke'">
<view class="section-left">
<view class="section-icon"></view>
<text class="section-title">抽烟数量</text>
</view>
<view class="counter">
<view class="counter-btn" @tap="decreaseNum">-</view>
<text class="counter-value">{{ formData.num }}</text>
<view class="counter-btn" @tap="increaseNum">+</view>
</view>
</view>
<view class="form-item">
<text class="form-label">备注</text>
<textarea
class="form-textarea"
v-model="formData.remark"
:placeholder="type === 'smoke' ? '记录抽烟原因...' : '记录抵抗心得...'"
maxlength="200"
<view class="section-card" v-if="type === 'smoke'">
<view class="level-header">
<text class="section-title">烟瘾程度</text>
<view class="level-badge">Level {{ formData.level }}</view>
</view>
<slider
class="level-slider"
:value="formData.level"
:min="1"
:max="5"
:step="1"
activeColor="#22C55E"
backgroundColor="#E5E7EB"
block-color="#22C55E"
:block-size="20"
@change="onLevelChange"
/>
<view class="level-scale">
<text class="level-scale-text">无感</text>
<text class="level-scale-text">强烈</text>
</view>
</view>
<view class="remark-section">
<text class="section-title">备注可选</text>
<view class="remark-card">
<textarea
class="form-textarea"
v-model="formData.remark"
:placeholder="type === 'smoke' ? '记录此时的心情或诱因,如压力大、应酬...' : '记录抵抗心得或诱因...'"
maxlength="200"
/>
</view>
</view>
</view>
<view class="dialog-footer">
<view class="dialog-btn dialog-btn-cancel" @tap="close">取消</view>
<view class="dialog-btn dialog-btn-confirm" @tap="submit">确定</view>
<view class="dialog-btn-primary" @tap="submit">
<view class="btn-icon"></view>
<text class="btn-text">保存记录</text>
</view>
</view>
</view>
</view>
@@ -94,7 +119,7 @@ export default {
},
computed: {
title() {
return this.type === 'smoke' ? '记录抽烟' : '想抽忍住了'
return '添加记录'
}
},
watch: {
@@ -118,8 +143,8 @@ export default {
smoke_time_only: this.initialData.smoke_time_only || '',
smoke_at: this.initialData.smoke_at || '',
remark: this.initialData.remark || '',
level: this.initialData.level || 2,
num: this.initialData.num || 1
level: this.initialData.level ?? 2,
num: this.initialData.num ?? 1
}
} else {
// 新建模式,使用当前时间
@@ -166,15 +191,43 @@ export default {
increaseNum() {
this.formData.num++
},
selectLevel(level) {
this.formData.level = level
onLevelChange(e) {
this.formData.level = e.detail.value
},
isTimeValid() {
const dateStr = this.formData.smoke_time
const timeStr = this.formData.smoke_time_only
if (!dateStr || !timeStr) {
uni.showToast({ title: '请选择日期和时间', icon: 'none' })
return false
}
const selected = new Date(`${dateStr}T${timeStr}:00`)
if (isNaN(selected.getTime())) {
uni.showToast({ title: '时间格式有误', icon: 'none' })
return false
}
const now = new Date()
const maxTime = new Date(now.getTime() + 5 * 60 * 1000)
if (selected > maxTime) {
uni.showToast({ title: '时间不能超过当前时间5分钟', icon: 'none' })
return false
}
return true
},
submit() {
if (!this.isTimeValid()) {
return
}
const submitData = {
smoke_time: this.formData.smoke_time,
smoke_at: this.formData.smoke_at,
remark: this.formData.remark,
level: this.type === 'smoke' ? this.formData.level : 2,
level: this.type === 'smoke' ? this.formData.level : 0,
num: this.type === 'smoke' ? this.formData.num : 0
}
@@ -192,7 +245,7 @@ export default {
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
background-color: rgba(0, 0, 0, 0.35);
z-index: 9999;
display: flex;
align-items: flex-end;
@@ -200,179 +253,308 @@ export default {
.dialog-container {
width: 100%;
max-height: 80vh;
max-height: 85vh;
background-color: #FFFFFF;
border-radius: 32rpx 32rpx 0 0;
border-radius: 36rpx 36rpx 0 0;
overflow: hidden;
transform: translateY(100%);
transition: transform 0.3s ease-out;
padding-bottom: 16rpx;
}
.dialog-show {
transform: translateY(0);
}
.dialog-handle {
width: 80rpx;
height: 8rpx;
background-color: #E5E7EB;
border-radius: 999rpx;
margin: 16rpx auto 0;
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
border-bottom: 2rpx solid #F3F4F6;
padding: 24rpx 32rpx 16rpx;
}
.dialog-title {
font-size: 32rpx;
font-weight: 600;
color: #1F2937;
font-size: 40rpx;
font-weight: 700;
color: #111827;
}
.dialog-close {
width: 48rpx;
height: 48rpx;
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 48rpx;
font-size: 44rpx;
color: #9CA3AF;
line-height: 1;
background-color: #F3F4F6;
border-radius: 50%;
}
.dialog-body {
padding: 32rpx;
max-height: 60vh;
padding: 16rpx 32rpx 24rpx;
max-height: 62vh;
overflow-y: auto;
}
.form-item {
margin-bottom: 32rpx;
.form-row {
display: flex;
gap: 20rpx;
margin-bottom: 24rpx;
}
.form-item:last-child {
margin-bottom: 0;
.picker-card {
flex: 1;
}
.form-label {
display: block;
font-size: 28rpx;
color: #6B7280;
margin-bottom: 16rpx;
.input-card {
background-color: #F9FAFB;
border-radius: 24rpx;
padding: 20rpx 24rpx;
border: 2rpx solid #F3F4F6;
min-height: 120rpx;
display: flex;
flex-direction: column;
justify-content: center;
}
.input-label {
font-size: 22rpx;
color: #9CA3AF;
margin-bottom: 12rpx;
font-weight: 500;
}
.form-input-row {
display: flex;
gap: 16rpx;
}
.picker-value {
flex: 1;
height: 80rpx;
background-color: #F9FAFB;
border-radius: 16rpx;
padding: 0 24rpx;
.input-value-row {
display: flex;
align-items: center;
font-size: 28rpx;
color: #1F2937;
border: 2rpx solid #E5E7EB;
gap: 12rpx;
}
.form-number {
.input-icon {
width: 32rpx;
height: 32rpx;
border-radius: 8rpx;
background-color: #DCFCE7;
position: relative;
}
.input-icon-date::after {
content: '';
position: absolute;
left: 6rpx;
top: 8rpx;
width: 20rpx;
height: 16rpx;
border: 2rpx solid #22C55E;
border-top-width: 6rpx;
border-radius: 4rpx;
}
.input-icon-time::after {
content: '';
position: absolute;
left: 8rpx;
top: 8rpx;
width: 16rpx;
height: 16rpx;
border: 2rpx solid #22C55E;
border-radius: 50%;
}
.input-icon-time::before {
content: '';
position: absolute;
left: 16rpx;
top: 12rpx;
width: 2rpx;
height: 8rpx;
background-color: #22C55E;
transform-origin: bottom;
}
.input-value {
font-size: 30rpx;
font-weight: 600;
color: #111827;
}
.section-card {
background-color: #FFFFFF;
border-radius: 24rpx;
padding: 24rpx;
border: 2rpx solid #F3F4F6;
box-shadow: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
margin-bottom: 24rpx;
}
.section-left {
display: flex;
align-items: center;
gap: 16rpx;
}
.form-number-btn {
width: 80rpx;
height: 80rpx;
background-color: #F9FAFB;
.section-icon {
width: 64rpx;
height: 64rpx;
background-color: #DCFCE7;
border-radius: 16rpx;
position: relative;
}
.section-icon::after {
content: '';
position: absolute;
left: 18rpx;
top: 18rpx;
width: 28rpx;
height: 28rpx;
border-radius: 6rpx;
border: 3rpx solid #22C55E;
border-top-color: transparent;
}
.section-title {
font-size: 30rpx;
font-weight: 600;
color: #111827;
}
.counter {
display: flex;
align-items: center;
gap: 20rpx;
background-color: #F8FAFC;
padding: 12rpx 16rpx;
border-radius: 999rpx;
border: 2rpx solid #F1F5F9;
margin-top: 16rpx;
justify-content: flex-end;
}
.counter-btn {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background-color: #E8FFF1;
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
color: #10B981;
color: #22C55E;
font-weight: 600;
border: 2rpx solid #E5E7EB;
}
.form-number-input {
flex: 1;
height: 80rpx;
background-color: #F9FAFB;
border-radius: 16rpx;
padding: 0 24rpx;
.counter-value {
min-width: 40rpx;
text-align: center;
font-size: 32rpx;
color: #1F2937;
border: 2rpx solid #E5E7EB;
font-weight: 700;
color: #111827;
}
.form-level {
display: flex;
gap: 16rpx;
}
.level-item {
flex: 1;
height: 80rpx;
background-color: #F9FAFB;
border-radius: 16rpx;
.level-header {
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
color: #6B7280;
border: 2rpx solid #E5E7EB;
transition: all 0.3s;
justify-content: space-between;
margin-bottom: 16rpx;
}
.level-active {
background-color: #10B981;
color: #FFFFFF;
border-color: #10B981;
.level-badge {
padding: 8rpx 18rpx;
border-radius: 999rpx;
background-color: #DCFCE7;
color: #22C55E;
font-size: 24rpx;
font-weight: 600;
}
.level-slider {
margin: 8rpx 0 4rpx;
}
.level-scale {
display: flex;
justify-content: space-between;
margin-top: 8rpx;
}
.level-scale-text {
font-size: 22rpx;
color: #9CA3AF;
}
.remark-section {
margin-bottom: 24rpx;
}
.remark-card {
margin-top: 16rpx;
background-color: #F9FAFB;
border-radius: 20rpx;
padding: 8rpx;
border: 2rpx solid #F3F4F6;
}
.form-textarea {
width: 100%;
min-height: 160rpx;
min-height: 180rpx;
background-color: #F9FAFB;
border-radius: 16rpx;
padding: 24rpx;
padding: 24rpx 20rpx;
font-size: 28rpx;
color: #1F2937;
border: 2rpx solid #E5E7EB;
color: #111827;
border: 2rpx solid transparent;
box-sizing: border-box;
}
.dialog-footer {
display: flex;
gap: 16rpx;
padding: 32rpx;
border-top: 2rpx solid #F3F4F6;
background-color: #FFFFFF;
padding: 16rpx 32rpx 32rpx;
}
.dialog-btn {
flex: 1;
height: 88rpx;
border-radius: 44rpx;
.dialog-btn-primary {
height: 96rpx;
border-radius: 32rpx;
background-color: #22C55E;
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
box-shadow: 0 10rpx 24rpx rgba(34, 197, 94, 0.3);
}
.btn-icon {
width: 44rpx;
height: 44rpx;
border-radius: 50%;
background-color: #0F172A;
position: relative;
}
.btn-icon::before {
content: '';
position: absolute;
left: 14rpx;
top: 18rpx;
width: 12rpx;
height: 6rpx;
border-left: 4rpx solid #FFFFFF;
border-bottom: 4rpx solid #FFFFFF;
transform: rotate(-45deg);
}
.btn-text {
font-size: 30rpx;
font-weight: 500;
}
.dialog-btn-cancel {
background-color: #F3F4F6;
color: #6B7280;
}
.dialog-btn-confirm {
background-color: #10B981;
color: #FFFFFF;
font-weight: 600;
color: #0F172A;
}
</style>
+3 -3
View File
@@ -1,11 +1,11 @@
const ENV = {
development: {
BASE_URL: 'http://192.168.214.3:8080/api/v1',
MINI_PROGRAM_ID: 1
BASE_URL: ' http://192.168.31.132:8080/api/v1',
MINI_PROGRAM_ID: 2
},
production: {
BASE_URL: 'https://api.example.com/api/v1',
MINI_PROGRAM_ID: 1
MINI_PROGRAM_ID: 2
}
}
+9 -3
View File
@@ -202,7 +202,9 @@ function calculateDailyTarget(baseline, stage, dayInStage) {
---
## 5. 健康恢复计算
## 5. 健康恢复计算(后端统一)
接口:`GET /api/v1/smoke/stats?range=week|month|year`(详见 `docs/smoke/API.md`
基于医学研究的恢复时间线:
@@ -236,7 +238,9 @@ function calculateLungRecovery(smokeFreeMinutes) {
---
## 6. 省钱计算
## 6. 省钱计算(后端统一)
接口:`GET /api/v1/smoke/stats?range=week|month|year`(详见 `docs/smoke/API.md`
```javascript
// utils/money.js
@@ -250,7 +254,9 @@ function calculateMoneySaved(packPriceCent, cigsPerPack, baselineCigsPerDay, act
---
## 7. 激励语生成
## 7. 激励语生成(后端统一)
接口:`GET /api/v1/smoke/motivation`(详见 `docs/smoke/API.md`
根据用户状态生成不同的激励语:
+9 -8
View File
@@ -32,6 +32,7 @@
| 下次建议时间 | 显示建议的下次抽烟时间点 | `GET /next_smoke_time` |
| 今日已抽 | X / 目标数,较昨日 ±N | `next_smoke_time.today_count` + `next_smoke_time.reduced_from_yesterday`(可为负) + `next_smoke_time.exceeded_yesterday`(标识“超出昨日”) |
| 烟瘾发作已抵抗 | 忍住次数统计 | `next_smoke_time.resisted_count` |
| 激励语 | 当天一句话鼓励 | `GET /motivation` |
| 记录抽烟按钮 | 快速记录一次抽烟 | `POST /logs` |
| 想抽忍住了按钮 | 记录成功抵抗 | `POST /logs/resisted` |
@@ -46,15 +47,15 @@
| 功能 | 说明 | 数据来源 |
|------|------|----------|
| 周/月/年切换 | 切换统计时间范围 | `GET /dashboard?start=&end=` |
| 周/月/年切换 | 切换统计时间范围 | `GET /stats?range=week|month|year` |
| 每周洞察 | AI 分析本周表现 | `GET /ai/advice` |
| 吸烟趋势图 | 柱状图展示每日吸烟量 | `dashboard.weekly` |
| 趋势对比 | 较上周减少 X% | 本地计算 |
| 日均吸烟量 | 统计周期内日均值 | 本地计算 |
| 节省金额 | 基于减少量 × 单价计算 | profile + logs |
| 肺部功能恢复 | 根据戒烟天数估算 | 固定公式 |
| 连续记录天数 | 用户活跃天数 | logs 统计 |
| 已拒绝次数 | 累计忍住次数 | `level=0,num=0` 统计 |
| 吸烟趋势图 | 柱状图展示每日吸烟量 | `stats.trend` |
| 趋势对比 | 较上周期变化 | `stats.change_percent` |
| 日均吸烟量 | 统计周期内日均值 | `stats.daily_average` |
| 节省金额 | 后端统一计算 | `stats.money` |
| 肺部功能恢复 | 后端统一计算 | `stats.health` |
| 连续记录天数 | 用户活跃天数 | `stats.streak_days` |
| 已拒绝次数 | 累计忍住次数 | `stats.resisted_total` |
### 2.3 AI 助手页 (ai_quit_assistant)
+55 -131
View File
@@ -434,158 +434,82 @@ AI 生成说明:
- `reduced_from_yesterday`:较昨日减少的支数(允许为负数;为负时表示“今天超出昨日”)。
- `exceeded_yesterday`:是否超出昨日(`true` 表示今天超出昨日,前端可用作单独标识)。
## 14) 获取健康与储蓄统计(合并接口
## 14) 数据统计分析(趋势 + 健康 + 省钱
`GET /api/v1/smoke/health_savings?period=week&start=2026-01-01&end=2026-01-07`
说明:
- 合并了"肺部功能恢复"和"节省金额"两个指标,专用于统计页面展示。
- 支持按周/月/年查询,通过 `period` 参数指定时间范围类型。
`GET /api/v1/smoke/stats?range=week|month|year&date=2026-01-07`
参数:
- `period`(必填):时间范围类型,可选值:
- `week`:周(默认本周一至本周日)
- `month`:月(默认本月1日至本月最后一日)
- `year`:年(默认本年1月1日至本年12月31日)
- `start`(可选):起始日期(格式 `YYYY-MM-DD`),不传则根据 `period` 自动计算
- `end`(可选):截止日期(格式 `YYYY-MM-DD`),不传则根据 `period` 自动计算
- `range``week|month|year`,默认 `week`
- `date`:锚点日期(`YYYY-MM-DD`),默认今天
**计算逻辑**
1. **肺部功能恢复百分比**
- 基于用户最后一次实际抽烟时间(忽略 `level=0 && num=0` 的忍住记录)
- 计算无烟时长(分钟)
- 根据医学研究恢复时间线计算恢复百分比(详见 `docs/ALGORITHM.md`
- 公式:
```
无烟天数 = (当前时间 - 最后抽烟时间) / (24 * 60) 分钟
恢复百分比计算:
- < 14天: (天数 / 14) * 15%
- 14-30天: 15% + ((天数 - 14) / 16) * 15%
- 30-90天: 30% + ((天数 - 30) / 60) * 20%
- > 90天: 50% + ((天数 - 90) / 275) * 50%,最高 100%
```
2. **节省金额**
- 基于用户在统计周期内的实际抽烟量与基线对比
- 计算公式:
```
预期总支数 = baseline_cigs_per_day * 周期天数
实际总支数 = 周期内所有记录的 num 累加(排除 level=0 && num=0
节省支数 = 预期总支数 - 实际总支数
节省包数 = 节省支数 / 20(假设每包20支)
节省金额(分)= 节省包数 * pack_price_cent
```
- 若实际总支数 > 预期总支数,则节省金额为 0(不显示负数)
3. **目标金额**
- 默认目标:`baseline_cigs_per_day * 周期天数 / 20 * pack_price_cent`(即完全戒烟节省的金额)
- 或使用用户设定的目标金额(如有)
成功响应示例:
说明
- 用于“统计页”一屏数据整合(趋势、均值、环比、健康恢复、省钱、连续记录、已拒绝次数)。
- `trend_unit``day``month`,用于前端图表横轴显示。
成功响应(示例):
```json
{
"code": 200,
"message": "success",
"data": {
"period": "week",
"range": "week",
"start": "2026-01-01",
"end": "2026-01-07",
"days": 7,
"health": {
"lung_recovery_percent": 40.5,
"smoke_free_minutes": 11520,
"smoke_free_days": 8,
"last_smoke_at": "2025-12-24T14:30:00+08:00",
"recovery_stage": "early", // early(0-14天) / mid(14-30天) / late(30-90天) / advanced(>90天)
"next_milestone": {
"days_until": 6,
"milestone": "2周 - 肺功能提升15%",
"percent_at_milestone": 15
}
},
"savings": {
"saved_amount_cent": 14500, // 已节省金额(分)
"saved_amount_yuan": 145.00, // 已节省金额(元,前端展示用)
"target_amount_cent": 20000, // 目标金额(分)
"target_amount_yuan": 200.00, // 目标金额(元)
"saved_cigs": 58, // 节省支数
"expected_cigs": 140, // 预期总支数
"actual_cigs": 82, // 实际总支数
"progress_percent": 72.5, // 完成进度百分比
"pack_price_cent": 2500, // 单包价格(分)
"cigs_per_pack": 20 // 每包支数
},
"profile": {
"baseline_cigs_per_day": 20,
"trend_unit": "day",
"trend": [
{ "label": "2026-01-01", "count": 2 },
{ "label": "2026-01-02", "count": 1 },
{ "label": "2026-01-03", "count": 0 },
{ "label": "2026-01-04", "count": 0 },
{ "label": "2026-01-05", "count": 3 },
{ "label": "2026-01-06", "count": 0 },
{ "label": "2026-01-07", "count": 0 }
],
"daily_average": 4,
"change_percent": -20,
"money": {
"available": true,
"pack_price_cent": 2500,
"smoking_years": 8
"cigs_per_pack": 20,
"expected_total": 140,
"actual_total": 92,
"saved_cent": 6000
},
"calculated_at": "2026-01-08T10:00:00+08:00"
"health": {
"available": true,
"smoke_free_minutes": 420,
"lung_recovery_percent": 12,
"milestones": [
{ "name": "心率血压恢复正常", "minutes": 20, "reached": true },
{ "name": "血氧水平恢复", "minutes": 480, "reached": false }
]
},
"streak_days": 12,
"resisted_total": 24
}
}
```
字段说明:
- `change_percent`:与上一个同周期对比的变化比例(可为负)。
- `money.available=false`:表示缺少 `baseline_cigs_per_day``pack_price_cent`
- `health.available=false`:表示无历史记录。
**health(健康数据)**
- `lung_recovery_percent`:肺部功能恢复百分比(0-100
- `smoke_free_minutes`:无烟时长(分钟)
- `smoke_free_days`:无烟天数(保留1位小数)
- `last_smoke_at`:最后一次实际抽烟时间(RFC3339格式)
- `recovery_stage`:恢复阶段标识
- `next_milestone`:下一个里程碑信息
## 15) 激励语(后端统一生成)
**savings(储蓄数据)**
- `saved_amount_cent`:已节省金额(分,用于计算)
- `saved_amount_yuan`:已节省金额(元,用于前端展示)
- `target_amount_cent`:目标金额(分)
- `target_amount_yuan`:目标金额(元)
- `saved_cigs`:节省的支数
- `expected_cigs`:预期总支数(基线 × 天数)
- `actual_cigs`:实际总支数
- `progress_percent`:完成进度百分比(saved_amount / target_amount * 100
- `pack_price_cent`:单包价格(分)
- `cigs_per_pack`:每包支数(默认20
`GET /api/v1/smoke/motivation`
**period 参数说明**
说明
- 基于当日数据(如 `today_count``resisted_count``last_smoke_at`)与 `quit_motivations` 生成一句激励语。
- `period=week`(周):
- 默认:本周一 00:00:00 至 本周日 23:59:59
- 若传 `start`,则 `end = start + 6 天`
- `period=month`(月):
- 默认:本月1日 00:00:00 至 本月最后一日 23:59:59
- 若传 `start`,则 `end = start 所在月的最后一日`
- `period=year`(年):
- 默认:本年1月1日 00:00:00 至 本年12月31日 23:59:59
- 若传 `start`,则 `end = start 所在年的12月31日`
curl 示例:
```bash
# 查询本周数据
curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/health_savings?period=week' \
-H 'Authorization: Bearer wx-session-key'
# 查询本月数据
curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/health_savings?period=month' \
-H 'Authorization: Bearer wx-session-key'
# 查询本年数据
curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/health_savings?period=year' \
-H 'Authorization: Bearer wx-session-key'
# 查询指定日期范围
curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/health_savings?period=week&start=2026-01-01&end=2026-01-07' \
-H 'Authorization: Bearer wx-session-key'
成功响应(示例):
```json
{
"code": 200,
"message": "success",
"data": {
"message": "今天的表现很稳,继续保持!记住你的目标:身体健康。",
"type": "encourage"
}
}
```
**注意事项**
1. 若用户尚未补全 `profile`(缺少 `baseline_cigs_per_day` 或 `pack_price_cent`),相关计算字段可能为 0 或使用默认值
2. 若用户从未抽烟(无历史记录),`last_smoke_at` 可能不存在,肺部恢复百分比按最大恢复计算
3. 节省金额计算时,若实际支数 > 预期支数,`saved_amount_cent` 为 0(不显示负数)
4. 时间范围计算时,使用用户所在时区(后端需根据用户配置或默认使用 UTC+8)
+1 -1
View File
@@ -50,7 +50,7 @@
"quickapp" : {},
/* */
"mp-weixin" : {
"appid" : "wx83800d4b11dd4617",
"appid" : "wx74d28a21dcadd983",
"setting" : {
"urlCheck" : false
},
+16 -4
View File
@@ -155,7 +155,8 @@ const changeText = computed(() => {
})
const timerDisplay = computed(() => {
const totalSeconds = (dashboardStore.minutesSinceLast || 165) * 60 + timerSeconds.value
const baseMinutes = dashboardStore.minutesSinceLast ?? 165
const totalSeconds = baseMinutes * 60 + timerSeconds.value
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
@@ -203,14 +204,25 @@ function openResistedDialog() {
async function handleSubmit(submitData) {
try {
await api.createLog(submitData)
if (dialogType.value === 'smoke') {
dashboardStore.incrementTodayCount()
await api.createLog(submitData)
dashboardStore.resetTimer()
timerSeconds.value = 0
uni.showToast({ title: '记录成功', icon: 'success' })
try {
await Promise.all([
dashboardStore.fetchDashboard(true),
dashboardStore.fetchNextSmokeTime()
])
} catch (e) {
console.error('refreshAfterSmokeLog error:', e)
}
} else {
await api.createResistedLog({
smoke_time: submitData.smoke_time,
smoke_at: submitData.smoke_at,
remark: submitData.remark
})
resistedCount.value++
uni.showToast({ title: '太棒了!', icon: 'success' })
}
+2 -2
View File
@@ -196,8 +196,8 @@ function handleEdit(log) {
smoke_time_only: log.displayTime,
smoke_at: log.smoke_at,
remark: log.remark || '',
level: log.level || 2,
num: log.num || 1
level: log.level ?? 2,
num: log.num ?? 1
}
showEditDialog.value = true
}
+679 -214
View File
File diff suppressed because it is too large Load Diff