commit c883ae7b176a363a13bae7affa81ee89433d3b0d Author: nepiedg <806669289@qq.com> Date: Sun Jan 25 11:45:16 2026 +0800 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..14bb5ab --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +unpackage/* diff --git a/App.vue b/App.vue new file mode 100644 index 0000000..359a1c1 --- /dev/null +++ b/App.vue @@ -0,0 +1,168 @@ + + + diff --git a/UI/activity_history/code.html b/UI/activity_history/code.html new file mode 100644 index 0000000..6a99a2b --- /dev/null +++ b/UI/activity_history/code.html @@ -0,0 +1,196 @@ + + + + +历史记录 + + + + + + + + + + + +
+
+

历史记录

+ +
+
+
+ + + +
+
+
+
+
+
+
+

今天, 11月14日

+
+
+
+
+shield +
+
+
+
+
+

已忍住

+成功 +
+

4:20 PM

+
+ +sentiment_stressed + 压力大 + +
+
+
+
+
+
+smoking_rooms +
+
+
+
+

已抽烟

+间隔 1小时30分 +
+

1:15 PM

+
+ +mood_bad + 无聊 + +
+
+
+
+
+
+smoking_rooms +
+
+
+
+

已抽烟

+今日第一支 +
+

11:45 AM

+
+ +sunny + 晨间习惯 + +
+
+
+
+
+
+

昨天, 11月13日

+
+
+
+
+smoking_rooms +
+
+
+
+ + +
+
+
+

已抽烟

+间隔 4小时12分 +
+

9:30 PM

+
+ +local_bar + 社交 + +
+
+
+
+
+
+
+ +
+
+ + \ No newline at end of file diff --git a/UI/activity_history/screen.png b/UI/activity_history/screen.png new file mode 100644 index 0000000..0ac132e Binary files /dev/null and b/UI/activity_history/screen.png differ diff --git a/UI/ai_quit_assistant/code.html b/UI/ai_quit_assistant/code.html new file mode 100644 index 0000000..ad09906 --- /dev/null +++ b/UI/ai_quit_assistant/code.html @@ -0,0 +1,159 @@ + + + + +AI 戒烟助手 + + + + + + + + + + + +
+
+menu +
+
+

AI 戒烟助手

+
+ +在线 +
+
+
+
+
+
+
+
+
+
+
+

第 18/30 天

+
+
+
+

当前减量计划阶段

+

阶段 2:减量期

+

本阶段还剩 12 天

+
+
+
+阶段进度 +40% +
+
+
+
+
+
+
+
+
+
+smart_toy +

每日 AI 分析

+
+
+
+
+
+
+
+
+
+
+

AI 教练

+• 刚刚 +
+
+

早上好 Alex。昨天你的吸烟量比限额少了 2 支。这是一个巨大的胜利!🏆

+

数据显示你的烟瘾在下午 2 点左右达到顶峰——今天试着那个时候去散散步。

+
+
+
+
+
+
+

今日目标

+已完成 1/3 +
+ + + +
+
+
+ +
+ + \ No newline at end of file diff --git a/UI/ai_quit_assistant/screen.png b/UI/ai_quit_assistant/screen.png new file mode 100644 index 0000000..fbe9c10 Binary files /dev/null and b/UI/ai_quit_assistant/screen.png differ diff --git a/UI/home_dashboard/code.html b/UI/home_dashboard/code.html new file mode 100644 index 0000000..63a8bfa --- /dev/null +++ b/UI/home_dashboard/code.html @@ -0,0 +1,166 @@ + + + + +首页控制台 + + + + + + + + + +
+
+
+
+
+
+
+
+

早上好,Alex

+保持连胜纪录!🔥 +
+
+ +
+
+
+
+
+psychology +
+
+

发现规律

+

你的烟瘾通常在下午2点达到高峰。我们为你准备了一个快速呼吸练习。

+
+ +
+
+
+
+
+ + + + +
+距上次抽烟 +

02:45:12

+
+auto_awesome +

下次建议: 16:30

+
+
+
+
+
+
+
+
+今日已抽 +
+
+3 / 10 +较昨日 -2 +
+
+
+
+
+
+
+
+烟瘾发作 +
+
+5 +已抵抗 +
+
+
+
+
+
+
+
+
+
+ + +
+
+ + + \ No newline at end of file diff --git a/UI/home_dashboard/screen.png b/UI/home_dashboard/screen.png new file mode 100644 index 0000000..6755868 Binary files /dev/null and b/UI/home_dashboard/screen.png differ diff --git a/UI/profile_&_settings/code.html b/UI/profile_&_settings/code.html new file mode 100644 index 0000000..f3c8008 --- /dev/null +++ b/UI/profile_&_settings/code.html @@ -0,0 +1,178 @@ + + + + +个人中心 + + + + + + + + + +
+
+
+arrow_back +
+

个人中心

+
+

编辑

+
+
+
+
+
+
+
+photo_camera +
+
+
+

Alex Doe

+
+ + 目标:12月1日戒烟 🎯 + +
+

已连续戒烟 12 天 🔥

+
+
+
+
+

我的进程

+
+
+
+track_changes +
+
+

目标设定

+

调整每日限额与戒烟日期

+
+chevron_right +
+
+
+psychology +
+
+

AI 计划调整

+

个性化辅导风格

+
+chevron_right +
+
+
+
+

偏好设置

+
+
+
+notifications +
+
+

通知设置

+
+chevron_right +
+
+
+diamond +
+
+

解锁会员

+PRO +
+chevron_right +
+
+
+
+

通用

+
+
+
+settings +
+
+

基础设置

+
+chevron_right +
+
+
+security +
+
+

隐私与数据

+
+chevron_right +
+
+
+
+ +

版本 1.0.2

+
+
+
+
+home +首页 +
+
+bar_chart +追踪 +
+
+person +我的 +
+
+
+ + \ No newline at end of file diff --git a/UI/profile_&_settings/screen.png b/UI/profile_&_settings/screen.png new file mode 100644 index 0000000..96b6614 Binary files /dev/null and b/UI/profile_&_settings/screen.png differ diff --git a/UI/smoking_statistics/code.html b/UI/smoking_statistics/code.html new file mode 100644 index 0000000..1766cf7 --- /dev/null +++ b/UI/smoking_statistics/code.html @@ -0,0 +1,243 @@ + + + + +数据统计分析 + + + + + + + + + + + + +
+
+ +

数据统计分析

+ +
+
+
+
+ + + +
+
+
+
+
+auto_awesome +
+
+

每周洞察

+

你在周末的吸烟量明显减少。非常棒!试着在这周一保持这个良好的势头。

+
+
+
+
+
+

吸烟趋势

+
+trending_down +减少 20% +
+
+
+
+

日均吸烟量

+
+4 +支 / 天 +
+
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+

健康与储蓄

+
+
+
+savings +
+
+ + + + +
+已省 +¥145 +
+
+
+

节省金额

+

目标 ¥200

+
+
+
+
+pulmonology +
+
+ + + + +
+40% +
+
+
+

肺部功能恢复

+

当前进度

+
+
+
+
+
+
+
+local_fire_department +连续记录 +
+

12 天

+

未吸烟

+
+
+
+block +已拒绝 +
+

24 次

+

对抗烟瘾

+
+
+
+ +
+ + \ No newline at end of file diff --git a/UI/smoking_statistics/screen.png b/UI/smoking_statistics/screen.png new file mode 100644 index 0000000..7028b5b Binary files /dev/null and b/UI/smoking_statistics/screen.png differ diff --git a/api/auth.js b/api/auth.js new file mode 100644 index 0000000..217db27 --- /dev/null +++ b/api/auth.js @@ -0,0 +1,46 @@ +import { request } from './request' +import { MINI_PROGRAM_ID } from '@/config' +import { storage, SESSION_KEY, USER_KEY } from '@/utils/storage' + +export async function login() { + return new Promise((resolve, reject) => { + uni.login({ + provider: 'weixin', + success: async (loginRes) => { + try { + const res = await request.post('/auth/login', { + mini_program_id: MINI_PROGRAM_ID, + code: loginRes.code + }) + + storage.set(SESSION_KEY, res.data.session_key) + storage.set(USER_KEY, res.data.user) + + resolve(res.data) + } catch (e) { + reject(e) + } + }, + fail: (err) => { + reject(err) + } + }) + }) +} + +export function getUser() { + return storage.get(USER_KEY) +} + +export function getSessionKey() { + return storage.get(SESSION_KEY) +} + +export function isLoggedIn() { + return !!getSessionKey() +} + +export function logout() { + storage.remove(SESSION_KEY) + storage.remove(USER_KEY) +} diff --git a/api/index.js b/api/index.js new file mode 100644 index 0000000..58e033c --- /dev/null +++ b/api/index.js @@ -0,0 +1,3 @@ +export * from './auth' +export * from './smoke' +export * from './profile' diff --git a/api/profile.js b/api/profile.js new file mode 100644 index 0000000..f6f0762 --- /dev/null +++ b/api/profile.js @@ -0,0 +1,9 @@ +import { request } from './request' + +export function getProfile() { + return request.get('/smoke/profile') +} + +export function updateProfile(data) { + return request.put('/smoke/profile', data) +} diff --git a/api/request.js b/api/request.js new file mode 100644 index 0000000..757439d --- /dev/null +++ b/api/request.js @@ -0,0 +1,66 @@ +import { BASE_URL } from '@/config' +import { storage, SESSION_KEY } from '@/utils/storage' + +export const request = { + async request(options) { + const sessionKey = storage.get(SESSION_KEY) + + return new Promise((resolve, reject) => { + uni.request({ + url: BASE_URL + options.url, + method: options.method || 'GET', + data: options.data, + header: { + 'Content-Type': 'application/json', + 'Authorization': sessionKey ? `Bearer ${sessionKey}` : '' + }, + success: async (res) => { + if (res.statusCode === 401) { + const { login } = await import('./auth') + try { + await login() + resolve(this.request(options)) + } catch (e) { + reject(e) + } + return + } + + if (res.statusCode !== 200) { + uni.showToast({ + title: res.data?.message || '请求失败', + icon: 'none' + }) + reject(new Error(res.data?.message || '请求失败')) + return + } + + resolve(res.data) + }, + fail: (err) => { + uni.showToast({ + title: '网络错误', + icon: 'none' + }) + reject(err) + } + }) + }) + }, + + get(url, params) { + return this.request({ url, method: 'GET', data: params }) + }, + + post(url, data) { + return this.request({ url, method: 'POST', data }) + }, + + put(url, data) { + return this.request({ url, method: 'PUT', data }) + }, + + delete(url) { + return this.request({ url, method: 'DELETE' }) + } +} diff --git a/api/smoke.js b/api/smoke.js new file mode 100644 index 0000000..6779bec --- /dev/null +++ b/api/smoke.js @@ -0,0 +1,45 @@ +import { request } from './request' + +export function getDashboard(params = {}) { + return request.get('/smoke/dashboard', params) +} + +export function getNextSmokeTime(params = {}) { + return request.get('/smoke/next_smoke_time', params) +} + +export function getLogs(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) { + return request.post('/smoke/logs', data) +} + +export function updateLog(id, data) { + return request.put(`/smoke/logs/${id}`, data) +} + +export function deleteLog(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) { + return request.post('/smoke/ai/advice_unlocks', data) +} diff --git a/config/index.js b/config/index.js new file mode 100644 index 0000000..2a53188 --- /dev/null +++ b/config/index.js @@ -0,0 +1,13 @@ +const ENV = { + development: { + BASE_URL: 'http://127.0.0.1:8080/api/v1', + MINI_PROGRAM_ID: 1 + }, + production: { + BASE_URL: 'https://api.example.com/api/v1', + MINI_PROGRAM_ID: 1 + } +} + +const env = process.env.NODE_ENV || 'development' +export const { BASE_URL, MINI_PROGRAM_ID } = ENV[env] diff --git a/docs/ALGORITHM.md b/docs/ALGORITHM.md new file mode 100644 index 0000000..4b54bb5 --- /dev/null +++ b/docs/ALGORITHM.md @@ -0,0 +1,319 @@ +# 戒烟算法与 AI 策略说明 + +## 1. 核心理念 + +采用**渐进式递减**策略,而非突然戒断: +- 科学研究表明,逐步减少比冷火鸡戒断成功率更高 +- 通过延长抽烟间隔,让身体逐渐适应尼古丁减少 +- AI 分析个人模式,提供个性化的递减计划 + +--- + +## 2. 默认递减算法 (staircase_delay_v1) + +### 2.1 算法概述 + +``` +下次建议时间 = 上次抽烟时间 + 基础间隔 + 奖励间隔 +``` + +### 2.2 参数说明 + +| 参数 | 来源 | 默认值 | 说明 | +|------|------|--------|------| +| base_interval | profile.baseline_interval_minutes | 60 分钟 | 用户初始平均抽烟间隔 | +| resisted_7d | 近7天忍住次数 | 0 | level=0,num=0 的记录数 | +| bonus_interval | 计算得出 | 0 | 奖励延长时间 | + +### 2.3 奖励机制 + +每累计 **5 次忍住**,基础间隔 **+5 分钟**,最多 **+60 分钟**: + +``` +bonus_interval = min(floor(resisted_7d / 5) * 5, 60) +final_interval = base_interval + bonus_interval +``` + +**示例**: +- 用户基础间隔 48 分钟,近 7 天忍住 12 次 +- bonus = floor(12/5) * 5 = 10 分钟 +- 最终间隔 = 48 + 10 = 58 分钟 + +### 2.4 睡眠规避 + +若计算出的时间落在睡眠区间,顺延到次日起床时间: + +``` +if suggested_time in [sleep_time, wake_up_time]: + suggested_time = next_day_wake_up_time +``` + +### 2.5 算法流程图 + +``` +获取上次抽烟时间 (last_smoke_at) + ↓ +获取用户基础间隔 (base_interval_minutes) + ↓ +统计近7天忍住次数 (resisted_7d) + ↓ +计算奖励间隔: bonus = min(floor(resisted_7d / 5) * 5, 60) + ↓ +计算建议时间: suggested = last_smoke_at + base + bonus + ↓ +检查是否在睡眠时间? + ├── 是 → 顺延到起床时间 + └── 否 → 返回建议时间 +``` + +--- + +## 3. AI 增强算法 + +### 3.1 AI 时间节点规划 + +当用户解锁 AI 功能后,系统会: + +1. **收集近 3 天数据**: + - 每次抽烟的时间点 + - 每次忍住的时间点 + - 抽烟原因/场景标签 + +2. **分析抽烟模式**: + - 高峰时段识别(如下午 2-4 点) + - 触发场景识别(如压力、无聊、社交) + - 间隔规律分析 + +3. **生成个性化时间节点**: + - 避开高峰时段的前半小时 + - 在用户通常能忍住的时段设置节点 + - 逐日递增间隔 + +### 3.2 AI 建议内容 + +AI 会生成以下内容: + +```json +{ + "advice": "昨天你的吸烟量比限额少了2支,这是一个巨大的胜利!数据显示你的烟瘾在下午2点左右达到顶峰——今天试着那个时候去散散步。", + "time_nodes": [ + { "time": "09:30", "type": "suggested", "note": "第一支,早餐后" }, + { "time": "12:30", "type": "suggested", "note": "午餐后" }, + { "time": "15:30", "type": "suggested", "note": "下午茶时间" }, + { "time": "19:00", "type": "suggested", "note": "晚餐后" }, + { "time": "22:00", "type": "suggested", "note": "睡前最后一支" } + ], + "daily_target": 5, + "tips": ["2点是你的高峰期,准备一颗薄荷糖", "试着用深呼吸替代"] +} +``` + +### 3.3 AI Prompt 设计 + +``` +你是一位专业的戒烟辅导教练。基于用户的抽烟数据,提供个性化的戒烟建议。 + +用户档案: +- 日均吸烟量:{baseline_cigs_per_day} 支 +- 烟龄:{smoking_years} 年 +- 抽烟动机:{smoke_motivations} +- 戒烟动力:{quit_motivations} +- 作息:{wake_up_time} - {sleep_time} + +近3天数据: +{recent_logs} + +请分析: +1. 用户的抽烟规律和高峰时段 +2. 主要的触发场景 +3. 成功忍住的模式 + +然后生成: +1. 一段鼓励性的分析总结(2-3句话) +2. 明天的建议抽烟时间节点(比今天少1支) +3. 2-3条实用的应对建议 +``` + +--- + +## 4. 阶段划分 + +### 4.1 三阶段戒烟计划 + +| 阶段 | 时间 | 目标 | 策略 | +|------|------|------|------| +| 记录期 | Day 1-7 | 建立基线 | 正常抽烟,但每次都记录 | +| 减量期 | Day 8-21 | 减少 50% | 每周目标递减,AI 指导 | +| 巩固期 | Day 22-30 | 维持/归零 | 强化抵抗,心理建设 | + +### 4.2 阶段进度计算 + +```javascript +// utils/stage.js +function calculateStage(startDate) { + const daysSinceStart = daysBetween(startDate, new Date()) + + if (daysSinceStart <= 7) { + return { + stage: 1, + name: '记录期', + progress: daysSinceStart / 7, + daysLeft: 7 - daysSinceStart + } + } else if (daysSinceStart <= 21) { + return { + stage: 2, + name: '减量期', + progress: (daysSinceStart - 7) / 14, + daysLeft: 21 - daysSinceStart + } + } else { + return { + stage: 3, + name: '巩固期', + progress: Math.min((daysSinceStart - 21) / 9, 1), + daysLeft: Math.max(30 - daysSinceStart, 0) + } + } +} +``` + +### 4.3 每日目标计算 + +```javascript +// utils/target.js +function calculateDailyTarget(baseline, stage, dayInStage) { + if (stage === 1) { + return baseline + } + + if (stage === 2) { + const reduction = (dayInStage / 14) * 0.5 + return Math.max(Math.round(baseline * (1 - reduction)), 1) + } + + if (stage === 3) { + const targetRate = 0.25 - (dayInStage / 9) * 0.25 + return Math.max(Math.round(baseline * targetRate), 0) + } + + return baseline +} +``` + +--- + +## 5. 健康恢复计算 + +基于医学研究的恢复时间线: + +| 时间点 | 恢复指标 | 计算方式 | +|--------|----------|----------| +| 20分钟 | 心率血压恢复正常 | 固定 | +| 8小时 | 血氧水平恢复 | 固定 | +| 24小时 | 心脏病风险开始下降 | 固定 | +| 48小时 | 嗅觉味觉开始恢复 | 固定 | +| 2周 | 肺功能提升 15% | 线性计算 | +| 1月 | 肺功能提升 30% | 线性计算 | +| 3月 | 肺功能提升 50% | 线性计算 | +| 1年 | 心脏病风险降低 50% | 线性计算 | + +```javascript +// utils/health.js +function calculateLungRecovery(smokeFreeMinutes) { + const days = smokeFreeMinutes / (24 * 60) + + if (days < 14) { + return (days / 14) * 15 + } else if (days < 30) { + return 15 + ((days - 14) / 16) * 15 + } else if (days < 90) { + return 30 + ((days - 30) / 60) * 20 + } else { + return Math.min(50 + ((days - 90) / 275) * 50, 100) + } +} +``` + +--- + +## 6. 省钱计算 + +```javascript +// utils/money.js +function calculateMoneySaved(packPriceCent, cigsPerPack, baselineCigsPerDay, actualCigsTotal, days) { + const expectedTotal = baselineCigsPerDay * days + const savedCigs = expectedTotal - actualCigsTotal + const savedPacks = savedCigs / cigsPerPack + return Math.round(savedPacks * packPriceCent) +} +``` + +--- + +## 7. 激励语生成 + +根据用户状态生成不同的激励语: + +```javascript +// utils/motivation.js +function getMotivationMessage(context) { + const { + minutesSinceLast, + todayCount, + dailyTarget, + resistedToday, + quitMotivations + } = context + + if (resistedToday > 0 && minutesSinceLast < 30) { + return '太棒了!你刚刚成功抵抗了一次烟瘾' + } + + if (todayCount < dailyTarget * 0.5) { + return '今天的表现非常出色,继续保持!' + } + + if (todayCount === dailyTarget - 1) { + return '还剩最后一支配额,考虑把它留到睡前?' + } + + if (todayCount > dailyTarget) { + return `没关系,明天是新的一天。记住你为什么要戒烟:${quitMotivations[0]}` + } + + return '保持连胜纪录!' +} +``` + +--- + +## 8. 数据分析指标 + +### 8.1 关键指标 + +| 指标 | 计算方式 | 用途 | +|------|----------|------| +| 日均吸烟量 | 周期内总量 / 天数 | 趋势对比 | +| 周同比变化 | (本周 - 上周) / 上周 | 进度评估 | +| 忍住成功率 | 忍住次数 / (忍住+抽烟次数) | 意志力评估 | +| 平均间隔 | 总时长 / 抽烟次数 | 递减效果 | +| 最长无烟时长 | 最大间隔记录 | 成就激励 | + +### 8.2 周报数据结构 + +```javascript +// 周报数据结构 +const weeklyReport = { + period: { start: '2026-01-01', end: '2026-01-07' }, + totalCigs: 35, + dailyAverage: 5, + comparedToLastWeek: -20, // 百分比变化 + resistedCount: 12, + longestGap: 180, // 分钟 + peakHours: ['14:00', '21:00'], + topTriggers: ['压力大', '无聊'], + achievements: ['连续7天记录', '单日忍住5次'], + nextWeekTarget: 4 +} +``` diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..132198f --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,334 @@ +# 开发计划与任务拆分 + +## 1. 开发阶段概览 + +``` +Phase 1: 基础框架搭建 (2天) + ↓ +Phase 2: 首页核心功能 (3天) ★ 优先保证首页体验 + ↓ +Phase 3: 记录与历史 (2天) + ↓ +Phase 4: 统计与图表 (2天) + ↓ +Phase 5: AI助手与个人中心 (3天) + ↓ +Phase 6: 优化与测试 (2天) +``` + +--- + +## 2. Phase 1: 基础框架搭建 (2天) + +### 2.1 项目初始化 +- [ ] 创建 uni-app 项目 (Vue3 + JavaScript) +- [ ] 配置路径别名 (@/) +- [ ] 创建目录结构 + +### 2.2 基础配置 +- [ ] 配置 pages.json (页面路由 + TabBar) +- [ ] 配置全局样式 (暗色主题变量) +- [ ] 配置环境变量 (开发/生产 API 地址) + +### 2.3 核心模块 +- [ ] 封装 request.js (请求拦截、Token管理、错误处理) +- [ ] 封装 storage.js (本地存储工具) +- [ ] 配置 Pinia stores 结构 +- [ ] 实现登录流程 (wx.login + 后端认证) + +### 2.4 公共组件 +- [ ] 创建 Loading 组件 +- [ ] 创建 Skeleton 骨架屏组件 +- [ ] 创建 Button 组件 (主按钮/次按钮样式) +- [ ] 创建 Card 组件 (卡片容器) + +**交付物**:可运行的空白项目,登录功能正常 + +--- + +## 3. Phase 2: 首页核心功能 (3天) ★ + +> ⚠️ 首页是用户最常访问的页面,必须保证 < 500ms 首屏渲染 + +### 3.1 Day 1: 页面结构 + 数据层 + +#### API 封装 +- [ ] `api/smoke.js`: getDashboard() +- [ ] `api/smoke.js`: getNextSmokeTime() +- [ ] `api/profile.js`: getProfile() + +#### Store 设计 +- [ ] `stores/dashboard.js`: 看板数据 + 缓存逻辑 +- [ ] `stores/user.js`: 用户信息 + 登录状态 + +#### 页面结构 +- [ ] 首页骨架屏 +- [ ] 页面布局 (header + timer + cards + buttons) + +### 3.2 Day 2: 核心组件 + +#### 计时器组件 (TimerRing) +- [ ] Canvas 绘制进度环 +- [ ] 时间格式化 (HH:MM:SS) +- [ ] requestAnimationFrame 优化 +- [ ] 页面可见性处理 (切后台暂停) + +#### 统计卡片 +- [ ] 今日已抽卡片 (X/目标, 较昨日±N) +- [ ] 烟瘾发作已抵抗卡片 + +#### 快捷按钮 +- [ ] 记录抽烟按钮 → 弹出记录表单 +- [ ] 想抽忍住了按钮 → 快速提交 + +### 3.3 Day 3: 交互 + 优化 + +#### AI 提示卡片 +- [ ] 延迟加载 (300ms后) +- [ ] 可关闭 (本地存储关闭状态) +- [ ] 下次建议时间显示 + +#### 性能优化 +- [ ] 并行请求优化 +- [ ] 骨架屏过渡动画 +- [ ] 首屏性能埋点 + +#### 新用户引导 +- [ ] 检测 profile.exists +- [ ] 跳转引导页逻辑 + +**交付物**:完整可用的首页,首屏 < 500ms + +--- + +## 4. Phase 3: 记录与历史 (2天) + +### 4.1 Day 1: 记录表单 + +#### 记录弹窗组件 +- [ ] 时间选择 (默认当前时间) +- [ ] 原因标签选择 (压力大/无聊/社交/习惯等) +- [ ] 备注输入 +- [ ] 支数选择 (默认1) + +#### API 集成 +- [ ] `POST /logs` 新增记录 +- [ ] `POST /logs/resisted` 忍住记录 +- [ ] 提交后刷新首页数据 + +### 4.2 Day 2: 历史记录页 + +#### 列表页面 +- [ ] 筛选 Tabs (全部/已抽烟/已忍住) +- [ ] 时间线布局 +- [ ] 按日期分组 +- [ ] 下拉刷新 + 上拉加载 + +#### 记录卡片 +- [ ] 类型图标 (抽烟/忍住) +- [ ] 时间 + 原因标签 +- [ ] 间隔时间显示 +- [ ] 左滑操作 (编辑/删除) + +#### 编辑/删除 +- [ ] 编辑弹窗 +- [ ] 删除确认 +- [ ] `PUT/DELETE /logs/:id` + +**交付物**:完整的记录流程,历史记录页可用 + +--- + +## 5. Phase 4: 统计与图表 (2天) + +### 5.1 Day 1: 统计页基础 + +#### 时间范围切换 +- [ ] 周/月/年 Tabs +- [ ] 日期范围计算 +- [ ] 数据请求 (`GET /dashboard?start=&end=`) + +#### 吸烟趋势图 +- [ ] 集成 uCharts / ECharts +- [ ] 柱状图组件封装 +- [ ] 数据格式转换 + +#### 每周洞察卡片 +- [ ] AI 分析展示 (异步加载) + +### 5.2 Day 2: 详细指标 + +#### 健康与储蓄卡片 +- [ ] 节省金额计算 + 环形进度 +- [ ] 肺部功能恢复 + 环形进度 + +#### 成就卡片 +- [ ] 连续记录天数 +- [ ] 已拒绝次数 + +#### 趋势对比 +- [ ] 周同比变化计算 +- [ ] 日均吸烟量 + +**交付物**:完整的统计页,图表正常显示 + +--- + +## 6. Phase 5: AI助手与个人中心 (3天) + +### 6.1 Day 1: AI 助手页 + +#### 阶段进度卡片 +- [ ] 阶段计算逻辑 +- [ ] 进度条展示 +- [ ] 天数倒计时 + +#### 每日 AI 分析 +- [ ] 对话式 UI +- [ ] `GET /ai/advice` 集成 +- [ ] 会员/广告解锁判断 + +#### 今日目标 +- [ ] 任务列表 +- [ ] 完成状态切换 (本地存储) + +### 6.2 Day 2: 个人中心页 + +#### 用户信息 +- [ ] 头像 + 昵称展示 +- [ ] 目标戒烟日期 +- [ ] 连续天数 + +#### 设置项 +- [ ] 目标设定入口 +- [ ] AI 计划调整入口 +- [ ] 通知设置 +- [ ] 会员解锁 + +#### 基础设置 +- [ ] 作息时间设置 +- [ ] 每包价格设置 + +### 6.3 Day 3: 新用户引导 + +#### 引导页面 +- [ ] 分步表单 (5步) +- [ ] 进度指示器 +- [ ] 动画过渡 + +#### 数据收集 +- [ ] 日均吸烟量 +- [ ] 烟龄 +- [ ] 抽烟/戒烟动机 +- [ ] 作息时间 + +#### 提交流程 +- [ ] `PUT /profile` 提交 +- [ ] 完成后跳转首页 + +**交付物**:AI助手页、个人中心页、引导流程完整 + +--- + +## 7. Phase 6: 优化与测试 (2天) + +### 7.1 Day 1: 性能优化 + +#### 首页优化 +- [ ] 首屏时间 < 500ms 验证 +- [ ] 图片懒加载 +- [ ] 组件按需加载 + +#### 缓存优化 +- [ ] 请求缓存策略检查 +- [ ] 本地存储清理策略 + +#### 包体积 +- [ ] 依赖分析 +- [ ] 无用代码移除 +- [ ] 分包加载配置 + +### 7.2 Day 2: 测试与修复 + +#### 功能测试 +- [ ] 全流程测试 (新用户 → 日常使用) +- [ ] 边界情况测试 +- [ ] 网络异常测试 + +#### UI 适配 +- [ ] 不同机型测试 +- [ ] 安全区域适配 + +#### Bug 修复 +- [ ] 测试问题修复 +- [ ] 体验优化 + +**交付物**:可发布的小程序版本 + +--- + +## 8. 开发优先级 + +按重要性和依赖关系排序: + +``` +P0 (必须完成): +├── 登录认证 +├── 首页看板 (计时器 + 今日统计) +├── 记录抽烟/忍住 +├── 历史记录查看 +└── 新用户引导 + +P1 (核心功能): +├── 统计图表 (周视图) +├── 下次建议时间 +├── 个人设置 +└── 基础 AI 建议 + +P2 (增强功能): +├── 月/年统计 +├── AI 时间节点 +├── 会员/广告解锁 +└── 通知提醒 + +P3 (未来迭代): +├── 成就系统 +├── 社交分享 +└── 数据导出 +``` + +--- + +## 9. 技术风险与应对 + +| 风险 | 影响 | 应对措施 | +|------|------|----------| +| 首页加载慢 | 用户体验差 | 骨架屏 + 并行请求 + 缓存 | +| 图表库体积大 | 包体积超标 | 按需引入 + 分包 | +| AI 接口慢 | 等待时间长 | 异步加载 + Loading状态 | +| 网络不稳定 | 数据丢失 | 离线缓存 + 重试机制 | +| 微信审核不通过 | 延期上线 | 提前了解审核规范 | + +--- + +## 10. 验收标准 + +### 首页 +- [ ] 首屏渲染 < 500ms +- [ ] 计时器实时更新 +- [ ] 记录操作 < 3步完成 + +### 记录 +- [ ] 支持快速记录 +- [ ] 支持编辑/删除 +- [ ] 数据实时同步 + +### 统计 +- [ ] 图表正常显示 +- [ ] 数据计算准确 +- [ ] 切换流畅 + +### 整体 +- [ ] 无明显卡顿 +- [ ] 无崩溃闪退 +- [ ] 视觉符合设计稿 diff --git a/docs/PRD.md b/docs/PRD.md new file mode 100644 index 0000000..f43169b --- /dev/null +++ b/docs/PRD.md @@ -0,0 +1,231 @@ +# 戒烟小程序 - 产品需求文档 (PRD) + +## 1. 产品概述 + +### 1.1 产品定位 +一款基于 AI 辅助的科学戒烟小程序,通过记录抽烟行为、数据分析、智能递减计划帮助用户逐步减少吸烟量,最终实现戒烟目标。 + +### 1.2 核心价值 +- **记录追踪**:精确记录每次抽烟/忍住行为 +- **数据洞察**:可视化展示吸烟趋势与规律 +- **AI 指导**:个性化递减建议与时间节点规划 +- **正向激励**:省钱计算、健康恢复、成就系统 + +### 1.3 目标用户 +- 有戒烟意愿的吸烟者 +- 想要减少吸烟量的用户 +- 需要科学方法辅助戒烟的人群 + +--- + +## 2. 功能模块 + +### 2.1 首页 (home_dashboard) + +**核心目标**:快速记录 + 实时激励 + +| 元素 | 说明 | 数据来源 | +|------|------|----------| +| 问候语 | 根据时段显示(早上好/下午好等) + 用户昵称 | 本地计算 + profile | +| AI 提示卡片 | 发现的抽烟规律/建议(可关闭) | `GET /ai/advice` 缓存 | +| 计时环 | 距上次抽烟时间(时:分:秒) | `dashboard.minutes_since_last` | +| 下次建议时间 | 显示建议的下次抽烟时间点 | `GET /next_smoke_time` | +| 今日已抽 | X / 目标数,较昨日 ±N | `dashboard.today_count` | +| 烟瘾发作已抵抗 | 忍住次数统计 | 筛选 `level=0,num=0` 记录 | +| 记录抽烟按钮 | 快速记录一次抽烟 | `POST /logs` | +| 想抽忍住了按钮 | 记录成功抵抗 | `POST /logs/resisted` | + +**性能要求**: +- 首屏渲染 < 500ms +- 关键数据(计时环)优先加载 +- 非关键数据(AI提示)异步延迟加载 + +### 2.2 统计页 (smoking_statistics) + +**核心目标**:数据可视化 + 趋势分析 + +| 功能 | 说明 | 数据来源 | +|------|------|----------| +| 周/月/年切换 | 切换统计时间范围 | `GET /dashboard?start=&end=` | +| 每周洞察 | AI 分析本周表现 | `GET /ai/advice` | +| 吸烟趋势图 | 柱状图展示每日吸烟量 | `dashboard.weekly` | +| 趋势对比 | 较上周减少 X% | 本地计算 | +| 日均吸烟量 | 统计周期内日均值 | 本地计算 | +| 节省金额 | 基于减少量 × 单价计算 | profile + logs | +| 肺部功能恢复 | 根据戒烟天数估算 | 固定公式 | +| 连续记录天数 | 用户活跃天数 | logs 统计 | +| 已拒绝次数 | 累计忍住次数 | `level=0,num=0` 统计 | + +### 2.3 AI 助手页 (ai_quit_assistant) + +**核心目标**:个性化指导 + 阶段管理 + +| 功能 | 说明 | 数据来源 | +|------|------|----------| +| 减量计划卡片 | 当前阶段(第X/30天)、阶段名称、进度 | profile + 本地计算 | +| 每日 AI 分析 | 对话式展示昨日分析与今日建议 | `GET /ai/advice` | +| 今日目标 | 任务清单(喝水、散步、阅读激励等) | AI 生成 + 本地存储 | +| 记录按钮 | 快速记录吸烟或烟瘾 | 跳转记录流程 | + +**阶段划分**: +1. 阶段1 - 记录期 (Day 1-7):建立基线数据 +2. 阶段2 - 减量期 (Day 8-21):逐步减少吸烟量 +3. 阶段3 - 巩固期 (Day 22-30):维持低量/零吸烟 + +### 2.4 历史记录页 (activity_history) + +**核心目标**:记录管理 + 行为回顾 + +| 功能 | 说明 | 数据来源 | +|------|------|----------| +| 筛选 Tabs | 全部 / 已抽烟 / 已忍住 | 前端筛选 | +| 时间线 | 按日期分组展示 | `GET /logs` | +| 记录卡片 | 类型、时间、原因标签、间隔时间 | logs 数据 | +| 左滑操作 | 编辑 / 删除 | `PUT/DELETE /logs/:id` | +| 新增按钮 | 浮动按钮快速新增 | 跳转记录流程 | + +### 2.5 个人中心 (profile_&_settings) + +**核心目标**:用户信息 + 设置管理 + +| 功能 | 说明 | 数据来源 | +|------|------|----------| +| 用户信息 | 头像、昵称 | 微信授权 | +| 目标展示 | 目标戒烟日期、连续天数 | profile | +| 目标设定 | 调整每日限额与戒烟日期 | `PUT /profile` | +| AI 计划调整 | 个性化辅导风格设置 | profile 扩展 | +| 通知设置 | 提醒时间、频率 | 本地存储 | +| 会员解锁 | PRO 功能 / 广告解锁 | 会员系统 | +| 基础设置 | 作息时间等 | `PUT /profile` | +| 隐私与数据 | 数据导出、账号注销 | 待扩展 | + +--- + +## 3. 用户流程 + +### 3.1 新用户引导流程 + +``` +启动小程序 + ↓ +微信登录 (wx.login) + ↓ +检查 profile (GET /profile) + ↓ +exists=false ? → 进入引导页 + ↓ +Step 1: 每日吸烟量 (baseline_cigs_per_day) + ↓ +Step 2: 烟龄 (smoking_years) + ↓ +Step 3: 吸烟动机 (smoke_motivations) + ↓ +Step 4: 戒烟动力 (quit_motivations) + ↓ +Step 5: 作息时间 (wake_up_time, sleep_time) + ↓ +Step 6: 设置目标 (目标日期、每日限额) + ↓ +提交 profile (PUT /profile) + ↓ +进入首页 +``` + +### 3.2 日常使用流程 + +``` +打开首页 + ↓ +查看计时器 + 下次建议时间 + ↓ +[想抽烟时] + ├── 还没到建议时间 → 点击"想抽忍住了" + └── 到了/忍不住 → 点击"记录抽烟" + ↓ +记录表单 (可选填原因) + ↓ +提交成功 → 更新首页数据 +``` + +--- + +## 4. 数据模型 + +### 4.1 用户档案 (profile) + +| 字段 | 类型 | 说明 | +|------|------|------| +| baseline_cigs_per_day | int | 基准日吸烟量 | +| smoking_years | int | 烟龄(年) | +| pack_price_cent | int | 单包价格(分) | +| smoke_motivations | []string | 抽烟动机 | +| quit_motivations | []string | 戒烟动力 | +| wake_up_time | string | 起床时间 HH:MM | +| sleep_time | string | 入睡时间 HH:MM | +| daily_target | int | 每日目标限额(扩展) | +| quit_date | date | 目标戒烟日期(扩展) | + +### 4.2 抽烟记录 (log) + +| 字段 | 类型 | 说明 | +|------|------|------| +| smoke_time | date | 记录日期 | +| smoke_at | datetime | 实际抽烟时间 | +| level | int | 烟瘾等级 0-5 (0=忍住) | +| num | int | 支数 (0=忍住) | +| remark | string | 原因备注 | + +--- + +## 5. 性能优化策略 + +### 5.1 首页加载优化 + +**目标**:首屏 < 500ms + +``` +[并行请求] +├── GET /profile (用户信息,判断是否需引导) +├── GET /dashboard (今日统计,计时器数据) +└── GET /next_smoke_time (下次建议时间) + +[延迟加载] +└── GET /ai/advice (AI提示卡片,非关键) +``` + +**缓存策略**: +- profile: 登录后缓存,变更时更新 +- dashboard: 每次进入刷新,后台定时更新 +- next_smoke_time: 缓存至下次记录 +- ai/advice: 按天缓存 + +### 5.2 数据预加载 + +- 首页加载时预取统计页首屏数据 +- TabBar 切换时使用缓存 + 后台刷新 + +--- + +## 6. 权限与会员 + +### 6.1 免费功能 +- 记录抽烟/忍住 +- 基础统计(周视图) +- 首页计时器 +- 基础递减算法 + +### 6.2 会员/广告解锁功能 +- AI 每日建议 +- AI 时间节点规划 +- 月/年统计视图 +- 数据导出 + +--- + +## 7. 待扩展功能 + +- [ ] 成就系统 (连续X天、累计忍住X次等) +- [ ] 社交分享 (戒烟打卡海报) +- [ ] 提醒推送 (到达建议时间提醒) +- [ ] 健康知识卡片 +- [ ] 紧急求助 (烟瘾强烈时的快速干预) diff --git a/docs/TECH.md b/docs/TECH.md new file mode 100644 index 0000000..1d00944 --- /dev/null +++ b/docs/TECH.md @@ -0,0 +1,485 @@ +# 技术实现方案 + +## 1. 技术栈 + +| 层级 | 技术选型 | 说明 | +|------|----------|------| +| 框架 | uni-app (Vue3 + JavaScript) | 跨平台小程序开发 | +| 状态管理 | Pinia | 轻量级状态管理 | +| 请求 | uni.request 封装 | 统一拦截、Token管理 | +| 图表 | uCharts | 轻量级数据可视化 | +| UI组件 | 自定义组件 | 符合设计规范 | +| 存储 | uni.storage | 本地缓存 | + +--- + +## 2. 项目结构 + +``` +├── pages/ # 页面 +│ ├── index/ # 首页 +│ ├── stats/ # 统计 +│ ├── ai/ # AI助手 +│ ├── logs/ # 历史记录 +│ ├── profile/ # 个人中心 +│ └── onboarding/ # 新用户引导 +├── components/ # 组件 +│ ├── common/ # 通用组件 +│ ├── charts/ # 图表组件 +│ └── business/ # 业务组件 +├── stores/ # Pinia stores +│ ├── user.js # 用户状态 +│ ├── smoke.js # 抽烟记录状态 +│ └── dashboard.js # 首页看板状态 +├── api/ # API封装 +│ ├── request.js # 请求基类 +│ ├── auth.js # 认证接口 +│ ├── smoke.js # 抽烟记录接口 +│ └── profile.js # 用户档案接口 +├── utils/ # 工具函数 +│ ├── time.js # 时间处理 +│ ├── storage.js # 存储封装 +│ └── format.js # 格式化 +├── hooks/ # 组合式函数 +│ ├── useTimer.js # 计时器逻辑 +│ ├── useDashboard.js # 看板数据 +│ └── useAuth.js # 认证逻辑 +└── static/ # 静态资源 + └── icons/ # TabBar图标 +``` + +--- + +## 3. 首页性能优化方案 + +### 3.1 加载策略 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 首页加载时序 │ +├─────────────────────────────────────────────────────────┤ +│ 0ms ─ 页面骨架屏渲染 │ +│ │ │ +│ ├──── 并行请求 ──────────────────────────────────── │ +│ │ ├── /profile (检查用户状态) │ +│ │ ├── /dashboard (核心数据) │ +│ │ └── /next_smoke_time (建议时间) │ +│ │ │ +│ 200ms ─ 核心数据返回,渲染计时器+统计卡片 │ +│ │ │ +│ 300ms ─ 首屏渲染完成 │ +│ │ │ +│ │ ┌── 延迟加载 ────────────────────────────── │ +│ │ └── /ai/advice (AI提示卡片) │ +│ │ │ +│ 500ms ─ 完整页面渲染 │ +└─────────────────────────────────────────────────────────┘ +``` + +### 3.2 缓存策略 + +```javascript +// stores/dashboard.js +import { defineStore } from 'pinia' + +export const useDashboardStore = defineStore('dashboard', { + state: () => ({ + todayCount: 0, + 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 计时器优化 + +```javascript +// hooks/useTimer.js +import { ref, onMounted, onUnmounted } from 'vue' + +export function useTimer(minutesSinceLast) { + const displayTime = ref('00:00:00') + let rafId = null + let lastTimestamp = 0 + + function tick(timestamp) { + if (timestamp - lastTimestamp >= 1000) { + lastTimestamp = timestamp + updateDisplay() + } + rafId = requestAnimationFrame(tick) + } + + onMounted(() => { + rafId = requestAnimationFrame(tick) + }) + + onUnmounted(() => { + cancelAnimationFrame(rafId) + }) + + return { displayTime } +} +``` + +### 3.4 骨架屏 + +首页使用骨架屏避免白屏: + +```vue + +``` + +--- + +## 4. 核心模块实现 + +### 4.1 认证模块 + +```javascript +// api/auth.js +import { request } from './request' +import { MINI_PROGRAM_ID } from '@/config' + +export async function login() { + const [err, loginRes] = await uni.login({ provider: 'weixin' }) + if (err) throw err + + const res = await request.post('/auth/login', { + mini_program_id: MINI_PROGRAM_ID, + code: loginRes.code + }) + + uni.setStorageSync('session_key', res.data.session_key) + uni.setStorageSync('user', res.data.user) + + return res.data +} +``` + +### 4.2 请求封装 + +```javascript +// api/request.js +const BASE_URL = 'https://api.example.com/api/v1' + +export const request = { + async request(options) { + const sessionKey = uni.getStorageSync('session_key') + + const [err, res] = await uni.request({ + url: BASE_URL + options.url, + method: options.method || 'GET', + data: options.data, + header: { + 'Content-Type': 'application/json', + 'Authorization': sessionKey ? `Bearer ${sessionKey}` : '' + } + }) + + if (err) { + uni.showToast({ title: '网络错误', icon: 'none' }) + throw err + } + + if (res.statusCode === 401) { + const { login } = await import('./auth') + await login() + return this.request(options) + } + + if (res.statusCode !== 200) { + throw new Error(res.data?.message || '请求失败') + } + + return res.data + }, + + get(url, params) { + return this.request({ url, method: 'GET', data: params }) + }, + + post(url, data) { + return this.request({ url, method: 'POST', data }) + }, + + put(url, data) { + return this.request({ url, method: 'PUT', data }) + }, + + delete(url) { + return this.request({ url, method: 'DELETE' }) + } +} +``` + +### 4.3 首页数据加载 + +```javascript +// pages/index/index.vue +import { ref, onMounted } from 'vue' +import { useDashboardStore } from '@/stores/dashboard' +import * as api from '@/api/smoke' + +const loading = ref(true) +const dashboardStore = useDashboardStore() + +async function initPage() { + loading.value = true + + try { + const [profileRes, dashboardRes, nextTimeRes] = await Promise.all([ + api.getProfile(), + api.getDashboard(), + api.getNextSmokeTime() + ]) + + if (!profileRes.data.exists || !profileRes.data.is_completed) { + uni.redirectTo({ url: '/pages/onboarding/index' }) + return + } + + dashboardStore.setDashboard(dashboardRes.data) + dashboardStore.setNextSmokeTime(nextTimeRes.data) + + } finally { + loading.value = false + } + + setTimeout(loadAiAdvice, 300) +} + +onMounted(initPage) +``` + +--- + +## 5. 页面路由配置 + +```json +// pages.json +{ + "pages": [ + { "path": "pages/index/index" }, + { "path": "pages/stats/index" }, + { "path": "pages/ai/index" }, + { "path": "pages/logs/index" }, + { "path": "pages/profile/index" }, + { "path": "pages/onboarding/index" } + ], + "tabBar": { + "color": "#6B7280", + "selectedColor": "#4ADE80", + "backgroundColor": "#0D1F17", + "borderStyle": "black", + "list": [ + { "pagePath": "pages/index/index", "text": "首页", "iconPath": "static/icons/home.png", "selectedIconPath": "static/icons/home-active.png" }, + { "pagePath": "pages/stats/index", "text": "统计", "iconPath": "static/icons/stats.png", "selectedIconPath": "static/icons/stats-active.png" }, + { "pagePath": "pages/ai/index", "text": "AI助手", "iconPath": "static/icons/ai.png", "selectedIconPath": "static/icons/ai-active.png" }, + { "pagePath": "pages/logs/index", "text": "记录", "iconPath": "static/icons/logs.png", "selectedIconPath": "static/icons/logs-active.png" }, + { "pagePath": "pages/profile/index", "text": "我的", "iconPath": "static/icons/profile.png", "selectedIconPath": "static/icons/profile-active.png" } + ] + }, + "globalStyle": { + "navigationBarBackgroundColor": "#0D1F17", + "navigationBarTextStyle": "white", + "backgroundColor": "#0D1F17", + "navigationStyle": "custom" + } +} +``` + +--- + +## 6. 状态管理设计 + +### 6.1 Store 划分 + +| Store | 职责 | 持久化 | +|-------|------|--------| +| userStore | 用户信息、登录状态 | 是 | +| profileStore | 用户档案(基准数据) | 是 | +| dashboardStore | 首页看板数据 | 否(实时) | +| logsStore | 记录列表、分页状态 | 否 | + +### 6.2 数据流 + +``` +用户操作 → Action → API请求 → 更新State → 视图更新 + ↓ + 更新本地缓存 +``` + +--- + +## 7. 图表实现 + +### 7.1 周统计柱状图 + +```javascript +// components/charts/WeeklyChart.vue +import { computed } from 'vue' + +const chartData = computed(() => ({ + categories: props.weekly.map(d => formatWeekday(d.date)), + series: [{ + name: '吸烟量', + data: props.weekly.map(d => d.count) + }] +})) + +const opts = { + type: 'column', + color: ['#4ADE80'], + padding: [15, 15, 0, 5], + xAxis: { + disableGrid: true, + fontColor: '#9CA3AF' + }, + yAxis: { + gridColor: '#374151', + fontColor: '#9CA3AF' + }, + extra: { + column: { + width: 20, + barBorderRadius: [4, 4, 0, 0] + } + } +} +``` + +### 7.2 进度环 + +首页计时器使用 Canvas 绘制进度环: + +```javascript +// components/TimerRing.vue +function drawRing(progress) { + const ctx = uni.createCanvasContext('timerCanvas', this) + const centerX = 150 + const centerY = 150 + const radius = 120 + + // 背景环 + ctx.beginPath() + ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI) + ctx.setStrokeStyle('#1F3D2B') + ctx.setLineWidth(12) + ctx.stroke() + + // 进度环 + ctx.beginPath() + ctx.arc(centerX, centerY, radius, -Math.PI/2, -Math.PI/2 + progress * 2 * Math.PI) + ctx.setStrokeStyle('#4ADE80') + ctx.setLineWidth(12) + ctx.setLineCap('round') + ctx.stroke() + + ctx.draw() +} +``` + +--- + +## 8. 错误处理 + +### 8.1 全局错误拦截 + +```javascript +// api/request.js +function handleError(error) { + if (error.statusCode === 401) { + return reLogin() + } + + if (error.statusCode === 403) { + return { needUnlock: true, error } + } + + if (error.errMsg && error.errMsg.includes('network')) { + uni.showToast({ title: '网络连接失败', icon: 'none' }) + } + + throw error +} +``` + +### 8.2 页面级错误处理 + +```javascript +// pages/index/index.vue +async function initPage() { + try { + await loadData() + } catch (error) { + showRetry.value = true + } +} +``` + +--- + +## 9. 性能监控 + +```javascript +// utils/performance.js +export function trackPageLoad(pageName) { + const startTime = Date.now() + + return { + markFirstPaint() { + const fp = Date.now() - startTime + console.log(`[Perf] ${pageName} First Paint: ${fp}ms`) + }, + + markFullyLoaded() { + const loaded = Date.now() - startTime + console.log(`[Perf] ${pageName} Fully Loaded: ${loaded}ms`) + } + } +} +``` + +--- + +## 10. 环境配置 + +```javascript +// config/index.js +const ENV = { + development: { + BASE_URL: 'http://127.0.0.1:8080/api/v1', + MINI_PROGRAM_ID: 1 + }, + production: { + BASE_URL: 'https://api.example.com/api/v1', + MINI_PROGRAM_ID: 1 + } +} + +const env = process.env.NODE_ENV || 'development' +export const { BASE_URL, MINI_PROGRAM_ID } = ENV[env] +``` diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..f3b25a9 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,416 @@ +# 戒烟/抽烟记录 API + +所有接口前缀:`/api/v1/smoke` +除登录外都需要:`Authorization: Bearer `(见:`docs/common/auth.md`) + +## 1) 新增记录 + +`POST /api/v1/smoke/logs` + +请求体: + +```json +{ + "smoke_time": "2025-12-31", + "smoke_at": "2025-12-31 08:30:00", + "remark": "压力大", + "level": 2, + "num": 3 +} +``` + +说明: +- `smoke_time` 可选;不传则默认“当天”。 +- `smoke_at` 可选;真实抽烟时间(格式 `YYYY-MM-DD HH:MM:SS`)。用于“按时间节点分析/AI 建议”;不传则可用 `createtime` 近似。 +- `level/num` 可选;不传时后端会按 `1` 处理。 +- 如果要记录“想抽但忍住了”,请传 `level=0` 且 `num=0`(会在 `fa_smoke_log` 中展示为一条记录,但不会影响看板的支数累加)。 + +curl 示例: + +```bash +curl -X POST 'http://127.0.0.1:8080/api/v1/smoke/logs' \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer wx-session-key' \ + -d '{"smoke_time":"2025-12-31","smoke_at":"2025-12-31 08:30:00","remark":"压力大","level":2,"num":3}' +``` + +成功响应示例(字段以实际为准): + +```json +{ + "code": 200, + "message": "success", + "data": { + "id": 5202, + "smoke_time": "2025-12-31T00:00:00+08:00", + "smoke_at": "2025-12-31T08:30:00+08:00", + "remark": "压力大", + "createtime": 1735600000, + "updatetime": 1735600000, + "deletetime": null, + "level": 2, + "num": 3 + } +} +``` + +## 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` + +参数: +- `page`:页码,默认 `1` +- `page_size`:每页数量,默认 `20`,最大 `200` +- `start/end`:可选,按 `smoke_time` 过滤(格式 `YYYY-MM-DD`) + +成功响应示例: + +```json +{ + "code": 200, + "message": "success", + "data": { + "items": [], + "total": 0, + "page": 1, + "page_size": 20 + } +} +``` + +## 4) 获取看板概览 + +`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) 更新记录 + +`PUT /api/v1/smoke/logs/:id` + +请求体(字段可选,按需传): + +```json +{ + "smoke_time": "2026-01-01", + "smoke_at": "2026-01-01 21:10:00", + "remark": "聚会", + "level": 3, + "num": 1 +} +``` + +注意: +- 如果你想“清空 smoke_time”,请传空字符串:`{"smoke_time":""}`。 +- 如果你想“清空 smoke_at”,请传空字符串:`{"smoke_at":""}`。 +- 如果传 `null` 或者不传 `smoke_time`,后端会认为你没有修改该字段。 + +## 7) 删除记录(软删除) + +`DELETE /api/v1/smoke/logs/:id` + +成功响应: + +```json +{ + "code": 200, + "message": "success", + "data": { + "deleted": true + } +} +``` + +## 8) 获取 AI 戒烟建议(会员 + 广告解锁并行) + +`GET /api/v1/smoke/ai/advice?date=2026-01-02` + +说明: +- `date` 可选,默认“昨天”(建议针对哪一天的数据)。 +- 权限:会员用户直接可用;非会员需要先对该 `date` 完成“看广告解锁”(见下一个接口)。 +- 建议结果会按 `uid + date + prompt_version` 缓存(表:`fa_smoke_ai_advice`)。 + +未满足权限时的建议响应(示例): +```json +{ + "code": 403, + "message": "需要会员或观看广告解锁后才可生成建议", + "data": { + "date": "2026-01-02", + "need": "vip_or_ad" + } +} +``` + +成功响应(示例): +```json +{ + "code": 200, + "message": "success", + "data": { + "date": "2026-01-02", + "advice": "..." + } +} +``` + +## 9) 看广告解锁(用于非会员) + +`POST /api/v1/smoke/ai/advice_unlocks` + +请求体: +```json +{ + "date": "2026-01-02", + "ad_watched_at": "2026-01-03 09:00:00" +} +``` + +说明: +- 该接口用于记录“已完成观看广告”,落库到 `fa_smoke_ai_advice_unlocks`(`uid + unlock_date` 唯一)。 +- `ad_watched_at` 可由后端取当前时间;如需审计/对账可保留前端上报并做校验。 +- 解锁是“按天”的:观看一次广告解锁一天内的 AI 生成功能(可用于「每日 AI 建议」以及「AI 下次抽烟时间节点」)。 +- 如果你要生成“明天”的 AI 时间节点,请把 `date` 传为明天日期(例如 `2026-01-06`)。 + +## 10) 获取用户基础信息(首次进入:判断是否需要补全) + +`GET /api/v1/smoke/profile` + +说明: +- 首次进入小程序建议先调用该接口:若 `exists=false` 或 `is_completed=false`,前端进入“信息补全”流程。 +- `baseline_interval_minutes` 用于建立初始基准:在用户清醒时段内的“平均间隔(分钟)”。计算:`awake_minutes / baseline_cigs_per_day`。 +- 若未提供作息时间(起床/入睡),后端会用默认清醒时长 `16*60=960` 分钟参与计算。 + +成功响应(示例): + +```json +{ + "code": 200, + "message": "success", + "data": { + "exists": true, + "profile": { + "id": 1, + "created_at": "2026-01-05T10:00:00+08:00", + "updated_at": "2026-01-05T10:00:00+08:00", + "baseline_cigs_per_day": 20, + "smoking_years": 8, + "pack_price_cent": 2500, + "smoke_motivations": ["压力大", "社交"], + "quit_motivations": ["身体健康", "省钱"], + "wake_up_time": "07:30", + "sleep_time": "23:30", + "onboarding_completed_at": "2026-01-05T10:00:00+08:00" + }, + "is_completed": true, + "awake_minutes": 960, + "baseline_interval_minutes": 48 + } +} +``` + +当 `exists=false`(尚未补全)时,响应示例: + +```json +{ + "code": 200, + "message": "success", + "data": { + "exists": false, + "is_completed": false, + "awake_minutes": 960, + "baseline_interval_minutes": 0 + } +} +``` + +字段用途(补全页面可参考): +- `baseline_cigs_per_day`(基础烟量/日均抽烟支数):建立初始基准,计算初始建议间隔时长。 +- `smoking_years`(烟龄/年)+ `pack_price_cent`(单包价格/分):用于看板计算“已省金额”和“恢复时长”等指标(公式可在看板端实现)。 +- `smoke_motivations`(抽烟动机):如 `压力大/无聊/社交/提神`,用于 AI 在分析 remark 时更有针对性。 +- `quit_motivations`(戒烟动力):如 `身体健康/家人孩子/省钱`,当用户产生动摇时 AI 可用这些信息做“情感阻断/自我提醒”。 +- `wake_up_time` + `sleep_time`(作息时间):用于自动规避睡眠时间,防止在用户睡觉时提醒其“坚持”。 + +## 11) 补全/更新用户基础信息(Upsert) + +`PUT /api/v1/smoke/profile` + +说明: +- 字段按需传;首次进入建议一次性补全。 +- 作息时间格式:`HH:MM`(24 小时制),例如 `07:30`、`23:10`。 +- `pack_price_cent` 为“分”;若前端用“元”,请乘以 100。 + +请求体(示例): + +```json +{ + "baseline_cigs_per_day": 20, + "smoking_years": 8, + "pack_price_cent": 2500, + "smoke_motivations": ["压力大", "社交"], + "quit_motivations": ["身体健康", "省钱"], + "wake_up_time": "07:30", + "sleep_time": "23:30" +} +``` + +成功响应:同 `GET /api/v1/smoke/profile`(返回最新 `profile` + `is_completed` + `baseline_interval_minutes`)。 + +## 12) 想抽但忍住了(写入一条 level=0,num=0 的记录) + +`POST /api/v1/smoke/logs/resisted` + +请求体(示例): +```json +{ + "smoke_time": "2026-01-05", + "smoke_at": "2026-01-05 10:20:00", + "remark": "压力大,想抽但忍住了" +} +``` + +说明: +- 该接口会在 `fa_smoke_log` 中新增一条记录:`level=0` 且 `num=0`,用于更直观记录“想抽/忍住”的过程。 +- 这类记录不会影响 `today_count/weekly.count` 的支数统计(因为 `num=0`)。 + +## 13) 获取“下次抽烟记录时间”(默认 + AI 自动切换) + +`GET /api/v1/smoke/next_smoke_time` + +说明: +- 用于首页展示“建议的下次记录时间”。 +- 如果指定日期存在 AI 给出的时间节点(`time_nodes` 不为空),则优先使用 AI 的建议;否则使用默认策略。 +- 可选参数: + - `date`:计划日期(默认今天),支持 `YYYY-MM-DD` 或 `today/tomorrow`。 + - `mode`(默认 `auto`) + - `auto`:只在已存在 AI 时间节点时使用 AI(不主动生成) + - `ai`:生成该 `date` 的 AI 时间节点(需要先看广告解锁;生成一次缓存一天) + - `default`:永远返回默认策略 + +默认策略(不使用 AI): +- 基础间隔:优先使用 `GET /api/v1/smoke/profile` 返回的 `baseline_interval_minutes`;若不存在则默认 `60` 分钟。 +- 阶梯式延时:最近 7 天内每累计 `5` 条“忍住记录(level=0,num=0)”,在基础间隔上 `+5` 分钟(最多 `+60` 分钟)。 +- 若用户已补全作息时间,会自动规避睡眠区间:若计算出的时间落在睡眠区间,顺延到下一次起床时间。 + +AI 生成说明: +- 当 `mode=ai` 时,会把最近 3 天的抽烟数据(含“忍住记录”)作为输入提供给 AI,用于更贴合近期模式生成时间节点。 +- 未解锁时会返回 `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", + "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 +{ + "code": 200, + "message": "success", + "data": { + "source": "ai", + "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分钟快走+喝水,压力场景用深呼吸替代。", + "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" + } + } +} +``` diff --git a/docs/auth.md b/docs/auth.md new file mode 100644 index 0000000..68305a8 --- /dev/null +++ b/docs/auth.md @@ -0,0 +1,64 @@ +# 认证与登录 + +## 1) 登录 + +`POST /api/v1/auth/login` + +说明:小程序端调用 `wx.login()` 获取 `code`,后端用该 `code` 向微信 `jscode2session` 换取 `openid/session_key`,并在数据库中创建/更新用户记录。 + +请求示例: + +```bash +curl -X POST 'http://127.0.0.1:8080/api/v1/auth/login' \ + -H 'Content-Type: application/json' \ + -d '{ + "mini_program_id": 2, + "code": "wx.login 返回的 code", + "nickname": "可选:昵称", + "avatar_url": "可选:头像", + "gender": 1, + "phone": "可选:手机号" + }' +``` + +成功响应示例(节选): + +```json +{ + "code": 200, + "message": "success", + "data": { + "user": { + "id": 1, + "mini_program_id": 1, + "open_id": "oXXX", + "nickname": "昵称", + "avatar_url": "https://...", + "gender": 1, + "phone": "110" + }, + "session_key": "wx-session-key", + "mini_program": { + "id": 1, + "name": "某小程序", + "app_id": "wx..." + } + } +} +``` + +## 2) 受保护接口如何带 Token + +后端把 `session_key` 当做 Token 使用,调用受保护接口时在 Header 中带: + +``` +Authorization: Bearer +``` + +请求示例: + +```bash +curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs?page=1&page_size=20' \ + -H 'Authorization: Bearer wx-session-key' +``` + diff --git a/index.html b/index.html new file mode 100644 index 0000000..b5d330d --- /dev/null +++ b/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + +
+ + + diff --git a/main.js b/main.js new file mode 100644 index 0000000..33bb97f --- /dev/null +++ b/main.js @@ -0,0 +1,24 @@ +import App from './App' +import pinia from './stores' + +// #ifndef VUE3 +import Vue from 'vue' +import './uni.promisify.adaptor' +Vue.config.productionTip = false +App.mpType = 'app' +const app = new Vue({ + ...App +}) +app.$mount() +// #endif + +// #ifdef VUE3 +import { createSSRApp } from 'vue' +export function createApp() { + const app = createSSRApp(App) + app.use(pinia) + return { + app + } +} +// #endif diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..dd08fb5 --- /dev/null +++ b/manifest.json @@ -0,0 +1,72 @@ +{ + "name" : "smt", + "appid" : "__UNI__5B98909", + "description" : "", + "versionName" : "1.0.0", + "versionCode" : "100", + "transformPx" : false, + /* 5+App特有相关 */ + "app-plus" : { + "usingComponents" : true, + "nvueStyleCompiler" : "uni-app", + "compilerVersion" : 3, + "splashscreen" : { + "alwaysShowBeforeRender" : true, + "waiting" : true, + "autoclose" : true, + "delay" : 0 + }, + /* 模块配置 */ + "modules" : {}, + /* 应用发布信息 */ + "distribute" : { + /* android打包配置 */ + "android" : { + "permissions" : [ + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ] + }, + /* ios打包配置 */ + "ios" : {}, + /* SDK配置 */ + "sdkConfigs" : {} + } + }, + /* 快应用特有相关 */ + "quickapp" : {}, + /* 小程序特有相关 */ + "mp-weixin" : { + "appid" : "", + "setting" : { + "urlCheck" : false + }, + "usingComponents" : true + }, + "mp-alipay" : { + "usingComponents" : true + }, + "mp-baidu" : { + "usingComponents" : true + }, + "mp-toutiao" : { + "usingComponents" : true + }, + "uniStatistics" : { + "enable" : false + }, + "vueVersion" : "3" +} diff --git a/pages.json b/pages.json new file mode 100644 index 0000000..f816876 --- /dev/null +++ b/pages.json @@ -0,0 +1,87 @@ +{ + "pages": [ + { + "path": "pages/index/index", + "style": { + "navigationStyle": "custom" + } + }, + { + "path": "pages/stats/index", + "style": { + "navigationBarTitleText": "数据统计分析" + } + }, + { + "path": "pages/ai/index", + "style": { + "navigationBarTitleText": "AI戒烟助手" + } + }, + { + "path": "pages/logs/index", + "style": { + "navigationBarTitleText": "历史记录" + } + }, + { + "path": "pages/profile/index", + "style": { + "navigationBarTitleText": "个人中心" + } + }, + { + "path": "pages/onboarding/index", + "style": { + "navigationStyle": "custom" + } + } + ], + "globalStyle": { + "navigationBarTextStyle": "white", + "navigationBarTitleText": "戒烟助手", + "navigationBarBackgroundColor": "#0D1F17", + "backgroundColor": "#0D1F17", + "backgroundColorTop": "#0D1F17", + "backgroundColorBottom": "#0D1F17" + }, + "tabBar": { + "color": "#6B7280", + "selectedColor": "#4ADE80", + "backgroundColor": "#0D1F17", + "borderStyle": "black", + "list": [ + { + "pagePath": "pages/index/index", + "text": "首页", + "iconPath": "static/icons/home.png", + "selectedIconPath": "static/icons/home-active.png" + }, + { + "pagePath": "pages/stats/index", + "text": "统计", + "iconPath": "static/icons/stats.png", + "selectedIconPath": "static/icons/stats-active.png" + }, + { + "pagePath": "pages/ai/index", + "text": "AI助手", + "iconPath": "static/icons/ai.png", + "selectedIconPath": "static/icons/ai-active.png" + }, + { + "pagePath": "pages/logs/index", + "text": "记录", + "iconPath": "static/icons/logs.png", + "selectedIconPath": "static/icons/logs-active.png" + }, + { + "pagePath": "pages/profile/index", + "text": "我的", + "iconPath": "static/icons/profile.png", + "selectedIconPath": "static/icons/profile-active.png" + } + ] + }, + "uniIdRouter": {} +} diff --git a/pages/ai/index.vue b/pages/ai/index.vue new file mode 100644 index 0000000..728c3ee --- /dev/null +++ b/pages/ai/index.vue @@ -0,0 +1,316 @@ + + + + + diff --git a/pages/index/index.vue b/pages/index/index.vue new file mode 100644 index 0000000..a6e66bc --- /dev/null +++ b/pages/index/index.vue @@ -0,0 +1,551 @@ + + + + + diff --git a/pages/logs/index.vue b/pages/logs/index.vue new file mode 100644 index 0000000..8492d22 --- /dev/null +++ b/pages/logs/index.vue @@ -0,0 +1,312 @@ + + + + + diff --git a/pages/onboarding/index.vue b/pages/onboarding/index.vue new file mode 100644 index 0000000..5e9744c --- /dev/null +++ b/pages/onboarding/index.vue @@ -0,0 +1,362 @@ + + + + + diff --git a/pages/profile/index.vue b/pages/profile/index.vue new file mode 100644 index 0000000..74aaf1f --- /dev/null +++ b/pages/profile/index.vue @@ -0,0 +1,285 @@ + + + + + diff --git a/pages/stats/index.vue b/pages/stats/index.vue new file mode 100644 index 0000000..772f556 --- /dev/null +++ b/pages/stats/index.vue @@ -0,0 +1,355 @@ + + + + + diff --git a/stores/dashboard.js b/stores/dashboard.js new file mode 100644 index 0000000..e7fbc05 --- /dev/null +++ b/stores/dashboard.js @@ -0,0 +1,72 @@ +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 + } + } +}) diff --git a/stores/index.js b/stores/index.js new file mode 100644 index 0000000..e025a24 --- /dev/null +++ b/stores/index.js @@ -0,0 +1,9 @@ +import { createPinia } from 'pinia' + +const pinia = createPinia() + +export default pinia + +export * from './user' +export * from './dashboard' +export * from './profile' diff --git a/stores/profile.js b/stores/profile.js new file mode 100644 index 0000000..9e981d2 --- /dev/null +++ b/stores/profile.js @@ -0,0 +1,53 @@ +import { defineStore } from 'pinia' +import { storage, PROFILE_KEY } from '@/utils/storage' +import { getProfile, updateProfile } from '@/api/profile' + +export const useProfileStore = defineStore('profile', { + state: () => ({ + exists: false, + isCompleted: false, + profile: storage.get(PROFILE_KEY), + awakeMinutes: 960, + baselineIntervalMinutes: 60 + }), + + getters: { + needOnboarding: (state) => !state.exists || !state.isCompleted + }, + + actions: { + async fetchProfile() { + try { + const res = await getProfile() + this.exists = res.data.exists + this.isCompleted = res.data.is_completed + this.awakeMinutes = res.data.awake_minutes || 960 + this.baselineIntervalMinutes = res.data.baseline_interval_minutes || 60 + + if (res.data.profile) { + this.profile = res.data.profile + storage.set(PROFILE_KEY, res.data.profile) + } + + return res.data + } catch (e) { + console.error('fetchProfile error:', e) + throw e + } + }, + + async saveProfile(data) { + try { + const res = await updateProfile(data) + this.exists = res.data.exists + this.isCompleted = res.data.is_completed + this.profile = res.data.profile + storage.set(PROFILE_KEY, res.data.profile) + return res.data + } catch (e) { + console.error('saveProfile error:', e) + throw e + } + } + } +}) diff --git a/stores/user.js b/stores/user.js new file mode 100644 index 0000000..69e3af6 --- /dev/null +++ b/stores/user.js @@ -0,0 +1,28 @@ +import { defineStore } from 'pinia' +import { storage, USER_KEY, SESSION_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) + }), + + actions: { + setUser(user, sessionKey) { + this.user = user + this.sessionKey = sessionKey + this.isLoggedIn = true + storage.set(USER_KEY, user) + storage.set(SESSION_KEY, sessionKey) + }, + + logout() { + this.user = null + this.sessionKey = null + this.isLoggedIn = false + storage.remove(USER_KEY) + storage.remove(SESSION_KEY) + } + } +}) diff --git a/uni.promisify.adaptor.js b/uni.promisify.adaptor.js new file mode 100644 index 0000000..5fec4f3 --- /dev/null +++ b/uni.promisify.adaptor.js @@ -0,0 +1,13 @@ +uni.addInterceptor({ + returnValue (res) { + if (!(!!res && (typeof res === "object" || typeof res === "function") && typeof res.then === "function")) { + return res; + } + return new Promise((resolve, reject) => { + res.then((res) => { + if (!res) return resolve(res) + return res[0] ? reject(res[0]) : resolve(res[1]) + }); + }); + }, +}); \ No newline at end of file diff --git a/uni.scss b/uni.scss new file mode 100644 index 0000000..b9249e9 --- /dev/null +++ b/uni.scss @@ -0,0 +1,76 @@ +/** + * 这里是uni-app内置的常用样式变量 + * + * uni-app 官方扩展插件及插件市场(https://ext.dcloud.net.cn)上很多三方插件均使用了这些样式变量 + * 如果你是插件开发者,建议你使用scss预处理,并在插件代码中直接使用这些变量(无需 import 这个文件),方便用户通过搭积木的方式开发整体风格一致的App + * + */ + +/** + * 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能 + * + * 如果你的项目同样使用了scss预处理,你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件 + */ + +/* 颜色变量 */ + +/* 行为相关颜色 */ +$uni-color-primary: #007aff; +$uni-color-success: #4cd964; +$uni-color-warning: #f0ad4e; +$uni-color-error: #dd524d; + +/* 文字基本颜色 */ +$uni-text-color:#333;//基本色 +$uni-text-color-inverse:#fff;//反色 +$uni-text-color-grey:#999;//辅助灰色,如加载更多的提示信息 +$uni-text-color-placeholder: #808080; +$uni-text-color-disable:#c0c0c0; + +/* 背景颜色 */ +$uni-bg-color:#ffffff; +$uni-bg-color-grey:#f8f8f8; +$uni-bg-color-hover:#f1f1f1;//点击状态颜色 +$uni-bg-color-mask:rgba(0, 0, 0, 0.4);//遮罩颜色 + +/* 边框颜色 */ +$uni-border-color:#c8c7cc; + +/* 尺寸变量 */ + +/* 文字尺寸 */ +$uni-font-size-sm:12px; +$uni-font-size-base:14px; +$uni-font-size-lg:16px; + +/* 图片尺寸 */ +$uni-img-size-sm:20px; +$uni-img-size-base:26px; +$uni-img-size-lg:40px; + +/* Border Radius */ +$uni-border-radius-sm: 2px; +$uni-border-radius-base: 3px; +$uni-border-radius-lg: 6px; +$uni-border-radius-circle: 50%; + +/* 水平间距 */ +$uni-spacing-row-sm: 5px; +$uni-spacing-row-base: 10px; +$uni-spacing-row-lg: 15px; + +/* 垂直间距 */ +$uni-spacing-col-sm: 4px; +$uni-spacing-col-base: 8px; +$uni-spacing-col-lg: 12px; + +/* 透明度 */ +$uni-opacity-disabled: 0.3; // 组件禁用态的透明度 + +/* 文章场景相关 */ +$uni-color-title: #2C405A; // 文章标题颜色 +$uni-font-size-title:20px; +$uni-color-subtitle: #555555; // 二级标题颜色 +$uni-font-size-subtitle:26px; +$uni-color-paragraph: #3F536E; // 文章段落颜色 +$uni-font-size-paragraph:15px; diff --git a/utils/format.js b/utils/format.js new file mode 100644 index 0000000..c1ad812 --- /dev/null +++ b/utils/format.js @@ -0,0 +1,41 @@ +export function formatMoney(cent) { + if (!cent && cent !== 0) return '¥0' + const yuan = cent / 100 + return `¥${yuan.toFixed(yuan % 1 === 0 ? 0 : 2)}` +} + +export function formatPercent(value, decimals = 0) { + if (!value && value !== 0) return '0%' + return `${(value * 100).toFixed(decimals)}%` +} + +export function formatNumber(num) { + if (!num && num !== 0) return '0' + return num.toLocaleString() +} + +export function formatChange(current, previous) { + if (!previous) return { text: '', class: '' } + + const diff = current - previous + const percent = Math.round((diff / previous) * 100) + + if (diff < 0) { + return { + text: `较昨日 ${diff}`, + class: 'change-down', + percent: `${percent}%` + } + } else if (diff > 0) { + return { + text: `较昨日 +${diff}`, + class: 'change-up', + percent: `+${percent}%` + } + } + return { + text: '与昨日持平', + class: 'change-same', + percent: '0%' + } +} diff --git a/utils/index.js b/utils/index.js new file mode 100644 index 0000000..0504083 --- /dev/null +++ b/utils/index.js @@ -0,0 +1,3 @@ +export * from './storage' +export * from './time' +export * from './format' diff --git a/utils/storage.js b/utils/storage.js new file mode 100644 index 0000000..fc4937b --- /dev/null +++ b/utils/storage.js @@ -0,0 +1,41 @@ +const STORAGE_PREFIX = 'smt_' + +export const storage = { + set(key, value) { + try { + uni.setStorageSync(STORAGE_PREFIX + key, JSON.stringify(value)) + } catch (e) { + console.error('Storage set error:', e) + } + }, + + get(key, defaultValue = null) { + try { + const value = uni.getStorageSync(STORAGE_PREFIX + key) + return value ? JSON.parse(value) : defaultValue + } catch (e) { + console.error('Storage get error:', e) + return defaultValue + } + }, + + remove(key) { + try { + uni.removeStorageSync(STORAGE_PREFIX + key) + } catch (e) { + console.error('Storage remove error:', e) + } + }, + + clear() { + try { + uni.clearStorageSync() + } catch (e) { + console.error('Storage clear error:', e) + } + } +} + +export const SESSION_KEY = 'session_key' +export const USER_KEY = 'user' +export const PROFILE_KEY = 'profile' diff --git a/utils/time.js b/utils/time.js new file mode 100644 index 0000000..9504a16 --- /dev/null +++ b/utils/time.js @@ -0,0 +1,83 @@ +export function formatTime(date) { + if (!date) return '' + if (typeof date === 'string') { + date = new Date(date) + } + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + return `${hours}:${minutes}` +} + +export function formatDate(date) { + if (!date) return '' + if (typeof date === 'string') { + date = new Date(date) + } + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +export function formatDateTime(date) { + if (!date) return '' + if (typeof date === 'string') { + date = new Date(date) + } + return `${formatDate(date)} ${formatTime(date)}:${String(date.getSeconds()).padStart(2, '0')}` +} + +export function formatDuration(minutes) { + if (!minutes || minutes < 0) return '0分钟' + + const hours = Math.floor(minutes / 60) + const mins = Math.round(minutes % 60) + + if (hours === 0) { + return `${mins}分钟` + } + if (mins === 0) { + return `${hours}小时` + } + return `${hours}小时${mins}分` +} + +export function formatTimerDisplay(totalSeconds) { + const hours = Math.floor(totalSeconds / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const seconds = totalSeconds % 60 + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}` +} + +export function getGreeting() { + const hour = new Date().getHours() + if (hour < 6) return '凌晨好' + if (hour < 12) return '早上好' + if (hour < 14) return '中午好' + if (hour < 18) return '下午好' + return '晚上好' +} + +export function isToday(dateStr) { + const today = new Date().toISOString().split('T')[0] + return dateStr === today +} + +export function isYesterday(dateStr) { + const yesterday = new Date() + yesterday.setDate(yesterday.getDate() - 1) + return dateStr === yesterday.toISOString().split('T')[0] +} + +export function daysBetween(date1, date2) { + const d1 = new Date(date1) + const d2 = new Date(date2) + const diffTime = Math.abs(d2 - d1) + return Math.ceil(diffTime / (1000 * 60 * 60 * 24)) +} + +export function getWeekday(dateStr) { + const weekdays = ['日', '一', '二', '三', '四', '五', '六'] + const date = new Date(dateStr) + return weekdays[date.getDay()] +}