refactor: trim smoke home API usage

This commit is contained in:
nepiedg
2026-04-26 22:05:21 +08:00
parent e28854b97d
commit 374168d959
7 changed files with 49 additions and 562 deletions
+2 -2
View File
@@ -254,9 +254,9 @@ function calculateMoneySaved(packPriceCent, cigsPerPack, baselineCigsPerDay, act
--- ---
## 7. 激励语生成(后端统一 ## 7. 激励语生成(首页内联返回
接口:`GET /api/v1/smoke/motivation`(详见 `docs/smoke/API.md` 接口:`GET /api/v1/smoke/home``motivation` 字段。
根据用户状态生成不同的激励语: 根据用户状态生成不同的激励语:
+15 -42
View File
@@ -60,18 +60,14 @@
├─────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────┤
│ 0ms ─ 页面骨架屏渲染 │ │ 0ms ─ 页面骨架屏渲染 │
│ │ │ │ │ │
│ ├──── 并行请求 ──────────────────────────────────── │ │ ├──── 串行守卫 + 单接口数据 ──────────────────────── │
│ │ ├── /profile (检查用户状态) │ │ │ ├── /profile (检查用户状态) │
│ │ ── /dashboard (核心数据) │ │ │ ── /home (首页核心数据)
│ │ └── /next_smoke_time (建议时间) │
│ │ │ │ │ │
│ 200ms ─ 核心数据返回,渲染计时器+统计卡片 │ │ 200ms ─ 核心数据返回,渲染计时器+统计卡片 │
│ │ │ │ │ │
│ 300ms ─ 首屏渲染完成 │ │ 300ms ─ 首屏渲染完成 │
│ │ │ │ │ │
│ │ ┌── 延迟加载 ────────────────────────────── │
│ │ └── /ai/advice (AI提示卡片) │
│ │ │
│ 500ms ─ 完整页面渲染 │ │ 500ms ─ 完整页面渲染 │
└─────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────┘
``` ```
@@ -79,29 +75,14 @@
### 3.2 缓存策略 ### 3.2 缓存策略
```javascript ```javascript
// stores/dashboard.js // 首页使用 /smoke/home 单接口返回当前屏所需字段。
import { defineStore } from 'pinia' // 页面级刷新由 onShow 触发,避免维护额外 dashboard store 和重复请求。
const homeData = ref(null)
export const useDashboardStore = defineStore('dashboard', { async function fetchRecordHomeData() {
state: () => ({ const res = await api.getHome()
todayCount: 0, homeData.value = res.data || {}
minutesSinceLast: 0, }
weekly: [],
nextSmokeTime: null,
lastFetchTime: 0,
cacheExpiry: 30 * 1000
}),
actions: {
async fetchDashboard(forceRefresh = false) {
const now = Date.now()
if (!forceRefresh && now - this.lastFetchTime < this.cacheExpiry) {
return
}
// 发起请求...
}
}
})
``` ```
### 3.3 计时器优化 ### 3.3 计时器优化
@@ -240,35 +221,27 @@ export const request = {
```javascript ```javascript
// pages/index/index.vue // pages/index/index.vue
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useDashboardStore } from '@/stores/dashboard' import * as api from '@/api'
import * as api from '@/api/smoke'
const loading = ref(true) const loading = ref(true)
const dashboardStore = useDashboardStore() const homeData = ref(null)
async function initPage() { async function initPage() {
loading.value = true loading.value = true
try { try {
const [profileRes, dashboardRes, nextTimeRes] = await Promise.all([ const profileRes = await api.getSmokeProfile()
api.getProfile(), if (!profileRes.exists || !profileRes.is_completed) {
api.getDashboard(),
api.getNextSmokeTime()
])
if (!profileRes.data.exists || !profileRes.data.is_completed) {
uni.redirectTo({ url: '/pages/onboarding/index' }) uni.redirectTo({ url: '/pages/onboarding/index' })
return return
} }
dashboardStore.setDashboard(dashboardRes.data) const homeRes = await api.getHome()
dashboardStore.setNextSmokeTime(nextTimeRes.data) homeData.value = homeRes.data || {}
} finally { } finally {
loading.value = false loading.value = false
} }
setTimeout(loadAiAdvice, 300)
} }
onMounted(initPage) onMounted(initPage)
+17 -288
View File
@@ -54,18 +54,7 @@ curl -X POST 'http://127.0.0.1:8080/api/v1/smoke/logs' \
} }
``` ```
## 2) 获取单条记录 ## 2) 列表查询(分页)
`GET /api/v1/smoke/logs/:id`
curl 示例:
```bash
curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \
-H 'Authorization: Bearer wx-session-key'
```
## 3) 列表查询(分页)
`GET /api/v1/smoke/logs?page=1&page_size=20&start=2025-12-01&end=2025-12-31&type=all` `GET /api/v1/smoke/logs?page=1&page_size=20&start=2025-12-01&end=2025-12-31&type=all`
@@ -93,71 +82,7 @@ curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \
} }
``` ```
## 4) 获取看板概览 ## 3) 更新记录
`GET /api/v1/smoke/dashboard?start=2026-01-01&end=2026-01-07`
参数:
- `start`:起始日期(含,格式 `YYYY-MM-DD`),默认“本周一”
- `end`:截止日期(含,格式 `YYYY-MM-DD`),默认“本周日”。若只传 `start``end` 默认为 `start + 6 天`
成功响应示例:
```json
{
"code": 200,
"message": "success",
"data": {
"today_count": 6,
"minutes_since_last": 42,
"weekly": [
{ "date": "2026-01-01", "count": 2, "is_today": false },
{ "date": "2026-01-02", "count": 1, "is_today": false },
{ "date": "2026-01-03", "count": 0, "is_today": false },
{ "date": "2026-01-04", "count": 0, "is_today": false },
{ "date": "2026-01-05", "count": 3, "is_today": true },
{ "date": "2026-01-06", "count": 0, "is_today": false },
{ "date": "2026-01-07", "count": 0, "is_today": false }
]
}
}
```
字段说明:
- `today_count`:当天吸烟总支数(累加 `num`
- `minutes_since_last`:距最后一次“实际抽烟”(忽略 `level=0 && num=0` 的忍住记录)的分钟数,通过最近一条 `smoke_at/smoke_time/createtime` 计算;若历史为空则字段不存在
- `weekly`:起止日期内每日汇总,`count` 为当日总支数,`is_today` 标记当前日期(即便不在 `start/end` 范围内也会标记为 `false`
## 5) 最近记录列表(轻量版)
`GET /api/v1/smoke/logs/latest?limit=20`
参数:
- `limit`:返回条数,默认 `20`,最大 `100`
成功响应示例:
```json
{
"code": 200,
"message": "success",
"data": {
"items": [
{
"id": 123,
"smoke_time": "2026-01-05T00:00:00+08:00",
"smoke_at": "2026-01-05T09:12:00+08:00",
"remark": "压力大",
"level": 3,
"num": 2,
"createtime": 1736049120
}
]
}
}
```
## 6) 更新记录
`POST /api/v1/smoke/logs/:id` `POST /api/v1/smoke/logs/:id`
@@ -178,7 +103,7 @@ curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \
- 如果你想“清空 smoke_at”,请传空字符串:`{"smoke_at":""}` - 如果你想“清空 smoke_at”,请传空字符串:`{"smoke_at":""}`
- 如果传 `null` 或者不传 `smoke_time`,后端会认为你没有修改该字段。 - 如果传 `null` 或者不传 `smoke_time`,后端会认为你没有修改该字段。
## 7) 删除记录(软删除) ## 4) 删除记录(软删除)
`DELETE /api/v1/smoke/logs/:id` `DELETE /api/v1/smoke/logs/:id`
@@ -194,157 +119,53 @@ curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \
} }
``` ```
## 8) 获取 AI 戒烟建议(会员 + 广告解锁并行 ## 5) 首页整合接口(Home
`GET /api/v1/smoke/ai/advice?date=2026-01-02`
说明:
- `date` 可选,默认“昨天”(建议针对哪一天的数据)。
- 权限:会员用户直接可用;非会员需要先对该 `date` 完成“看广告解锁”(见下一个接口)。
- 建议结果会按 `uid + date + prompt_version` 缓存(表:`fa_smoke_ai_advice`)。
## 9) 首页整合接口(Home Dashboard
`GET /api/v1/smoke/home` `GET /api/v1/smoke/home`
此接口把首页 UI 所需核心模块一次返回,避免前端串行请求多个接口。返回示例: 此接口只返回首页、AI 时间页和 AI 日总结页正在消费的核心字段,避免生成或传递无用模块。返回示例:
```json ```json
{ {
"code": 200, "code": 200,
"message": "success", "message": "success",
"data": { "data": {
"greeting": {
"title": "早安,Alex",
"subtitle": "今天也是清爽的一天",
"nickname": "Alex",
"time_of_day": "morning",
"avatar_url": "https://example.com/avatar.jpg"
},
"profile": {
"exists": true,
"is_completed": true,
"awake_minutes": 900,
"baseline_interval_minutes": 60,
"profile": {
"baseline_cigs_per_day": 10,
"pack_price_cent": 3200,
"wake_up_time": "07:20",
"sleep_time": "23:30"
}
},
"advice_card": {
"title": "智能控烟建议",
"date": "2026-01-04",
"message": "根据你的习惯,下午2点是烟瘾高峰,可以试试短暂散步。",
"status": "available"
},
"campaign_card": {
"title": "绿色生活,从戒烟开始",
"subtitle": "BRAND CAMPAIGN",
"badge": "广告"
},
"timer": { "timer": {
"label": "距上次抽烟",
"last_smoke_at": "2026-01-05T07:42:00+08:00",
"seconds_since_last": 9900, "seconds_since_last": 9900,
"next_suggested_at": "2026-01-05T10:30:00+08:00", "next_suggested_at": "2026-01-05T10:30:00+08:00",
"next_suggested_clock": "10:30", "next_suggested_clock": "10:30",
"not_before_at": "2026-01-05T10:30:00+08:00", "suggestion_source": "default"
"suggestion_source": "default",
"suggestion_algorithm": "staircase_delay_v1"
}, },
"summary": { "summary": {
"today_count": 3, "today_count": 3,
"daily_target": 10, "daily_target": 10,
"resisted_count": 1, "resisted_count": 1,
"reduced_from_yesterday": 2, "reduced_from_yesterday": 2,
"exceeded_yesterday": false, "exceeded_yesterday": false
"profile_completed": true
}, },
"motivation": { "motivation": {
"message": "太棒了!你刚刚成功抵抗了一次烟瘾", "message": "太棒了!你刚刚成功抵抗了一次烟瘾",
"type": "praise" "type": "praise"
},
"quick_actions": [
{ "type": "log_smoke", "title": "记录抽烟", "primary": false },
{ "type": "resist", "title": "想抽忍住了", "primary": true }
],
"data_sources": {
"ai_advice_date": "2026-01-04",
"plan_date": "2026-01-05"
} }
} }
} }
``` ```
字段说明: 字段说明:
- `greeting.title`:问候语 + 昵称(如“下午好,Alex”)。
- `greeting.subtitle`:副标题/心情提示文案。
- `greeting.nickname`:昵称(无昵称时使用“朋友”)。
- `greeting.time_of_day`:时间段标识(`morning`/`noon`/`afternoon`/`evening`)。
- `greeting.avatar_url`:头像 URL。
- `profile.exists`:是否存在用户档案。
- `profile.profile`:档案详情对象(可能为空)。
- `profile.is_completed`:是否已完成 onboarding。
- `profile.awake_minutes`:清醒时长(分钟)。
- `profile.baseline_interval_minutes`:基准间隔(分钟)。
- `advice_card.title`AI 提示卡片标题。
- `advice_card.date`:建议对应日期。
- `advice_card.message`AI 建议内容。
- `advice_card.status``available``locked`(需解锁)、`unavailable`AI 服务未配置)、`no_data`(所需日期没有记录)、`empty`(初始化)。
- `advice_card.model`:模型名称(有则返回)。
- `campaign_card.title`:活动标题。
- `campaign_card.subtitle`:活动副标题。
- `campaign_card.badge`:活动角标(如“广告”)。
- `timer.label`:展示标题(如“距上次抽烟”)。
- `timer.last_smoke_at`:最近一次实际抽烟时间(RFC3339)。
- `timer.seconds_since_last`:距上次抽烟的秒数(无记录返回 `-1`)。 - `timer.seconds_since_last`:距上次抽烟的秒数(无记录返回 `-1`)。
- `timer.next_suggested_at`:建议下次抽烟时间(RFC3339)。 - `timer.next_suggested_at`:建议下次抽烟时间(RFC3339)。
- `timer.next_suggested_clock`:仅时分显示(如“16:30”)。 - `timer.next_suggested_clock`:仅时分显示(如“16:30”)。
- `timer.not_before_at`:不早于的时间点(当前与 `next_suggested_at` 一致)。
- `timer.suggestion_source`:建议来源(`default`/`ai`)。 - `timer.suggestion_source`:建议来源(`default`/`ai`)。
- `timer.suggestion_algorithm`:算法版本(`staircase_delay_v1`)。
- `timer` 说明:`seconds_since_last` 基于服务器当前时间计算,`last_smoke_at` 若补录未来时间会截断到 `as_of`;当 `plan_date=今天` 时会补齐过期间隔确保 `next_suggested_at` 在未来。
- `summary.today_count`:今日吸烟支数累加。 - `summary.today_count`:今日吸烟支数累加。
- `summary.daily_target`:每日目标(线性递减:以 `onboarding_completed_at` 为起点,按 `quit_date` 线性下降到 0 - `summary.daily_target`:每日目标。
- `summary.resisted_count`:今日忍住次数。 - `summary.resisted_count`:今日忍住次数。
- `summary.reduced_from_yesterday`:与昨日的绝对差值(非负)。 - `summary.reduced_from_yesterday`:与昨日的绝对差值(非负)。
- `summary.exceeded_yesterday`:是否比昨天多。 - `summary.exceeded_yesterday`:是否比昨天多。
- `summary.profile_completed`:是否已完成基础信息 - `daily_summary`:当天已缓存的 AI 日总结;无缓存时为 `null`
- `motivation.message`:激励语文案。 - `motivation.message`:激励语文案。
- `motivation.type`:激励语类型。 - `motivation.type`:激励语类型。
- `quick_actions[].type`:动作类型(`log_smoke`/`resist`)。
- `quick_actions[].title`:按钮文案。
- `quick_actions[].primary`:是否主按钮。
- `data_sources.ai_advice_date`AI 建议日期。
- `data_sources.plan_date`:当前计划日期。
如需 AI 时间节点完整版,可继续调用 `GET /ai/next_smoke_time`;首页接口只返回默认建议,避免额外的 AI 生成成本。 如需生成 AI 时间节点,请调用 `GET /api/v1/smoke/ai/next_smoke_time`;首页接口只读取缓存,不主动生成 AI 建议,避免额外性能成本。
未满足权限时的建议响应(示例):
```json
{
"code": 403,
"message": "需要会员或观看广告解锁后才可生成建议",
"data": {
"date": "2026-01-02",
"need": "vip_or_ad"
}
}
```
成功响应(示例):
```json
{
"code": 200,
"message": "success",
"data": {
"date": "2026-01-02",
"advice": "..."
}
}
```
## 10) 看广告解锁(用于非会员) ## 10) 看广告解锁(用于非会员)
@@ -451,33 +272,13 @@ curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \
成功响应:同 `GET /api/v1/smoke/profile`(返回最新 `profile` + `is_completed` + `baseline_interval_minutes`)。 成功响应:同 `GET /api/v1/smoke/profile`(返回最新 `profile` + `is_completed` + `baseline_interval_minutes`)。
## 13) 想抽但忍住了(写入一条 level=0,num=0 的记录) ## 13) 获取 AI 下次抽烟建议
`POST /api/v1/smoke/logs/resisted` `GET /api/v1/smoke/ai/next_smoke_time`
请求体(示例):
```json
{
"smoke_time": "2026-01-05",
"smoke_at": "2026-01-05 10:20:00",
"remark": "压力大,想抽但忍住了",
"level": 0,
"num": 0
}
```
说明: 说明:
- 该接口会在 `fa_smoke_log` 中新增一条记录:`level=0``num=0`,用于更直观记录“想抽/忍住”的过程 - 用于 AI 建议页生成当天时间节点
- 这类记录不会影响 `today_count/weekly.count` 的支数统计(因为 `num=0` - 首页只通过 `GET /smoke/home` 读取已缓存的 AI 结果,不主动生成 AI,避免首页加载时产生额外性能成本
## 14) 获取“下次抽烟记录时间”(默认 + AI 自动切换)
`GET /api/v1/smoke/next_smoke_time`
说明:
- 用于首页展示“建议的下次记录时间”。
- 已整合首页所需汇总字段(上次抽烟时间/今日抽烟支数/今日克制次数/较昨日减少支数)。
- 如果指定日期存在 AI 给出的时间节点(`time_nodes` 不为空),则优先使用 AI 的建议;否则使用默认策略。
- 可选参数: - 可选参数:
- `date`:计划日期(默认今天),支持 `YYYY-MM-DD``today/tomorrow` - `date`:计划日期(默认今天),支持 `YYYY-MM-DD``today/tomorrow`
- `mode`(默认 `auto` - `mode`(默认 `auto`
@@ -495,74 +296,21 @@ AI 生成说明:
-`mode=ai` 时,会把最近 3 天的抽烟数据(含“忍住记录”)作为输入提供给 AI,用于更贴合近期模式生成时间节点。 -`mode=ai` 时,会把最近 3 天的抽烟数据(含“忍住记录”)作为输入提供给 AI,用于更贴合近期模式生成时间节点。
- 未解锁时会返回 `403`:提示需要观看广告解锁。 - 未解锁时会返回 `403`:提示需要观看广告解锁。
成功响应(示例:回落到默认): 成功响应(示例):
```json
{
"code": 200,
"message": "success",
"data": {
"source": "default",
"not_before_at": "2026-01-05T10:18:00+08:00",
"suggested_at": "2026-01-05T10:18:00+08:00",
"last_smoke_at": "2026-01-05T09:30:00+08:00",
"today_count": 3,
"resisted_count": 1,
"reduced_from_yesterday": 2,
"exceeded_yesterday": false,
"default": {
"last_smoke_at": "2026-01-05T09:30:00+08:00",
"next_smoke_at": "2026-01-05T10:18:00+08:00",
"base_interval_minutes": 48,
"interval_minutes": 48,
"stage": 0,
"resisted_7d": 3,
"sleep_adjusted": false,
"algorithm": "staircase_delay_v1",
"as_of": "2026-01-05T10:00:00+08:00"
}
}
}
```
当存在 AI 建议且包含 `time_nodes` 时,响应会是(示例):
```json ```json
{ {
"code": 200, "code": 200,
"message": "success", "message": "success",
"data": { "data": {
"source": "ai", "source": "ai",
"not_before_at": "2026-01-05T10:18:00+08:00",
"suggested_at": "2026-01-05T10:28:00+08:00", "suggested_at": "2026-01-05T10:28:00+08:00",
"last_smoke_at": "2026-01-05T09:30:00+08:00",
"today_count": 3,
"resisted_count": 1,
"reduced_from_yesterday": 2,
"exceeded_yesterday": false,
"time_nodes": ["10:30", "11:10", "14:00", "16:30"], "time_nodes": ["10:30", "11:10", "14:00", "16:30"],
"advice": "先把这次冲动延后到10:28,期间做一次5分钟快走+喝水,压力场景用深呼吸替代。", "advice": "先把这次冲动延后到10:28,期间做一次5分钟快走+喝水,压力场景用深呼吸替代。"
"default": { "algorithm": "staircase_delay_v1" },
"ai": {
"plan_date": "2026-01-05",
"not_before_at": "2026-01-05T10:18:00+08:00",
"suggested_at": "2026-01-05T10:28:00+08:00",
"time_nodes": ["10:30", "11:10", "14:00", "16:30"],
"advice": "先把这次冲动延后到10:28,期间做一次5分钟快走+喝水,压力场景用深呼吸替代。",
"prompt_version": "v1",
"model": "gpt-4.1-mini",
"provider": "openai-compatible"
}
} }
} }
``` ```
字段说明(新增首页字段): ## 14) 数据统计分析(趋势 + 健康 + 省钱)
- `last_smoke_at`:上次“实际抽烟”时间(忽略忍住记录),格式 `RFC3339`(含时区)。
- `today_count`:今日抽烟支数(累加 `num`)。
- `resisted_count`:今日克制次数(`num=0`)。
- `reduced_from_yesterday`:较昨日减少的支数(允许为负数;为负时表示“今天超出昨日”)。
- `exceeded_yesterday`:是否超出昨日(`true` 表示今天超出昨日,前端可用作单独标识)。
## 15) 数据统计分析(趋势 + 健康 + 省钱)
`GET /api/v1/smoke/stats?range=week|month|year&date=2026-01-07` `GET /api/v1/smoke/stats?range=week|month|year&date=2026-01-07`
@@ -624,22 +372,3 @@ AI 生成说明:
- `money.expected_total`:按“统计周期内有记录的天数”×`baseline_cigs_per_day` 计算;不统计无日志的天数。 - `money.expected_total`:按“统计周期内有记录的天数”×`baseline_cigs_per_day` 计算;不统计无日志的天数。
- `money.saved_cent`:按 `max(expected_total - actual_total, 0)` 计算,避免出现负值。 - `money.saved_cent`:按 `max(expected_total - actual_total, 0)` 计算,避免出现负值。
- `health.available=false`:表示无历史记录。 - `health.available=false`:表示无历史记录。
## 16) 激励语(后端统一生成)
`GET /api/v1/smoke/motivation`
说明:
- 基于当日数据(如 `today_count``resisted_count``last_smoke_at`)与 `quit_motivations` 生成一句激励语。
成功响应(示例):
```json
{
"code": 200,
"message": "success",
"data": {
"message": "今天的表现很稳,继续保持!记住你的目标:身体健康。",
"type": "encourage"
}
}
```
-28
View File
@@ -3,30 +3,14 @@ import { BASE_URL } from '@/config'
const BASE_URL_V2 = BASE_URL.replace('/v1', '/v2') const BASE_URL_V2 = BASE_URL.replace('/v1', '/v2')
export function getDashboard(params = {}) {
return request.get('/smoke/dashboard', params)
}
export function getHome(params = {}) { export function getHome(params = {}) {
return request.get('/smoke/home', params) return request.get('/smoke/home', params)
} }
export function getNextSmokeTime(params = {}) {
return request.get('/smoke/next_smoke_time', params)
}
export function getLogs(params = {}) { export function getLogs(params = {}) {
return request.get('/smoke/logs', params) return request.get('/smoke/logs', params)
} }
export function getLatestLogs(limit = 20) {
return request.get('/smoke/logs/latest', { limit })
}
export function getLog(id) {
return request.get(`/smoke/logs/${id}`)
}
export function createLog(data) { export function createLog(data) {
return request.post('/smoke/logs', data) return request.post('/smoke/logs', data)
} }
@@ -39,14 +23,6 @@ export function deleteLog(id) {
return request.delete(`/smoke/logs/${id}`) return request.delete(`/smoke/logs/${id}`)
} }
export function createResistedLog(data) {
return request.post('/smoke/logs/resisted', data)
}
export function getAiAdvice(date) {
return request.get('/smoke/ai/advice', { date })
}
export function unlockAiAdvice(data) { export function unlockAiAdvice(data) {
return request.post('/smoke/ai/advice_unlocks', data) return request.post('/smoke/ai/advice_unlocks', data)
} }
@@ -71,10 +47,6 @@ export function getShareData(shareToken, params = {}) {
return request.get(`/smoke/share/${shareToken}`, params) return request.get(`/smoke/share/${shareToken}`, params)
} }
export function revokeShare(shareToken) {
return request.post(`/smoke/share/${shareToken}/revoke`)
}
// 戒烟计划 API // 戒烟计划 API
export function generateQuitPlan() { export function generateQuitPlan() {
return request.post('/smoke/quit-plan/generate') return request.post('/smoke/quit-plan/generate')
+13 -127
View File
@@ -204,26 +204,6 @@
<text class="record-bio-metric-name">{{ item.name }}</text> <text class="record-bio-metric-name">{{ item.name }}</text>
<text class="record-bio-metric-value">{{ item.value }}</text> <text class="record-bio-metric-value">{{ item.value }}</text>
</view> </view>
<view class="record-bio-divider"></view>
<view class="record-bio-life-row">
<text class="record-bio-life-value">+{{ recordLifeSaved }} 小时</text>
<text class="record-bio-life-label">生命已回收</text>
</view>
</view>
</view>
</view>
<view class="record-bio-trend-card">
<view class="record-bio-section-head record-bio-section-head-compact">
<text class="record-bio-section-title">7 天节奏</text>
<text class="record-bio-section-chip">{{ recordStatusLabel }}</text>
</view>
<view class="record-bio-trend-chart">
<view v-for="item in recordTrendItems" :key="item.label" class="record-bio-trend-col">
<view class="record-bio-trend-line" :style="{ height: item.height }">
<view class="record-bio-trend-dot" :class="{ 'record-bio-trend-dot-active': item.active }"></view>
</view>
<text class="record-bio-trend-label">{{ item.label }}</text>
</view> </view>
</view> </view>
</view> </view>
@@ -330,6 +310,7 @@ const timerSeconds = ref(0) // 页面存活期间累计秒数
const isQuitMode = computed(() => userStore.mode === 'quit') const isQuitMode = computed(() => userStore.mode === 'quit')
const homeSummary = computed(() => homeData.value?.summary || {}) const homeSummary = computed(() => homeData.value?.summary || {})
const homeTimer = computed(() => homeData.value?.timer || {}) const homeTimer = computed(() => homeData.value?.timer || {})
const homeMotivation = computed(() => homeData.value?.motivation || {})
const quitSummary = computed(() => quitHomeData.value?.summary || {}) const quitSummary = computed(() => quitHomeData.value?.summary || {})
const quitDailyStatus = computed(() => quitHomeData.value?.daily_status || {}) const quitDailyStatus = computed(() => quitHomeData.value?.daily_status || {})
@@ -340,6 +321,7 @@ const packPriceYuan = computed(() => (profileStore.profile?.pack_price_cent || 2
// ========== 记录模式 ========== // ========== 记录模式 ==========
const todayCount = computed(() => homeSummary.value.today_count ?? 0) const todayCount = computed(() => homeSummary.value.today_count ?? 0)
const resistedCount = computed(() => homeSummary.value.resisted_count ?? 0)
const dailyTarget = computed(() => { const dailyTarget = computed(() => {
const target = homeSummary.value.daily_target const target = homeSummary.value.daily_target
if (target != null) return target if (target != null) return target
@@ -386,12 +368,6 @@ const todayCountRingStyle = computed(() => {
const recordHasData = computed(() => todayCount.value > 0 || timerBaseSeconds.value >= 0) const recordHasData = computed(() => todayCount.value > 0 || timerBaseSeconds.value >= 0)
// 今日节省(比目标少抽的部分换算)
const recordSavedMoney = computed(() => {
const saved = Math.max(0, (dailyTarget.value - todayCount.value) * (packPriceYuan.value / 20))
return saved.toFixed(1)
})
const recordHeroTitle = computed(() => { const recordHeroTitle = computed(() => {
if (!recordHasData.value) return '先记录第一刻' if (!recordHasData.value) return '先记录第一刻'
if (todayCount.value === 0) return '今天还没抽烟' if (todayCount.value === 0) return '今天还没抽烟'
@@ -408,25 +384,19 @@ const recordRhythmText = computed(() => {
const achievementCardTitle = computed(() => isQuitMode.value ? '无烟等级' : '长期记录等级') const achievementCardTitle = computed(() => isQuitMode.value ? '无烟等级' : '长期记录等级')
// 今日延长生命时长(每少一支 = 11 分钟)
const recordLifeSaved = computed(() => {
const minutes = Math.max(0, (dailyTarget.value - todayCount.value) * 11)
return Math.round(minutes / 60 * 10) / 10
})
const reducePercent = computed(() => { const reducePercent = computed(() => {
if (dailyTarget.value <= 0) return 0 if (dailyTarget.value <= 0) return 0
return Math.round(Math.max(0, dailyTarget.value - todayCount.value) / dailyTarget.value * 100) return Math.round(Math.max(0, dailyTarget.value - todayCount.value) / dailyTarget.value * 100)
}) })
const recordHealthTip = computed(() => { const recordHealthTip = computed(() => {
if (homeMotivation.value.message) return homeMotivation.value.message
if (todayCount.value === 0) return '今天还没抽烟,继续保持!' if (todayCount.value === 0) return '今天还没抽烟,继续保持!'
if (todayCount.value < dailyTarget.value) return `今天比目标少抽了${dailyTarget.value - todayCount.value}根,很棒!` if (todayCount.value < dailyTarget.value) return `今天比目标少抽了${dailyTarget.value - todayCount.value}根,很棒!`
if (todayCount.value === dailyTarget.value) return '今天已达到目标,加油!' if (todayCount.value === dailyTarget.value) return '今天已达到目标,加油!'
return '今天超标了,明天继续努力' return '今天超标了,明天继续努力'
}) })
const recordGoalLeft = computed(() => Math.max(0, dailyTarget.value - todayCount.value))
const recordControlScore = computed(() => { const recordControlScore = computed(() => {
if (!dailyTarget.value || dailyTarget.value <= 0) return todayCount.value > 0 ? 40 : 88 if (!dailyTarget.value || dailyTarget.value <= 0) return todayCount.value > 0 ? 40 : 88
const score = Math.round((1 - Math.min(todayCount.value / dailyTarget.value, 1)) * 72) + 18 const score = Math.round((1 - Math.min(todayCount.value / dailyTarget.value, 1)) * 72) + 18
@@ -450,26 +420,20 @@ const recordControlRingStyle = computed(() => {
return { background: `conic-gradient(${accent} 0deg ${angle}deg, rgba(226, 232, 240, 0.78) ${angle}deg 360deg)` } return { background: `conic-gradient(${accent} 0deg ${angle}deg, rgba(226, 232, 240, 0.78) ${angle}deg 360deg)` }
}) })
const recordYesterdayText = computed(() => {
const reduced = Number(homeSummary.value.reduced_from_yesterday)
if (Number.isNaN(reduced)) return '暂无对比'
if (reduced === 0) return '持平'
return homeSummary.value.exceeded_yesterday ? `${reduced}` : `${reduced}`
})
const recordBioHealthItems = computed(() => [ const recordBioHealthItems = computed(() => [
{ name: '今日目标', value: `${todayCount.value}/${dailyTarget.value || '-'}` }, { name: '今日目标', value: `${todayCount.value}/${dailyTarget.value || '-'}` },
{ name: '剩余额度', value: `${recordGoalLeft.value} ` }, { name: '今日忍住', value: `${resistedCount.value} ` },
{ name: '节省金额', value: `¥${recordSavedMoney.value}` } { name: '较昨日', value: recordYesterdayText.value },
{ name: '建议时间', value: nextSmokeTimeText.value || '--:--' }
]) ])
const recordTrendItems = computed(() => {
const labels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
const target = Math.max(dailyTarget.value || baselineCigsPerDay.value || 1, 1)
return labels.map((label, index) => {
const estimated = Math.max(0, todayCount.value + index - 6)
const value = index === labels.length - 1 ? todayCount.value : Math.min(target, estimated)
return {
label,
height: `${Math.max(24, Math.round((value / target) * 112))}rpx`,
active: index === labels.length - 1
}
})
})
// ========== 戒烟模式 ========== // ========== 戒烟模式 ==========
// 服务端连续无烟天数优先,失败时从本地缓存推算 // 服务端连续无烟天数优先,失败时从本地缓存推算
@@ -2793,7 +2757,6 @@ onShareAppMessage(() => ({
.record-bio-status-card, .record-bio-status-card,
.record-bio-console-card, .record-bio-console-card,
.record-bio-health-card, .record-bio-health-card,
.record-bio-trend-card,
.record-bio-achievement-card, .record-bio-achievement-card,
.record-bio-tip-card { .record-bio-tip-card {
position: relative; position: relative;
@@ -3060,7 +3023,6 @@ onShareAppMessage(() => ({
} }
.record-bio-health-card, .record-bio-health-card,
.record-bio-trend-card,
.record-bio-achievement-card { .record-bio-achievement-card {
padding: 28rpx; padding: 28rpx;
border-radius: 34rpx; border-radius: 34rpx;
@@ -3170,82 +3132,6 @@ onShareAppMessage(() => ({
color: #0F766E; color: #0F766E;
} }
.record-bio-divider {
height: 1rpx;
margin: 12rpx 0 14rpx;
background: rgba(148, 163, 184, 0.18);
}
.record-bio-life-row {
@include flex-col;
gap: 4rpx;
}
.record-bio-life-value {
font-size: 34rpx;
line-height: 1.2;
font-weight: 900;
color: var(--record-gold);
}
.record-bio-life-label {
font-size: 21rpx;
font-weight: 800;
color: #64748B;
}
.record-bio-trend-card {
background: linear-gradient(135deg, #ECFDF5 0%, #F0F9FF 100%);
}
.record-bio-trend-chart {
height: 176rpx;
margin-top: 22rpx;
padding: 0 4rpx;
display: flex;
align-items: flex-end;
justify-content: space-between;
border-bottom: 1rpx solid rgba(100, 116, 139, 0.18);
}
.record-bio-trend-col {
flex: 1;
min-width: 0;
@include flex-center;
flex-direction: column;
justify-content: flex-end;
gap: 10rpx;
}
.record-bio-trend-line {
position: relative;
width: 4rpx;
border-radius: 999rpx;
background: linear-gradient(180deg, rgba(251, 191, 36, 0.86), rgba(103, 232, 249, 0.5));
}
.record-bio-trend-dot {
position: absolute;
top: -8rpx;
left: 50%;
width: 16rpx;
height: 16rpx;
margin-left: -8rpx;
border-radius: 50%;
background: #67E8F9;
box-shadow: 0 0 0 5rpx rgba(103, 232, 249, 0.14);
}
.record-bio-trend-dot-active {
background: var(--record-gold);
box-shadow: 0 0 0 6rpx rgba(251, 191, 36, 0.18);
}
.record-bio-trend-label {
font-size: 18rpx;
color: #64748B;
}
.record-bio-achievement-card { .record-bio-achievement-card {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.78), rgba(250, 245, 255, 0.5)); background: linear-gradient(135deg, rgba(255, 255, 255, 0.78), rgba(250, 245, 255, 0.5));
} }
-72
View File
@@ -1,72 +0,0 @@
import { defineStore } from 'pinia'
import { getDashboard, getNextSmokeTime } from '@/api/smoke'
export const useDashboardStore = defineStore('dashboard', {
state: () => ({
todayCount: 0,
minutesSinceLast: 0,
weekly: [],
nextSmokeTime: null,
lastFetchTime: 0,
cacheExpiry: 30 * 1000,
loading: false
}),
getters: {
isCacheValid: (state) => {
return Date.now() - state.lastFetchTime < state.cacheExpiry
}
},
actions: {
async fetchDashboard(forceRefresh = false) {
if (!forceRefresh && this.isCacheValid) {
return
}
this.loading = true
try {
const res = await getDashboard()
this.todayCount = res.data.today_count || 0
this.minutesSinceLast = res.data.minutes_since_last || 0
this.weekly = res.data.weekly || []
this.lastFetchTime = Date.now()
} catch (e) {
console.error('fetchDashboard error:', e)
throw e
} finally {
this.loading = false
}
},
async fetchNextSmokeTime() {
try {
const res = await getNextSmokeTime()
this.nextSmokeTime = res.data
return res.data
} catch (e) {
console.error('fetchNextSmokeTime error:', e)
throw e
}
},
setDashboard(data) {
this.todayCount = data.today_count || 0
this.minutesSinceLast = data.minutes_since_last || 0
this.weekly = data.weekly || []
this.lastFetchTime = Date.now()
},
setNextSmokeTime(data) {
this.nextSmokeTime = data
},
incrementTodayCount() {
this.todayCount++
},
resetTimer() {
this.minutesSinceLast = 0
}
}
})
-1
View File
@@ -5,6 +5,5 @@ const pinia = createPinia()
export default pinia export default pinia
export * from './user' export * from './user'
export * from './dashboard'
export * from './profile' export * from './profile'
export * from './logs' export * from './logs'