feat: refresh UI and add vite ci workflow

This commit is contained in:
你çšnepiedg
2026-03-18 19:24:51 +08:00
parent 31e504a997
commit 55f5c216bd
50 changed files with 13304 additions and 437 deletions
+222
View File
@@ -0,0 +1,222 @@
<script>
import { login, isLoggedIn } from '@/api/auth'
export default {
globalData: {
loginReady: false,
loginPromise: null
},
onLaunch: function() {
console.log('App Launch')
this.globalData.loginPromise = this.initLogin()
},
onShow: function() {
console.log('App Show')
},
onHide: function() {
console.log('App Hide')
},
methods: {
async initLogin() {
try {
if (!isLoggedIn()) {
console.log('未登录,开始静默登录...')
await login()
console.log('静默登录成功')
} else {
console.log('已登录')
}
this.globalData.loginReady = true
return true
} catch (e) {
console.error('静默登录失败:', e)
this.globalData.loginReady = true
return false
}
},
waitForLogin() {
return this.globalData.loginPromise
}
}
}
</script>
<style>
page {
background:
radial-gradient(circle at top left, rgba(52, 200, 160, 0.14), transparent 28%),
radial-gradient(circle at top right, rgba(255, 255, 255, 0.78), transparent 24%),
linear-gradient(180deg, #eef3f8 0%, #f5f7fb 38%, #fbfdff 100%);
color: #111827;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 28rpx;
line-height: 1.5;
}
.container {
padding: 32rpx;
min-height: 100vh;
box-sizing: border-box;
}
.card {
background: rgba(255, 255, 255, 0.82);
border-radius: 28rpx;
padding: 32rpx;
margin-bottom: 24rpx;
border: 2rpx solid rgba(255, 255, 255, 0.64);
box-shadow: 0 10rpx 30rpx rgba(15, 23, 42, 0.06);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
}
.card-light {
background: rgba(255, 255, 255, 0.62);
}
.text-primary {
color: #1AA37A;
}
.text-secondary {
color: #667085;
}
.text-muted {
color: #98A2B3;
}
.text-center {
text-align: center;
}
.text-bold {
font-weight: 600;
}
.flex {
display: flex;
}
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.flex-col {
display: flex;
flex-direction: column;
}
.flex-1 {
flex: 1;
}
.gap-sm {
gap: 16rpx;
}
.gap-md {
gap: 24rpx;
}
.gap-lg {
gap: 32rpx;
}
.mt-sm {
margin-top: 16rpx;
}
.mt-md {
margin-top: 24rpx;
}
.mt-lg {
margin-top: 32rpx;
}
.mb-sm {
margin-bottom: 16rpx;
}
.mb-md {
margin-bottom: 24rpx;
}
.mb-lg {
margin-bottom: 32rpx;
}
.btn {
display: flex;
align-items: center;
justify-content: center;
height: 96rpx;
border-radius: 999rpx;
font-size: 32rpx;
font-weight: 600;
}
.btn-primary {
background: linear-gradient(180deg, #32c59d 0%, #1aa37a 100%);
color: #FFFFFF;
box-shadow: 0 12rpx 28rpx rgba(26, 163, 122, 0.22);
}
.btn-secondary {
background: rgba(255, 255, 255, 0.82);
color: #111827;
border: 2rpx solid rgba(15, 23, 42, 0.08);
}
.btn-outline {
background-color: transparent;
color: #1AA37A;
border: 2rpx solid rgba(26, 163, 122, 0.32);
}
.glass-card {
background: rgba(255, 255, 255, 0.68);
border: 2rpx solid rgba(255, 255, 255, 0.66);
box-shadow: 0 12rpx 32rpx rgba(15, 23, 42, 0.07);
backdrop-filter: blur(28rpx);
-webkit-backdrop-filter: blur(28rpx);
}
.surface-card {
background: rgba(255, 255, 255, 0.9);
border: 2rpx solid rgba(15, 23, 42, 0.06);
box-shadow: 0 10rpx 30rpx rgba(15, 23, 42, 0.05);
}
.pill-chip {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12rpx 22rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.72);
border: 2rpx solid rgba(255, 255, 255, 0.68);
color: #475467;
font-size: 22rpx;
font-weight: 600;
}
.safe-area-bottom {
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
</style>
+46
View File
@@ -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)
}
+3
View File
@@ -0,0 +1,3 @@
export * from './auth'
export * from './smoke'
export * from './profile'
+9
View File
@@ -0,0 +1,9 @@
import { request } from './request'
export function getProfile() {
return request.get('/smoke/profile')
}
export function updateProfile(data) {
return request.post('/smoke/profile', data)
}
+80
View File
@@ -0,0 +1,80 @@
import { BASE_URL } from '@/config'
import { storage, SESSION_KEY } from '@/utils/storage'
import { login as authLogin } from './auth'
// 是否为 token 失效(HTTP 401 或 body code 401,如 invalid token
function isInvalidToken(res) {
if (res.statusCode === 401) return true
const body = res.data
if (body && body.code === 401) return true
return false
}
export const request = {
async request(options) {
const sessionKey = storage.get(SESSION_KEY)
const isRetryAfter401 = options._retryAfter401 === true
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 (isInvalidToken(res)) {
if (isRetryAfter401) {
reject(new Error(res.data?.message || 'invalid token'))
return
}
try {
await authLogin()
const nextOpts = { ...options, _retryAfter401: true }
resolve(this.request(nextOpts))
} 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' })
}
}
+90
View File
@@ -0,0 +1,90 @@
import { request } from './request'
export function getDashboard(params = {}) {
return request.get('/smoke/dashboard', params)
}
export function getHome(params = {}) {
return request.get('/smoke/home', 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.post(`/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)
}
export function getAINextSmokeTime(params = {}) {
return request.get('/smoke/ai/next_smoke_time', { mode: 'ai', ...params })
}
export function getAIDailySummary(params = {}) {
return request.get('/smoke/ai/daily_summary', params)
}
export function getStats(params = {}) {
return request.get('/smoke/stats', params)
}
export function createShare(data = {}) {
return request.post('/smoke/share', data)
}
export function getShareData(shareToken, params = {}) {
return request.get(`/smoke/share/${shareToken}`, params)
}
export function revokeShare(shareToken) {
return request.post(`/smoke/share/${shareToken}/revoke`)
}
// 戒烟计划 API
export function generateQuitPlan() {
return request.post('/smoke/quit-plan/generate')
}
export function getQuitPlan(params = {}) {
return request.get('/smoke/quit-plan', params)
}
export function getQuitPlanDays(planId) {
return request.get('/smoke/quit-plan/days', { plan_id: planId })
}
export function resetQuitPlan() {
return request.post('/smoke/quit-plan/reset')
}
+134
View File
@@ -0,0 +1,134 @@
# 组件使用说明
## smoke-record-dialog - 抽烟记录弹框组件
这是一个可复用的底部弹框组件,用于记录抽烟或抵抗记录。
### 特性
- ✨ 从底部弹出,半屏展示
- 🎨 明亮主题设计
- 📱 支持日期时间选择
- 🔢 支持数量调整和烟瘾等级选择
- 📝 支持备注输入
- 🔄 全局可复用(已配置 easycom 自动导入)
### 使用方法
#### 方式一:easycom 自动导入(推荐)
无需手动导入,直接在模板中使用即可:
```vue
<template>
<view>
<!-- 触发按钮 -->
<button @tap="openDialog">记录抽烟</button>
<!-- 弹框组件 - 自动导入无需 import -->
<smoke-record-dialog
v-model:show="showDialog"
:type="dialogType"
@submit="handleSubmit"
/>
</view>
</template>
<script setup>
import { ref } from 'vue'
import * as api from '@/api'
const showDialog = ref(false)
const dialogType = ref('smoke') // 'smoke' 或 'resisted'
function openDialog() {
dialogType.value = 'smoke' // 或 'resisted'
showDialog.value = true
}
async function handleSubmit(data) {
try {
await api.createLog(data)
uni.showToast({ title: '记录成功', icon: 'success' })
// 更新数据...
} catch (e) {
console.error('提交失败:', e)
}
}
</script>
```
#### 方式二:手动导入(可选)
```vue
<script setup>
import smokeRecordDialog from '@/components/smoke-record-dialog/smoke-record-dialog.vue'
// 其他代码...
</script>
```
### Props
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| show | Boolean | false | 控制弹框显示/隐藏 |
| type | String | 'smoke' | 记录类型:'smoke'(抽烟) 或 'resisted'(忍住) |
### Events
| 事件名 | 参数 | 说明 |
|--------|------|------|
| update:show | Boolean | 弹框显示状态变化时触发 |
| submit | Object | 提交表单时触发,返回表单数据 |
### 提交数据格式
```javascript
{
smoke_time: "2025-12-31", // 日期
smoke_at: "2025-12-31 08:30:00", // 完整时间
remark: "压力大", // 备注
level: 2, // 烟瘾等级(1-5
num: 3 // 数量(忍住时为0
}
```
### 示例场景
#### 场景1:记录抽烟
```vue
<button @tap="recordSmoke">记录抽烟</button>
<smoke-record-dialog
v-model:show="showDialog"
type="smoke"
@submit="handleSmokeSubmit"
/>
```
#### 场景2:记录忍住
```vue
<button @tap="recordResisted">想抽忍住了</button>
<smoke-record-dialog
v-model:show="showDialog"
type="resisted"
@submit="handleResistedSubmit"
/>
```
### 样式定制
组件内部样式已设置为 `scoped`,如需自定义样式,可以通过以下方式:
1. 修改组件内部样式文件
2. 使用深度选择器覆盖样式(不推荐)
### 注意事项
1. 组件使用 v-model:show 双向绑定显示状态
2. type 为 'resisted' 时,num 自动设置为 0
3. 表单数据会在打开弹框时自动初始化为当前时间
4. 提交后弹框会自动关闭
@@ -0,0 +1,181 @@
# smoke-record-dialog 组件
## 📦 组件说明
抽烟记录弹框组件,用于记录抽烟或抵抗记录。从底部弹出,半屏展示。
## 🎯 组件特性
- ✅ 符合 uni-app/微信小程序规范的组件结构
- ✅ 使用 Options API(兼容性更好)
- ✅ 从底部弹出动画效果
- ✅ 半屏展示,优化用户体验
- ✅ 支持两种模式:抽烟记录 / 忍住记录
- ✅ 完整的表单功能
- ✅ 已配置 easycom 自动导入
## 📁 文件结构
```
components/
└── smoke-record-dialog/
├── smoke-record-dialog.vue # 组件主文件
└── README.md # 组件文档
```
## 🚀 快速使用
组件已配置 easycom 自动导入,无需手动 import:
```vue
<template>
<view>
<button @tap="showDialog = true">记录抽烟</button>
<smoke-record-dialog
v-model:show="showDialog"
type="smoke"
@submit="handleSubmit"
/>
</view>
</template>
<script setup>
import { ref } from 'vue'
const showDialog = ref(false)
function handleSubmit(data) {
console.log('提交数据:', data)
// 处理提交逻辑...
}
</script>
```
## 📝 Props
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| show | Boolean | false | 控制弹框显示/隐藏(支持 v-model) |
| type | String | 'smoke' | 记录类型:'smoke'(抽烟) 或 'resisted'(忍住) |
## 🎪 Events
| 事件名 | 参数 | 说明 |
|--------|------|------|
| update:show | Boolean | 弹框状态改变时触发(v-model 绑定) |
| submit | Object | 提交表单时触发 |
## 📤 提交数据格式
```javascript
{
smoke_time: "2025-01-25", // 日期
smoke_at: "2025-01-25 14:30:00", // 完整时间
remark: "压力大", // 备注(可选)
level: 2, // 烟瘾等级 1-5
num: 3 // 数量(忍住时为0
}
```
## 💡 使用示例
### 示例 1:记录抽烟
```vue
<template>
<view>
<button @tap="openSmokeDialog">记录抽烟</button>
<smoke-record-dialog
v-model:show="showDialog"
type="smoke"
@submit="onSmokeSubmit"
/>
</view>
</template>
<script setup>
import { ref } from 'vue'
import * as api from '@/api'
const showDialog = ref(false)
function openSmokeDialog() {
showDialog.value = true
}
async function onSmokeSubmit(data) {
await api.createLog(data)
uni.showToast({ title: '记录成功', icon: 'success' })
}
</script>
```
### 示例 2:记录忍住
```vue
<template>
<view>
<button @tap="openResistedDialog">想抽忍住了</button>
<smoke-record-dialog
v-model:show="showDialog"
type="resisted"
@submit="onResistedSubmit"
/>
</view>
</template>
<script setup>
import { ref } from 'vue'
import * as api from '@/api'
const showDialog = ref(false)
function openResistedDialog() {
showDialog.value = true
}
async function onResistedSubmit(data) {
await api.createLog(data)
uni.showToast({ title: '太棒了!', icon: 'success' })
}
</script>
```
## 🎨 样式说明
- 弹框背景:半透明黑色遮罩
- 容器样式:纯白背景,顶部圆角
- 主题色:#10B981(翡翠绿)
- 动画效果:0.3s 缓入缓出过渡
## ⚙️ 配置说明
组件已在 `pages.json` 中配置 easycom
```json
{
"easycom": {
"autoscan": true,
"custom": {
"^smoke-record-dialog$": "@/components/smoke-record-dialog/smoke-record-dialog.vue"
}
}
}
```
## 🔧 技术栈
- Vue 2 Options API
- uni-app 组件规范
- 微信小程序兼容
## ⚠️ 注意事项
1. 组件使用 Options API 而非 Composition API,以确保更好的兼容性
2. 组件名使用小写加连字符(kebab-case),符合 uni-app 规范
3. 已配置 easycom,无需手动导入
4. 提交后弹框会自动关闭
5. 表单数据会在打开时自动初始化为当前时间
@@ -0,0 +1,579 @@
<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-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 section-card-counter" 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="section-card">
<view class="level-header">
<text class="section-title">{{ type === 'smoke' ? '烟瘾程度' : '忍住强度' }}</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-primary" @tap="submit">
<view class="btn-icon"></view>
<text class="btn-text">保存记录</text>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'SmokeRecordDialog',
props: {
show: {
type: Boolean,
default: false
},
type: {
type: String,
default: 'smoke' // 'smoke' 或 'resisted'
},
initialData: {
type: Object,
default: null
}
},
data() {
return {
showAnimation: false,
formData: {
smoke_time: '',
smoke_time_only: '',
smoke_at: '',
remark: '',
level: 2,
num: 1
}
}
},
computed: {
title() {
return '添加记录'
}
},
watch: {
show(newVal) {
if (newVal) {
this.initFormData()
setTimeout(() => {
this.showAnimation = true
}, 50)
} else {
this.showAnimation = false
}
}
},
methods: {
initFormData() {
// 如果有初始数据(编辑模式),使用初始数据
if (this.initialData) {
this.formData = {
smoke_time: this.initialData.smoke_time || '',
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
}
} else {
// 新建模式,使用当前本地时间(不用 toISOString,避免 UTC 导致日期差一天)
const now = new Date()
const y = now.getFullYear()
const m = String(now.getMonth() + 1).padStart(2, '0')
const d = String(now.getDate()).padStart(2, '0')
const dateStr = `${y}-${m}-${d}`
const timeStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
const datetimeStr = `${dateStr} ${timeStr}:00`
this.formData = {
smoke_time: dateStr,
smoke_time_only: timeStr,
smoke_at: datetimeStr,
remark: '',
level: 2,
num: this.type === 'smoke' ? 1 : 0
}
}
},
handleMaskClick() {
this.close()
},
close() {
this.showAnimation = false
setTimeout(() => {
this.$emit('update:show', false)
}, 300)
},
onDateChange(e) {
this.formData.smoke_time = e.detail.value
this.updateSmokeAt()
},
onTimeChange(e) {
this.formData.smoke_time_only = e.detail.value
this.updateSmokeAt()
},
updateSmokeAt() {
this.formData.smoke_at = `${this.formData.smoke_time} ${this.formData.smoke_time_only}:00`
},
decreaseNum() {
if (this.formData.num > 1) {
this.formData.num--
}
},
increaseNum() {
this.formData.num++
},
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.formData.level,
num: this.type === 'smoke' ? this.formData.num : 0
}
this.$emit('submit', submitData)
this.close()
}
}
}
</script>
<style scoped>
.dialog-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(15, 23, 42, 0.22);
z-index: 9999;
display: flex;
align-items: flex-end;
}
.dialog-container {
width: 100%;
max-height: 85vh;
background: rgba(248, 250, 252, 0.9);
border-radius: 36rpx 36rpx 0 0;
overflow: hidden;
transform: translateY(100%);
transition: transform 0.3s ease-out;
padding-bottom: 16rpx;
border-top: 2rpx solid rgba(255, 255, 255, 0.72);
backdrop-filter: blur(28rpx);
-webkit-backdrop-filter: blur(28rpx);
}
.dialog-show {
transform: translateY(0);
}
.dialog-handle {
width: 80rpx;
height: 8rpx;
background-color: rgba(152, 162, 179, 0.45);
border-radius: 999rpx;
margin: 16rpx auto 0;
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 32rpx 16rpx;
}
.dialog-title {
font-size: 40rpx;
font-weight: 700;
color: #111827;
}
.dialog-close {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 44rpx;
color: #98A2B3;
line-height: 1;
background-color: rgba(255, 255, 255, 0.78);
border-radius: 50%;
}
.dialog-body {
padding: 16rpx 32rpx 24rpx;
max-height: 62vh;
overflow-y: auto;
}
.form-row {
display: flex;
gap: 20rpx;
margin-bottom: 24rpx;
}
.picker-card {
flex: 1;
}
.input-card {
background-color: rgba(255, 255, 255, 0.78);
border-radius: 24rpx;
padding: 20rpx 24rpx;
border: 2rpx solid rgba(255, 255, 255, 0.72);
min-height: 120rpx;
display: flex;
flex-direction: column;
justify-content: center;
}
.input-label {
font-size: 22rpx;
color: #9CA3AF;
margin-bottom: 12rpx;
font-weight: 500;
}
.input-value-row {
display: flex;
align-items: center;
gap: 12rpx;
}
.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: rgba(255, 255, 255, 0.84);
border-radius: 24rpx;
padding: 24rpx;
border: 2rpx solid rgba(255, 255, 255, 0.72);
box-shadow: 0 10rpx 24rpx rgba(15, 23, 42, 0.05);
margin-bottom: 24rpx;
}
.section-card-counter {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
flex-direction: row;
flex-wrap: nowrap;
}
.section-left {
display: flex;
align-items: center;
gap: 16rpx;
flex: 1;
min-width: 0;
}
.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;
white-space: nowrap;
}
.counter {
display: inline-flex;
align-items: center;
gap: 20rpx;
background-color: rgba(247, 249, 252, 0.92);
padding: 12rpx 16rpx;
border-radius: 999rpx;
border: 2rpx solid #F1F5F9;
margin-top: 0;
justify-content: center;
flex-shrink: 0;
}
.counter-btn {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
color: #1aa37a;
font-weight: 600;
}
.counter-value {
min-width: 40rpx;
text-align: center;
font-size: 32rpx;
font-weight: 700;
color: #111827;
}
.level-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.level-badge {
padding: 8rpx 18rpx;
border-radius: 999rpx;
background-color: rgba(52, 200, 160, 0.14);
color: #1aa37a;
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: rgba(255, 255, 255, 0.78);
border-radius: 20rpx;
padding: 8rpx;
border: 2rpx solid rgba(255, 255, 255, 0.72);
}
.form-textarea {
width: 100%;
min-height: 180rpx;
background-color: rgba(255, 255, 255, 0);
border-radius: 16rpx;
padding: 24rpx 20rpx;
font-size: 28rpx;
color: #111827;
border: 2rpx solid transparent;
box-sizing: border-box;
}
.dialog-footer {
padding: 16rpx 32rpx 32rpx;
}
.dialog-btn-primary {
height: 96rpx;
border-radius: 32rpx;
background: linear-gradient(180deg, #32c59d 0%, #1aa37a 100%);
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
box-shadow: 0 12rpx 28rpx rgba(26, 163, 122, 0.22);
}
.btn-icon {
width: 44rpx;
height: 44rpx;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.24);
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: 600;
color: #FFFFFF;
}
</style>
+13
View File
@@ -0,0 +1,13 @@
const ENV = {
development: {
BASE_URL: 'https://wx.nepiedg.top/api/v1',
MINI_PROGRAM_ID: 2
},
production: {
BASE_URL: 'https://wx.nepiedg.top/api/v1',
MINI_PROGRAM_ID: 2
}
}
const env = process.env.NODE_ENV || 'development'
export const { BASE_URL, MINI_PROGRAM_ID } = ENV[env]
+56
View File
@@ -0,0 +1,56 @@
import { ref } from 'vue'
import { login, isLoggedIn } from '@/api/auth'
const loginReady = ref(false)
let loginPromise = null
export function useLogin() {
async function waitForLogin() {
if (loginReady.value) {
return true
}
if (loginPromise) {
return loginPromise
}
const app = getApp()
if (app && app.globalData && app.globalData.loginPromise) {
loginPromise = app.globalData.loginPromise
const result = await loginPromise
loginReady.value = true
return result
}
loginPromise = doLogin()
return loginPromise
}
async function doLogin() {
try {
if (!isLoggedIn()) {
await login()
}
loginReady.value = true
return true
} catch (e) {
console.error('登录失败:', e)
loginReady.value = true
return false
}
}
async function ensureLogin() {
if (isLoggedIn()) {
loginReady.value = true
return true
}
return waitForLogin()
}
return {
loginReady,
waitForLogin,
ensureLogin
}
}
+24
View File
@@ -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
+72
View File
@@ -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" : [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
]
},
/* ios */
"ios" : {},
/* SDK */
"sdkConfigs" : {}
}
},
/* */
"quickapp" : {},
/* */
"mp-weixin" : {
"appid" : "wx74d28a21dcadd983",
"setting" : {
"urlCheck" : false
},
"usingComponents" : true
},
"mp-alipay" : {
"usingComponents" : true
},
"mp-baidu" : {
"usingComponents" : true
},
"mp-toutiao" : {
"usingComponents" : true
},
"uniStatistics" : {
"enable" : false
},
"vueVersion" : "3"
}
+111
View File
@@ -0,0 +1,111 @@
{
"easycom": {
"autoscan": true,
"custom": {
"^smoke-record-dialog$": "@/components/smoke-record-dialog/smoke-record-dialog.vue"
}
},
"pages": [
{
"path": "pages/mode-select/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/index/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/stats/index",
"style": {
"navigationBarTitleText": "数据统计分析"
}
},
{
"path": "pages/ai/index",
"style": {
"navigationBarTitleText": "AI 建议"
}
},
{
"path": "pages/ai_summary/index",
"style": {
"navigationBarTitleText": "AI 总结"
}
},
{
"path": "pages/logs/index",
"style": {
"navigationBarTitleText": "历史记录"
}
},
{
"path": "pages/share/index",
"style": {
"navigationBarTitleText": "戒烟分享"
}
},
{
"path": "pages/profile/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/quit-plan/index",
"style": {
"navigationBarTitleText": "戒烟计划"
}
},
{
"path": "pages/onboarding/index",
"style": {
"navigationStyle": "custom"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "戒烟助手",
"navigationBarBackgroundColor": "#F5F7FB",
"backgroundColor": "#F5F7FB",
"backgroundColorTop": "#EEF3F8",
"backgroundColorBottom": "#FBFDFF"
},
"tabBar": {
"color": "#98A2B3",
"selectedColor": "#1AA37A",
"backgroundColor": "#F9FBFD",
"borderStyle": "white",
"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/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": {}
}
+455
View File
@@ -0,0 +1,455 @@
<template>
<view class="page">
<view class="page-glow page-glow-a"></view>
<view class="page-glow page-glow-b"></view>
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="container">
<view class="hero-card">
<text class="hero-label">AI 建议</text>
<text class="hero-title">今日控烟节奏</text>
<text class="hero-desc">基于最近 3 天记录作息和默认控烟节奏生成建议</text>
</view>
<view class="section-card">
<view class="section-header">
<view>
<text class="section-title">下次建议</text>
<text class="section-subtitle">首页会同步显示这里的最新结果</text>
</view>
<view class="primary-btn" @tap="handleAISuggest">
<text class="primary-btn-text">{{ aiLoading ? '生成中...' : actionText }}</text>
</view>
</view>
<view v-if="suggestedClock" class="suggested-pill">
<text class="suggested-pill-label">下次建议</text>
<text class="suggested-pill-value">{{ suggestedClock }}</text>
</view>
<view v-if="aiTimeNodes.length" class="timeline">
<view
v-for="(node, idx) in aiTimeNodesWithStatus"
:key="idx"
class="timeline-node"
:class="{
'timeline-node-past': node.status === 'past',
'timeline-node-current': node.status === 'current',
'timeline-node-future': node.status === 'future'
}"
>
<view class="timeline-dot"></view>
<text class="timeline-time">{{ node.time }}</text>
<view class="timeline-line" v-if="idx < aiTimeNodesWithStatus.length - 1"></view>
</view>
</view>
<view v-if="aiAdvice" class="advice-card">
<text class="advice-title">AI 文案</text>
<text class="advice-text">{{ aiAdvice }}</text>
</view>
<view v-if="!aiTimeNodes.length && !aiAdvice && !aiLoading" class="empty-card">
<text class="empty-title">还没有生成今日 AI 建议</text>
<text class="empty-desc">点击右上角生成系统会结合近 3 天记录给出建议时间点</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import * as api from '@/api'
import { useLogin } from '@/hooks/useLogin'
const { waitForLogin } = useLogin()
const rewardAdUnitId = 'adunit-36e13d77e185f757'
const statusBarHeight = ref(0)
const homeData = ref(null)
const aiLoading = ref(false)
const homeTimer = computed(() => homeData.value?.timer || {})
const aiTimeNodes = computed(() => homeTimer.value?.ai_time_nodes || [])
const aiAdvice = computed(() => homeTimer.value?.ai_advice || '')
const actionText = computed(() => (aiTimeNodes.value.length > 0 ? '刷新' : '生成'))
const suggestedClock = computed(() => {
const timer = homeTimer.value
if (timer.next_suggested_clock) return timer.next_suggested_clock
if (timer.next_suggested_at) {
const date = new Date(timer.next_suggested_at)
if (!isNaN(date.getTime())) {
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
}
return ''
})
const aiTimeNodesWithStatus = computed(() => {
const now = new Date()
const nowMinutes = now.getHours() * 60 + now.getMinutes()
let foundCurrent = false
return aiTimeNodes.value.map((time) => {
const [h, m] = time.split(':').map(Number)
const nodeMinutes = h * 60 + m
if (foundCurrent) return { time, status: 'future' }
if (nodeMinutes > nowMinutes) {
foundCurrent = true
return { time, status: 'current' }
}
return { time, status: 'past' }
})
})
function formatLocalDate(date = new Date()) {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
async function fetchHomeData() {
const res = await api.getHome()
homeData.value = res.data || {}
}
async function runRewardedUnlock(onUnlocked) {
// #ifdef MP-WEIXIN
try {
const videoAd = wx.createRewardedVideoAd({
adUnitId: rewardAdUnitId
})
videoAd.onClose(async (res) => {
if (res && res.isEnded) {
await onUnlocked()
} else {
uni.showToast({ title: '需要看完广告哦', icon: 'none' })
}
})
videoAd.onError(async () => {
await onUnlocked()
})
await videoAd.show().catch(async () => {
await videoAd.load()
await videoAd.show()
})
return
} catch (e) {
await onUnlocked()
return
}
// #endif
// #ifndef MP-WEIXIN
await onUnlocked()
// #endif
}
async function unlockToday() {
try {
await api.unlockAiAdvice({ date: formatLocalDate() })
} catch (e) {
console.error('unlockToday error:', e)
}
}
async function fetchAISuggestion() {
aiLoading.value = true
try {
const res = await api.getAINextSmokeTime()
const data = res.data || {}
if (!homeData.value) {
homeData.value = {}
}
if (!homeData.value.timer) {
homeData.value.timer = {}
}
homeData.value.timer.suggestion_source = 'ai'
homeData.value.timer.ai_time_nodes = data.time_nodes || []
homeData.value.timer.ai_advice = data.advice || ''
homeData.value.timer.next_suggested_at = data.suggested_at || ''
if (data.suggested_at) {
const t = new Date(data.suggested_at)
if (!isNaN(t.getTime())) {
homeData.value.timer.next_suggested_clock = `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}`
}
}
uni.showToast({ title: 'AI 计划已生成', icon: 'success' })
} catch (e) {
console.error('fetchAISuggestion error:', e)
const msg = e?.data?.message || '生成失败,请稍后重试'
uni.showToast({ title: msg, icon: 'none' })
} finally {
aiLoading.value = false
}
}
async function handleAISuggest() {
if (aiLoading.value) return
if (aiTimeNodes.value.length > 0) {
await fetchAISuggestion()
return
}
await runRewardedUnlock(async () => {
await unlockToday()
await fetchAISuggestion()
})
}
onShow(async () => {
try {
const systemInfo = uni.getSystemInfoSync()
statusBarHeight.value = systemInfo.statusBarHeight || 0
await waitForLogin()
await fetchHomeData()
} catch (e) {
console.error('ai suggest onShow error:', e)
}
})
</script>
<style scoped>
.page {
min-height: 100vh;
position: relative;
background:
radial-gradient(circle at top left, rgba(52, 200, 160, 0.16), transparent 30%),
radial-gradient(circle at top right, rgba(255, 255, 255, 0.92), transparent 24%),
linear-gradient(180deg, #edf2f8 0%, #f5f7fb 38%, #fbfdff 100%);
overflow: hidden;
}
.status-bar {
background: transparent;
}
.page-glow {
position: absolute;
border-radius: 50%;
filter: blur(24rpx);
opacity: 0.72;
pointer-events: none;
}
.page-glow-a {
top: 80rpx;
left: -140rpx;
width: 360rpx;
height: 360rpx;
background: rgba(52, 200, 160, 0.14);
}
.page-glow-b {
top: 320rpx;
right: -120rpx;
width: 320rpx;
height: 320rpx;
background: rgba(255, 255, 255, 0.9);
}
.container {
padding: 24rpx 32rpx 80rpx;
position: relative;
z-index: 1;
}
.hero-card,
.section-card,
.advice-card,
.empty-card {
background: rgba(255, 255, 255, 0.8);
border-radius: 32rpx;
border: 2rpx solid rgba(255, 255, 255, 0.66);
box-shadow: 0 16rpx 36rpx rgba(15, 23, 42, 0.06);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
}
.hero-card {
padding: 32rpx;
margin-bottom: 24rpx;
}
.hero-label {
font-size: 22rpx;
color: #1a7f61;
display: block;
margin-bottom: 12rpx;
}
.hero-title {
font-size: 42rpx;
font-weight: 700;
color: #111827;
display: block;
margin-bottom: 12rpx;
}
.hero-desc {
font-size: 24rpx;
color: #667085;
line-height: 1.6;
}
.section-card {
padding: 28rpx;
}
.section-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24rpx;
margin-bottom: 24rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 700;
color: #111827;
display: block;
}
.section-subtitle {
font-size: 22rpx;
color: #667085;
display: block;
margin-top: 8rpx;
}
.primary-btn {
padding: 10rpx 24rpx;
border-radius: 999rpx;
background: linear-gradient(180deg, #32c59d 0%, #1aa37a 100%);
box-shadow: 0 12rpx 28rpx rgba(26, 163, 122, 0.22);
}
.primary-btn-text {
font-size: 22rpx;
font-weight: 600;
color: #FFFFFF;
}
.suggested-pill {
display: inline-flex;
align-items: center;
gap: 12rpx;
padding: 14rpx 22rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.88);
border: 2rpx solid rgba(255, 255, 255, 0.72);
margin-bottom: 24rpx;
}
.suggested-pill-label {
font-size: 22rpx;
color: #1a7f61;
}
.suggested-pill-value {
font-size: 26rpx;
font-weight: 700;
color: #111827;
}
.timeline {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8rpx;
margin-bottom: 24rpx;
}
.timeline-node {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
flex: 1;
}
.timeline-dot {
width: 20rpx;
height: 20rpx;
border-radius: 50%;
background-color: rgba(152, 162, 179, 0.4);
margin-bottom: 8rpx;
}
.timeline-line {
position: absolute;
top: 10rpx;
left: calc(50% + 18rpx);
right: -50%;
height: 2rpx;
background-color: rgba(152, 162, 179, 0.32);
}
.timeline-time {
font-size: 22rpx;
color: #98A2B3;
}
.timeline-node-past .timeline-dot {
background-color: #9CA3AF;
}
.timeline-node-past .timeline-time {
text-decoration: line-through;
}
.timeline-node-current .timeline-dot {
background-color: #1aa37a;
width: 24rpx;
height: 24rpx;
box-shadow: 0 0 12rpx rgba(16, 185, 129, 0.5);
}
.timeline-node-current .timeline-time {
color: #1a7f61;
font-weight: 600;
}
.timeline-node-future .timeline-time {
color: #667085;
}
.advice-card {
padding: 22rpx;
background-color: rgba(247, 249, 252, 0.92);
}
.advice-title {
font-size: 24rpx;
font-weight: 600;
color: #1a7f61;
display: block;
margin-bottom: 8rpx;
}
.advice-text {
font-size: 24rpx;
color: #344054;
line-height: 1.7;
}
.empty-card {
padding: 36rpx 28rpx;
}
.empty-title {
font-size: 28rpx;
font-weight: 600;
color: #111827;
display: block;
margin-bottom: 12rpx;
}
.empty-desc {
font-size: 24rpx;
color: #667085;
line-height: 1.6;
}
</style>
+441
View File
@@ -0,0 +1,441 @@
<template>
<view class="page">
<view class="page-glow page-glow-a"></view>
<view class="page-glow page-glow-b"></view>
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="container">
<view class="hero-card">
<text class="hero-label">AI 总结</text>
<text class="hero-title">按日期生成复盘</text>
<text class="hero-desc">选择任意历史日期生成当日抽烟总结关键发现和次日建议</text>
</view>
<view class="toolbar-card">
<picker mode="date" :value="selectedDate" :end="today" @change="handleDateChange">
<view class="date-picker">
<text class="date-picker-label">总结日期</text>
<text class="date-picker-value">{{ selectedDate }}</text>
</view>
</picker>
<view class="primary-btn" @tap="handleGenerateSummary">
<text class="primary-btn-text">{{ summaryLoading ? '生成中...' : actionText }}</text>
</view>
</view>
<view v-if="summaryLoading" class="summary-card loading-card">
<text class="loading-text">AI 正在分析 {{ selectedDate }} 的数据...</text>
</view>
<view v-else-if="summaryData" class="summary-card">
<text class="summary-date">{{ summaryData.date }}</text>
<text class="summary-text">{{ parsedSummary.summary }}</text>
<view class="highlights-card" v-if="parsedSummary.highlights && parsedSummary.highlights.length">
<text class="block-title">关键发现</text>
<view class="highlight-item" v-for="(item, idx) in parsedSummary.highlights" :key="idx">
<text class="highlight-dot">·</text>
<text class="highlight-text">{{ item }}</text>
</view>
</view>
<view class="suggestion-card" v-if="parsedSummary.suggestion">
<text class="block-title">明日建议</text>
<text class="suggestion-text">{{ parsedSummary.suggestion }}</text>
</view>
</view>
<view v-else class="summary-card empty-card">
<text class="empty-title">{{ emptyTitle }}</text>
<text class="empty-desc">{{ emptyDesc }}</text>
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import * as api from '@/api'
import { useLogin } from '@/hooks/useLogin'
const { waitForLogin } = useLogin()
const rewardAdUnitId = 'adunit-36e13d77e185f757'
const statusBarHeight = ref(0)
const homeData = ref(null)
const selectedDate = ref(formatLocalDate())
const summaryLoading = ref(false)
const summaryData = ref(null)
const summaryState = ref('empty')
const today = computed(() => formatLocalDate())
const actionText = computed(() => (summaryData.value ? '刷新' : '生成'))
const parsedSummary = computed(() => parseSummaryContent(summaryData.value?.content))
const emptyTitle = computed(() => {
if (summaryState.value === 'locked') return '当前日期尚未解锁'
if (summaryState.value === 'no_data') return '当天还没有可总结的记录'
if (summaryState.value === 'unavailable') return 'AI 服务暂时不可用'
return '还没有生成总结'
})
const emptyDesc = computed(() => {
if (summaryState.value === 'locked') return '完成激励广告后可生成该日期的 AI 总结。'
if (summaryState.value === 'no_data') return '先确认当天是否有抽烟记录,再重新生成。'
if (summaryState.value === 'unavailable') return '稍后重试,或查看后端日志里的提示词和输入数据。'
return '选择日期后点击生成,系统会结合当天记录做总结。'
})
function formatLocalDate(date = new Date()) {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
function extractJSONObject(text = '') {
const start = text.indexOf('{')
const end = text.lastIndexOf('}')
if (start === -1 || end === -1 || end <= start) return ''
return text.slice(start, end + 1)
}
function parseSummaryContent(content = '') {
if (!content) return {}
const jsonText = extractJSONObject(content)
if (jsonText) {
try {
return JSON.parse(jsonText)
} catch (e) {
console.error('parseSummaryContent json error:', e)
}
}
return {
summary: content,
highlights: [],
suggestion: ''
}
}
async function fetchHomeData() {
const res = await api.getHome()
homeData.value = res.data || {}
if (selectedDate.value === today.value && homeData.value?.daily_summary?.status === 'available') {
summaryData.value = homeData.value.daily_summary
summaryState.value = 'available'
}
}
function handleDateChange(event) {
selectedDate.value = event.detail.value
if (selectedDate.value === today.value && homeData.value?.daily_summary?.status === 'available') {
summaryData.value = homeData.value.daily_summary
summaryState.value = 'available'
return
}
summaryData.value = null
summaryState.value = 'empty'
}
async function runRewardedUnlock(onUnlocked) {
// #ifdef MP-WEIXIN
try {
const videoAd = wx.createRewardedVideoAd({
adUnitId: rewardAdUnitId
})
videoAd.onClose(async (res) => {
if (res && res.isEnded) {
await onUnlocked()
} else {
uni.showToast({ title: '需要看完广告哦', icon: 'none' })
}
})
videoAd.onError(async () => {
await onUnlocked()
})
await videoAd.show().catch(async () => {
await videoAd.load()
await videoAd.show()
})
return
} catch (e) {
await onUnlocked()
return
}
// #endif
// #ifndef MP-WEIXIN
await onUnlocked()
// #endif
}
async function unlockSummaryDate() {
try {
await api.unlockAiAdvice({ date: selectedDate.value })
} catch (e) {
console.error('unlockSummaryDate error:', e)
}
}
async function fetchSummaryByDate() {
summaryLoading.value = true
try {
const res = await api.getAIDailySummary({ date: selectedDate.value })
const data = res.data || {}
summaryData.value = {
date: data.date || selectedDate.value,
content: data.content || '',
model: data.model || '',
status: 'available'
}
summaryState.value = 'available'
if (selectedDate.value === today.value && homeData.value) {
homeData.value.daily_summary = summaryData.value
}
uni.showToast({ title: '总结已生成', icon: 'success' })
} catch (e) {
console.error('fetchSummaryByDate error:', e)
const msg = e?.data?.message || '生成失败,请稍后重试'
if (msg.includes('解锁')) {
summaryState.value = 'locked'
} else if (msg.includes('没有抽烟记录')) {
summaryState.value = 'no_data'
} else {
summaryState.value = 'unavailable'
}
summaryData.value = null
uni.showToast({ title: msg, icon: 'none' })
} finally {
summaryLoading.value = false
}
}
async function handleGenerateSummary() {
if (summaryLoading.value) return
if (summaryData.value) {
await fetchSummaryByDate()
return
}
await runRewardedUnlock(async () => {
await unlockSummaryDate()
await fetchSummaryByDate()
})
}
onShow(async () => {
try {
const systemInfo = uni.getSystemInfoSync()
statusBarHeight.value = systemInfo.statusBarHeight || 0
await waitForLogin()
await fetchHomeData()
} catch (e) {
console.error('ai summary onShow error:', e)
}
})
</script>
<style scoped>
.page {
min-height: 100vh;
position: relative;
background:
radial-gradient(circle at top left, rgba(52, 200, 160, 0.16), transparent 30%),
radial-gradient(circle at top right, rgba(255, 255, 255, 0.92), transparent 24%),
linear-gradient(180deg, #edf2f8 0%, #f5f7fb 38%, #fbfdff 100%);
overflow: hidden;
}
.status-bar {
background: transparent;
}
.page-glow {
position: absolute;
border-radius: 50%;
filter: blur(24rpx);
opacity: 0.72;
pointer-events: none;
}
.page-glow-a {
top: 80rpx;
left: -140rpx;
width: 360rpx;
height: 360rpx;
background: rgba(52, 200, 160, 0.14);
}
.page-glow-b {
top: 320rpx;
right: -120rpx;
width: 320rpx;
height: 320rpx;
background: rgba(255, 255, 255, 0.9);
}
.container {
padding: 24rpx 32rpx 80rpx;
position: relative;
z-index: 1;
}
.hero-card,
.toolbar-card,
.summary-card,
.highlights-card,
.suggestion-card {
background: rgba(255, 255, 255, 0.8);
border-radius: 32rpx;
border: 2rpx solid rgba(255, 255, 255, 0.66);
box-shadow: 0 16rpx 36rpx rgba(15, 23, 42, 0.06);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
}
.hero-card {
padding: 32rpx;
margin-bottom: 24rpx;
}
.hero-label {
font-size: 22rpx;
color: #1a7f61;
display: block;
margin-bottom: 12rpx;
}
.hero-title {
font-size: 42rpx;
font-weight: 700;
color: #111827;
display: block;
margin-bottom: 12rpx;
}
.hero-desc {
font-size: 24rpx;
color: #667085;
line-height: 1.6;
}
.toolbar-card {
padding: 24rpx;
display: flex;
align-items: center;
gap: 20rpx;
margin-bottom: 24rpx;
}
.date-picker {
flex: 1;
padding: 18rpx 22rpx;
border-radius: 20rpx;
background: rgba(247, 249, 252, 0.92);
border: 2rpx solid rgba(15, 23, 42, 0.06);
}
.date-picker-label {
font-size: 22rpx;
color: #667085;
display: block;
margin-bottom: 6rpx;
}
.date-picker-value {
font-size: 28rpx;
font-weight: 700;
color: #111827;
}
.primary-btn {
padding: 14rpx 24rpx;
border-radius: 999rpx;
background: linear-gradient(180deg, #32c59d 0%, #1aa37a 100%);
box-shadow: 0 12rpx 28rpx rgba(26, 163, 122, 0.22);
}
.primary-btn-text {
font-size: 22rpx;
font-weight: 600;
color: #FFFFFF;
}
.summary-card {
padding: 28rpx;
}
.loading-card {
display: flex;
justify-content: center;
}
.loading-text {
font-size: 24rpx;
color: #667085;
}
.summary-date {
font-size: 22rpx;
color: #1a7f61;
display: block;
margin-bottom: 12rpx;
}
.summary-text {
font-size: 28rpx;
color: #111827;
line-height: 1.7;
display: block;
}
.highlights-card,
.suggestion-card {
margin-top: 20rpx;
padding: 22rpx;
background-color: rgba(247, 249, 252, 0.92);
}
.block-title {
font-size: 24rpx;
font-weight: 700;
color: #1a7f61;
display: block;
margin-bottom: 10rpx;
}
.highlight-item {
display: flex;
align-items: flex-start;
gap: 8rpx;
margin-bottom: 8rpx;
}
.highlight-item:last-child {
margin-bottom: 0;
}
.highlight-dot {
font-size: 28rpx;
color: #1aa37a;
font-weight: 700;
line-height: 1.5;
}
.highlight-text,
.suggestion-text,
.empty-desc {
font-size: 24rpx;
color: #344054;
line-height: 1.6;
}
.empty-card {
text-align: center;
}
.empty-title {
font-size: 28rpx;
font-weight: 700;
color: #111827;
display: block;
margin-bottom: 12rpx;
}
</style>
+870
View File
@@ -0,0 +1,870 @@
<template>
<view class="page">
<view class="page-glow page-glow-a"></view>
<view class="page-glow page-glow-b"></view>
<view class="nav-placeholder" :style="{ height: navBarHeight + 'px' }"></view>
<view v-if="loading" class="skeleton">
<view class="skeleton-card skeleton-card-lg"></view>
<view class="skeleton-card skeleton-card-md"></view>
<view class="skeleton-grid">
<view class="skeleton-card"></view>
<view class="skeleton-card"></view>
</view>
<view class="skeleton-card skeleton-card-sm"></view>
</view>
<view v-else class="dashboard">
<view class="header-card">
<view class="header-copy">
<text class="header-eyebrow">SMT</text>
<text class="greeting-text">{{ greetingTitle }}</text>
<text class="greeting-sub">{{ greetingSubtitle }}</text>
</view>
<view class="header-side">
<image class="avatar" :src="userAvatar" mode="aspectFill"></image>
<view class="mode-chip" :class="isQuitMode ? 'mode-chip-quit' : 'mode-chip-record'">
{{ isQuitMode ? '戒烟模式' : '记录模式' }}
</view>
</view>
</view>
<view v-if="isQuitMode">
<view class="hero-card hero-card-quit">
<view class="hero-meta-row">
<text class="hero-label">已坚持</text>
<text class="hero-inline-chip">{{ todayChecked ? '今日已打卡' : '今日待打卡' }}</text>
</view>
<view class="hero-value-row">
<text class="hero-value">{{ quitDays }}</text>
<text class="hero-unit"></text>
</view>
<text class="hero-sub">{{ quitSubtitle }}</text>
</view>
<view class="primary-action" :class="{ 'primary-action-done': todayChecked }" @tap="handleQuitCheckin">
<view class="primary-action-icon">{{ todayChecked ? '✓' : '打' }}</view>
<text class="primary-action-title">{{ todayChecked ? '今日已打卡' : '今天没抽,去打卡' }}</text>
<text class="primary-action-desc">{{ todayChecked ? `已于 ${todayCheckinTime} 记录` : '把今天记成无烟的一天' }}</text>
</view>
<view class="stats-grid">
<view class="stat-card">
<text class="stat-label">已省下</text>
<text class="stat-value">¥{{ savedMoney }}</text>
<text class="stat-desc">按日均 {{ baselineCigsPerDay }} 支估算</text>
</view>
<view class="stat-card">
<text class="stat-label">恢复进度</text>
<text class="stat-value">{{ healthProgress }}%</text>
<text class="stat-desc">{{ healthTip }}</text>
</view>
</view>
<view class="note-card">
<text class="note-title">今日提醒</text>
<text class="note-text">{{ quitEncouragement }}</text>
</view>
</view>
<view v-else>
<view class="hero-card hero-card-record">
<view class="hero-meta-row">
<text class="hero-label">距上次抽烟</text>
<text v-if="nextSmokeTimeText" class="hero-inline-chip">建议 {{ nextSmokeTimeText }}</text>
</view>
<text class="hero-value hero-value-time">{{ timerDisplay }}</text>
<text class="hero-sub">{{ nextSmokeTimeText ? `下次建议:${nextSmokeTimeText}` : '先把今天的抽烟情况记下来' }}</text>
</view>
<view class="stats-grid">
<view class="stat-card">
<text class="stat-label">今日已抽</text>
<text class="stat-value">{{ todayCount }}<text class="stat-unit"></text></text>
<text class="stat-desc">目标 {{ dailyTarget }} {{ changeText }}</text>
</view>
<view class="stat-card">
<text class="stat-label">今天忍住</text>
<text class="stat-value">{{ resistedCount }}<text class="stat-unit"></text></text>
<text class="stat-desc">每次忍住都在拉开间隔</text>
</view>
</view>
<view class="action-row">
<view class="action-btn action-btn-record" @tap="openSmokeDialog">
<view class="action-icon"></view>
<text class="action-title">记录抽烟</text>
<text class="action-desc">补记这一根</text>
</view>
<view class="action-btn action-btn-resist" @tap="openResistedDialog">
<view class="action-icon"></view>
<text class="action-title">想抽忍住了</text>
<text class="action-desc">记一次成功抵抗</text>
</view>
</view>
</view>
</view>
<smoke-record-dialog
v-if="!isQuitMode"
v-model:show="showDialog"
:type="dialogType"
@submit="handleSubmit"
/>
</view>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { onShareAppMessage, onShow } from '@dcloudio/uni-app'
import * as api from '@/api'
import { useLogin } from '@/hooks/useLogin'
import { useProfileStore } from '@/stores/profile'
import { useUserStore } from '@/stores/user'
import { storage, QUIT_CHECKIN_KEY } from '@/utils/storage'
const profileStore = useProfileStore()
const userStore = useUserStore()
const { waitForLogin } = useLogin()
const loading = ref(true)
const navBarHeight = ref(0)
const showDialog = ref(false)
const dialogType = ref('smoke')
const homeData = ref(null)
const pageReady = ref(false)
const quitState = ref(defaultQuitState())
let timerInterval = null
const timerBaseSeconds = ref(-1)
const timerSeconds = ref(0)
const isQuitMode = computed(() => userStore.mode === 'quit')
const homeSummary = computed(() => homeData.value?.summary || {})
const homeTimer = computed(() => homeData.value?.timer || {})
const userName = computed(() => homeData.value?.greeting?.nickname || userStore.user?.nickname || '戒烟用户')
const userAvatar = computed(() => homeData.value?.greeting?.avatar_url || userStore.user?.avatar_url || 'https://linghu-wmr.oss-cn-beijing.aliyuncs.com/smt/avatar.png')
const greetingTitle = computed(() => {
const hour = new Date().getHours()
let greeting = '晚上好'
if (hour < 6) greeting = '凌晨好'
else if (hour < 12) greeting = '早上好'
else if (hour < 14) greeting = '中午好'
else if (hour < 18) greeting = '下午好'
return `${greeting}${userName.value}`
})
const greetingSubtitle = computed(() => {
if (isQuitMode.value) {
return todayChecked.value ? '今天已经记下来了,继续保持。' : '先把今天记成无烟的一天。'
}
return '记录越及时,后面的趋势越准。'
})
const todayCount = computed(() => homeSummary.value.today_count ?? 0)
const dailyTarget = computed(() => {
const target = homeSummary.value.daily_target
if (target !== undefined && target !== null) return target
return profileStore.profile?.baseline_cigs_per_day || 0
})
const resistedCount = computed(() => homeSummary.value.resisted_count ?? 0)
const changeText = computed(() => {
const reduced = homeSummary.value.reduced_from_yesterday
if (reduced === undefined || reduced === null) return '较昨日暂无对比'
if (reduced === 0) return '较昨日持平'
return homeSummary.value.exceeded_yesterday ? `较昨日多 ${reduced}` : `较昨日少 ${reduced}`
})
const timerDisplay = computed(() => {
if (timerBaseSeconds.value < 0) return '--:--:--'
const totalSeconds = timerBaseSeconds.value + timerSeconds.value
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')}`
})
const nextSmokeTimeText = computed(() => {
const timer = homeTimer.value
if (!timer) return ''
if (timer.next_suggested_clock) return timer.next_suggested_clock
if (!timer.next_suggested_at) return ''
const date = new Date(timer.next_suggested_at)
if (Number.isNaN(date.getTime())) return ''
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
})
const baselineCigsPerDay = computed(() => profileStore.profile?.baseline_cigs_per_day || 10)
const packPriceYuan = computed(() => (profileStore.profile?.pack_price_cent || 2500) / 100)
const quitDays = computed(() => {
if (!quitState.value.lastCheckinDate) return 0
const gap = diffDays(quitState.value.lastCheckinDate, formatDate(new Date()))
if (gap > 1) return 0
return Number(quitState.value.streakDays || 0)
})
const todayChecked = computed(() => quitState.value.lastCheckinDate === formatDate(new Date()))
const todayCheckinTime = computed(() => formatClock(quitState.value.lastCheckinAt))
const savedMoney = computed(() => {
const total = (quitDays.value * baselineCigsPerDay.value / 20) * packPriceYuan.value
return Math.round(total)
})
const healthProgress = computed(() => {
if (quitDays.value >= 365) return 100
if (quitDays.value >= 180) return 86
if (quitDays.value >= 90) return 72
if (quitDays.value >= 30) return 58
if (quitDays.value >= 14) return 38
if (quitDays.value >= 7) return 18
if (quitDays.value >= 1) return 6
return 0
})
const healthTip = computed(() => {
if (quitDays.value >= 365) return '肺部功能已进入长期恢复阶段'
if (quitDays.value >= 180) return '血液循环和咳嗽症状通常会继续改善'
if (quitDays.value >= 30) return '味觉和嗅觉通常会逐步恢复'
if (quitDays.value >= 7) return '一周后,呼吸会比开始时更轻松一些'
if (quitDays.value >= 1) return '第一阶段最难,坚持住就有意义'
return '打卡从今天开始,先拿下第一天'
})
const quitSubtitle = computed(() => {
if (!quitState.value.lastCheckinDate) return '还没有开始记录,先拿下第一天。'
const gap = diffDays(quitState.value.lastCheckinDate, formatDate(new Date()))
if (gap > 1) return '连续记录已中断,今天重新开始。'
if (todayChecked.value) return '今天已经打卡,明天继续。'
return '别漏掉今天这次打卡。'
})
const quitEncouragement = computed(() => {
if (todayChecked.value) return '今天的无烟记录已经落下,尽量把第一支拖得更晚一些。'
if (quitDays.value === 0) return '先不要想很久,只把今天守住就够了。'
return `连续 ${quitDays.value} 天很难得,今天再补上一天。`
})
function defaultQuitState() {
return {
lastCheckinDate: '',
lastCheckinAt: '',
streakDays: 0
}
}
function formatDate(date) {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
function diffDays(fromDate, toDate) {
if (!fromDate || !toDate) return 0
const from = new Date(`${fromDate}T00:00:00`)
const to = new Date(`${toDate}T00:00:00`)
const diff = to.getTime() - from.getTime()
return Math.floor(diff / (24 * 60 * 60 * 1000))
}
function formatClock(value) {
if (!value) return '--:--'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '--:--'
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
function setupNavBar() {
const systemInfo = uni.getSystemInfoSync()
const statusBarH = systemInfo.statusBarHeight || 0
try {
const menuBtn = uni.getMenuButtonBoundingClientRect()
navBarHeight.value = menuBtn.bottom + (menuBtn.top - statusBarH)
} catch (e) {
navBarHeight.value = statusBarH + 44
}
}
function loadQuitState() {
quitState.value = {
...defaultQuitState(),
...(storage.get(QUIT_CHECKIN_KEY) || {})
}
}
function saveQuitState(nextState) {
quitState.value = {
...defaultQuitState(),
...nextState
}
storage.set(QUIT_CHECKIN_KEY, quitState.value)
}
function startTimer() {
stopTimer()
if (timerBaseSeconds.value < 0) return
timerInterval = setInterval(() => {
timerSeconds.value++
}, 1000)
}
function stopTimer() {
if (!timerInterval) return
clearInterval(timerInterval)
timerInterval = null
}
function openSmokeDialog() {
dialogType.value = 'smoke'
showDialog.value = true
}
function openResistedDialog() {
dialogType.value = 'resisted'
showDialog.value = true
}
function applyHomeData(data) {
homeData.value = data
const seconds = data?.timer?.seconds_since_last
timerBaseSeconds.value = typeof seconds === 'number' ? seconds : -1
timerSeconds.value = 0
startTimer()
}
async function fetchRecordHomeData() {
const res = await api.getHome()
const data = res.data || {}
applyHomeData(data)
return data
}
async function handleSubmit(submitData) {
try {
if (dialogType.value === 'smoke') {
await api.createLog(submitData)
timerBaseSeconds.value = 0
timerSeconds.value = 0
startTimer()
uni.showToast({ title: '记录成功', icon: 'success' })
} else {
await api.createResistedLog({
smoke_time: submitData.smoke_time,
smoke_at: submitData.smoke_at,
remark: submitData.remark,
level: submitData.level,
num: submitData.num
})
uni.showToast({ title: '已记下这次忍住', icon: 'success' })
}
await fetchRecordHomeData()
} catch (e) {
console.error('handleSubmit error:', e)
uni.showToast({ title: '保存失败', icon: 'none' })
}
}
function handleQuitCheckin() {
if (todayChecked.value) {
uni.showToast({ title: '今天已经打过卡', icon: 'none' })
return
}
const today = formatDate(new Date())
const previousDate = quitState.value.lastCheckinDate
let streakDays = 1
if (previousDate) {
const gap = diffDays(previousDate, today)
if (gap === 1) {
streakDays = Number(quitState.value.streakDays || 0) + 1
} else if (gap === 0) {
streakDays = Number(quitState.value.streakDays || 0)
}
}
saveQuitState({
lastCheckinDate: today,
lastCheckinAt: new Date().toISOString(),
streakDays
})
uni.showToast({ title: '打卡成功', icon: 'success' })
}
async function ensureProfileReady() {
const profileData = await profileStore.fetchProfile()
const profile = profileData.profile
const isCompleted = profileData.is_completed ||
(profile && profile.onboarding_completed_at) ||
(profile && profile.baseline_cigs_per_day > 0)
if (!profileData.exists || !isCompleted) {
uni.navigateTo({ url: '/pages/onboarding/index' })
return false
}
return true
}
async function refreshCurrentMode() {
if (!userStore.mode) return
const profileReady = await ensureProfileReady()
if (!profileReady) return
if (isQuitMode.value) {
stopTimer()
loadQuitState()
return
}
await fetchRecordHomeData()
}
async function initPage() {
setupNavBar()
loading.value = true
try {
await waitForLogin()
await profileStore.fetchProfile()
if (!userStore.mode) {
uni.navigateTo({ url: '/pages/mode-select/index' })
return
}
await refreshCurrentMode()
} catch (e) {
console.error('initPage error:', e)
} finally {
pageReady.value = true
loading.value = false
}
}
onMounted(() => {
initPage()
})
onShow(async () => {
if (!pageReady.value) return
try {
await refreshCurrentMode()
} catch (e) {
console.error('home onShow error:', e)
}
})
onUnmounted(() => {
stopTimer()
})
onShareAppMessage(() => {
return {
title: isQuitMode.value ? '我在坚持戒烟打卡' : '我在记录自己的抽烟变化',
path: 'pages/index/index'
}
})
</script>
<style scoped>
.page {
min-height: 100vh;
position: relative;
background:
radial-gradient(circle at top left, rgba(52, 200, 160, 0.18), transparent 34%),
radial-gradient(circle at top right, rgba(255, 255, 255, 0.9), transparent 26%),
linear-gradient(180deg, #edf2f8 0%, #f5f7fb 38%, #fbfdff 100%);
box-sizing: border-box;
overflow: hidden;
}
.page-glow {
position: absolute;
border-radius: 50%;
filter: blur(24rpx);
opacity: 0.7;
pointer-events: none;
}
.page-glow-a {
top: 108rpx;
left: -140rpx;
width: 360rpx;
height: 360rpx;
background: rgba(52, 200, 160, 0.16);
}
.page-glow-b {
top: 280rpx;
right: -120rpx;
width: 320rpx;
height: 320rpx;
background: rgba(255, 255, 255, 0.86);
}
.nav-placeholder,
.dashboard,
.skeleton {
position: relative;
z-index: 1;
}
.dashboard {
padding: 24rpx 24rpx 168rpx;
}
.header-card,
.hero-card,
.primary-action,
.stat-card,
.note-card,
.action-btn,
.skeleton-card {
background: rgba(255, 255, 255, 0.76);
border-radius: 32rpx;
border: 2rpx solid rgba(255, 255, 255, 0.62);
box-shadow: 0 16rpx 42rpx rgba(15, 23, 42, 0.08);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
}
.header-card {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 30rpx;
margin-bottom: 28rpx;
gap: 20rpx;
}
.header-copy {
flex: 1;
display: flex;
flex-direction: column;
}
.header-side {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 14rpx;
}
.header-eyebrow {
font-size: 20rpx;
font-weight: 700;
letter-spacing: 4rpx;
text-transform: uppercase;
color: #98a2b3;
}
.avatar {
width: 92rpx;
height: 92rpx;
border-radius: 50%;
border: 4rpx solid rgba(255, 255, 255, 0.82);
background: rgba(255, 255, 255, 0.72);
}
.greeting-text {
display: block;
margin-top: 10rpx;
font-size: 42rpx;
line-height: 1.18;
font-weight: 700;
color: #111827;
}
.greeting-sub {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
line-height: 1.5;
color: #667085;
}
.mode-chip {
padding: 10rpx 18rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 600;
border: 2rpx solid rgba(255, 255, 255, 0.64);
}
.mode-chip-quit {
background: rgba(52, 200, 160, 0.14);
color: #17795c;
}
.mode-chip-record {
background: rgba(245, 158, 11, 0.12);
color: #b56b09;
}
.hero-card {
padding: 36rpx 32rpx;
margin-bottom: 24rpx;
}
.hero-card-quit {
background: linear-gradient(135deg, rgba(246, 255, 251, 0.88), rgba(255, 255, 255, 0.7));
}
.hero-card-record {
background: linear-gradient(135deg, rgba(255, 250, 243, 0.88), rgba(255, 255, 255, 0.7));
}
.hero-meta-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.hero-label {
display: block;
font-size: 24rpx;
color: #667085;
letter-spacing: 2rpx;
}
.hero-inline-chip {
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.82);
border: 2rpx solid rgba(255, 255, 255, 0.7);
font-size: 22rpx;
font-weight: 600;
color: #475467;
}
.hero-value-row {
display: flex;
align-items: baseline;
gap: 12rpx;
margin-top: 18rpx;
}
.hero-value,
.hero-value-time {
display: block;
margin-top: 18rpx;
font-size: 82rpx;
line-height: 1;
font-weight: 700;
color: #111827;
}
.hero-unit {
font-size: 28rpx;
color: #667085;
}
.hero-sub {
display: block;
margin-top: 18rpx;
font-size: 26rpx;
line-height: 1.6;
color: #475467;
}
.primary-action {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 36rpx 32rpx;
margin-bottom: 24rpx;
background: linear-gradient(180deg, #32c59d 0%, #1aa37a 100%);
color: #ffffff;
box-shadow: 0 16rpx 36rpx rgba(26, 163, 122, 0.24);
}
.primary-action-done {
background: linear-gradient(180deg, #1f9f7a 0%, #188564 100%);
}
.primary-action-icon {
width: 68rpx;
height: 68rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.18);
border: 2rpx solid rgba(255, 255, 255, 0.22);
font-size: 30rpx;
font-weight: 700;
}
.primary-action-title {
display: block;
margin-top: 18rpx;
font-size: 34rpx;
font-weight: 700;
}
.primary-action-desc {
display: block;
margin-top: 10rpx;
font-size: 24rpx;
line-height: 1.55;
opacity: 0.9;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20rpx;
margin-bottom: 24rpx;
}
.stat-card {
padding: 28rpx 24rpx 26rpx;
background: rgba(255, 255, 255, 0.86);
border: 2rpx solid rgba(15, 23, 42, 0.05);
box-shadow: 0 10rpx 30rpx rgba(15, 23, 42, 0.05);
}
.stat-label {
display: block;
font-size: 24rpx;
color: #667085;
}
.stat-value {
display: block;
margin-top: 18rpx;
font-size: 46rpx;
line-height: 1.1;
font-weight: 700;
color: #111827;
}
.stat-unit {
font-size: 26rpx;
font-weight: 600;
color: #667085;
}
.stat-desc {
display: block;
margin-top: 16rpx;
font-size: 23rpx;
line-height: 1.5;
color: #667085;
}
.note-card {
padding: 28rpx;
background: rgba(255, 255, 255, 0.84);
border: 2rpx solid rgba(15, 23, 42, 0.05);
box-shadow: 0 10rpx 30rpx rgba(15, 23, 42, 0.05);
}
.note-title {
display: block;
font-size: 24rpx;
color: #1a7f61;
font-weight: 700;
letter-spacing: 1rpx;
}
.note-text {
display: block;
margin-top: 14rpx;
font-size: 26rpx;
line-height: 1.7;
color: #344054;
}
.action-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20rpx;
}
.action-btn {
padding: 30rpx 24rpx 28rpx;
background: rgba(255, 255, 255, 0.86);
border: 2rpx solid rgba(15, 23, 42, 0.05);
box-shadow: 0 10rpx 30rpx rgba(15, 23, 42, 0.05);
}
.action-btn-record {
background: linear-gradient(135deg, rgba(255, 248, 239, 0.95), rgba(255, 255, 255, 0.88));
}
.action-btn-resist {
background: linear-gradient(135deg, rgba(245, 255, 251, 0.95), rgba(255, 255, 255, 0.88));
}
.action-icon {
width: 60rpx;
height: 60rpx;
border-radius: 18rpx;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.9);
border: 2rpx solid rgba(255, 255, 255, 0.76);
font-size: 28rpx;
font-weight: 700;
color: #111827;
}
.action-title {
display: block;
margin-top: 14rpx;
font-size: 30rpx;
font-weight: 700;
color: #111827;
}
.action-desc {
display: block;
margin-top: 10rpx;
font-size: 23rpx;
color: #6b7280;
}
.skeleton {
padding: 24rpx;
}
.skeleton-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20rpx;
margin-bottom: 20rpx;
}
.skeleton-card {
height: 180rpx;
background: linear-gradient(90deg, rgba(232, 238, 245, 0.92) 25%, rgba(255, 255, 255, 0.98) 50%, rgba(232, 238, 245, 0.92) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
margin-bottom: 20rpx;
}
.skeleton-card-lg {
height: 140rpx;
}
.skeleton-card-md {
height: 260rpx;
}
.skeleton-card-sm {
height: 120rpx;
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
</style>
+803
View File
@@ -0,0 +1,803 @@
<template>
<view class="page">
<view class="page-glow page-glow-a"></view>
<view class="page-glow page-glow-b"></view>
<view class="page-header">
<text class="page-eyebrow">History</text>
<text class="page-title">记录历史</text>
<text class="page-subtitle">按时间查看抽烟和忍住记录</text>
</view>
<!-- 筛选标签 -->
<view class="filters">
<view class="tabs">
<view
v-for="tab in tabs"
:key="tab.value"
class="tab"
:class="{ 'tab-active': currentTab === tab.value }"
@tap="currentTab = tab.value"
>
{{ tab.label }}
</view>
</view>
</view>
<!-- 记录列表 -->
<scroll-view
class="scroll-container"
scroll-y
:refresher-enabled="true"
:refresher-triggered="logsStore.refreshing"
@refresherrefresh="onRefresh"
@scrolltolower="onLoadMore"
>
<!-- 骨架屏 -->
<view v-if="logsStore.loading && logsStore.logs.length === 0" class="skeleton">
<view v-for="i in 3" :key="i" class="skeleton-item">
<view class="skeleton-dot"></view>
<view class="skeleton-card">
<view class="skeleton-line skeleton-line-title"></view>
<view class="skeleton-line skeleton-line-text"></view>
<view class="skeleton-line skeleton-line-text short"></view>
</view>
</view>
</view>
<!-- 时间轴 -->
<view v-else-if="filteredLogs.length > 0" class="log-list">
<view v-for="(group, date) in groupedLogs" :key="date" class="log-group">
<view class="group-header">
<text class="group-title">{{ formatGroupTitle(date) }}</text>
<text class="group-count">{{ group.length }}条记录</text>
</view>
<view class="group-items">
<view v-for="log in group" :key="log.id" class="log-card" :class="log.type === 'resisted' ? 'log-card-resisted' : 'log-card-smoke'">
<view class="log-bar"></view>
<view class="log-icon" :class="log.type === 'resisted' ? 'icon-resisted' : 'icon-smoke'">
<text v-if="log.type === 'resisted'"></text>
<text v-else></text>
</view>
<view class="log-main">
<view class="log-top">
<view class="log-time-tag">
<text class="log-time">{{ log.displayTime || '--:--' }}</text>
<text class="log-tag" :class="log.type === 'resisted' ? 'tag-resisted' : 'tag-smoke'">
{{ log.type === 'resisted' ? '已忍住' : '已抽烟' }}
</text>
</view>
<view class="log-right">
<text v-if="log.type === 'smoke'" class="count-pill">{{ log.num !== undefined && log.num !== null ? log.num : 0 }}</text>
<text v-else class="thumb-pill">👍</text>
</view>
</view>
<text
v-if="log.remark && typeof log.remark === 'string' && log.remark.trim() && log.remark.trim().length > 0"
class="log-desc"
>{{ log.remark.trim() }}</text>
<view class="log-meta-row">
<text
v-if="log.level !== undefined && log.level !== null"
class="level-text"
:class="levelClass(log.level)"
>烟瘾程度{{ levelLabel(log.level) }}</text>
<text v-if="log.interval" class="log-interval">距上次 {{ log.interval }}</text>
</view>
<view class="log-actions">
<text class="action-btn edit-btn" @tap.stop="handleEdit(log)">编辑</text>
<text class="action-btn delete-btn" @tap.stop="handleDelete(log)">删除</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-else class="empty-state">
<text class="empty-icon"></text>
<text class="empty-text">暂无记录</text>
<text class="empty-hint">点击右下角按钮开始记录</text>
</view>
<!-- 加载更多 -->
<view v-if="logsStore.loading && logsStore.logs.length > 0" class="loading-more">
<text class="loading-text">加载中...</text>
</view>
<view v-if="!logsStore.hasMore && logsStore.logs.length > 0" class="no-more">
<text class="no-more-text">没有更多了</text>
</view>
</scroll-view>
<!-- 浮动按钮 -->
<view class="fab" @tap="addLog">
<text class="fab-icon">+</text>
</view>
<!-- 编辑弹框 -->
<smoke-record-dialog
v-model:show="showEditDialog"
:type="editType"
:initial-data="editData"
@submit="handleUpdate"
/>
</view>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { onShareAppMessage } from '@dcloudio/uni-app'
import { useLogsStore } from '@/stores/logs'
import { useLogin } from '@/hooks/useLogin'
const { waitForLogin } = useLogin()
const logsStore = useLogsStore()
const tabs = [
{ label: '全部', value: 'all' },
{ label: '已抽烟', value: 'smoke' },
{ label: '已忍住', value: 'resisted' }
]
const currentTab = ref('all')
const showEditDialog = ref(false)
const editType = ref('smoke')
const editData = ref(null)
const editingLogId = ref(null)
// 筛选后的记录
const filteredLogs = computed(() => {
const logs = logsStore.formattedLogs
if (currentTab.value === 'all') {
return logs
}
return logs.filter(log => log.type === currentTab.value)
})
// 按日期分组
const groupedLogs = computed(() => {
return filteredLogs.value.reduce((groups, log) => {
const date = log.displayDate
if (!groups[date]) {
groups[date] = []
}
groups[date].push(log)
return groups
}, {})
})
// 本地日期 YYYY-MM-DD(避免 toISOString 用 UTC 导致日期差一天)
function localDateStr(d) {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
// 格式化分组标题
function formatGroupTitle(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
const today = new Date()
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
const todayStr = localDateStr(today)
const yesterdayStr = localDateStr(yesterday)
if (dateStr === todayStr) {
return '今天'
}
if (dateStr === yesterdayStr) {
return '昨天'
}
return `${date.getMonth() + 1}${date.getDate()}`
}
// 下拉刷新
async function onRefresh() {
await logsStore.fetchLogs(true, currentTab.value)
}
// 上拉加载
async function onLoadMore() {
await logsStore.loadMore()
}
// 新增记录
function addLog() {
uni.switchTab({ url: '/pages/index/index' })
}
// 编辑记录
function handleEdit(log) {
editingLogId.value = log.id
editType.value = log.type
editData.value = {
smoke_time: log.smoke_time?.split('T')[0] || '',
smoke_time_only: log.displayTime,
smoke_at: log.smoke_at,
remark: log.remark || '',
level: log.level ?? 2,
num: log.num ?? 1
}
showEditDialog.value = true
}
// 更新记录
async function handleUpdate(data) {
if (!editingLogId.value) return
const success = await logsStore.updateLog(editingLogId.value, data)
if (success) {
showEditDialog.value = false
editingLogId.value = null
editData.value = null
}
}
// 删除记录
function handleDelete(log) {
uni.showModal({
title: '确认删除',
content: `确定要删除这条${log.type === 'resisted' ? '忍住' : '抽烟'}记录吗?`,
confirmColor: '#EF4444',
success: async (res) => {
if (res.confirm) {
await logsStore.deleteLog(log.id)
}
}
})
}
// 初始化页面
async function initPage() {
try {
await waitForLogin()
await logsStore.fetchLogs(true, currentTab.value)
} catch (e) {
console.error('initPage error:', e)
}
}
onMounted(() => {
initPage()
})
function levelClass(level) {
const value = Number(level)
if (Number.isNaN(value)) return 'level-unknown'
if (value <= 1) return 'level-1'
if (value === 2) return 'level-2'
if (value === 3) return 'level-3'
if (value === 4) return 'level-4'
return 'level-5'
}
function levelLabel(level) {
const value = Number(level)
if (Number.isNaN(value)) return '未知'
if (value <= 1) return '轻微'
if (value === 2) return '中等'
if (value === 3) return '明显'
if (value === 4) return '强烈'
return '极强'
}
watch(currentTab, async (value) => {
await logsStore.fetchLogs(true, value)
})
onShareAppMessage(() => {
return {
title: '戒烟助手 - 我的戒烟记录',
path: 'pages/index/index'
}
})
</script>
<style scoped>
.page {
min-height: 100vh;
position: relative;
background:
radial-gradient(circle at top left, rgba(52, 200, 160, 0.16), transparent 30%),
radial-gradient(circle at top right, rgba(255, 255, 255, 0.92), transparent 24%),
linear-gradient(180deg, #edf2f8 0%, #f5f7fb 38%, #fbfdff 100%);
box-sizing: border-box;
overflow: hidden;
}
.page-glow {
position: absolute;
border-radius: 50%;
filter: blur(24rpx);
opacity: 0.72;
pointer-events: none;
}
.page-glow-a {
top: 60rpx;
left: -140rpx;
width: 360rpx;
height: 360rpx;
background: rgba(52, 200, 160, 0.14);
}
.page-glow-b {
top: 360rpx;
right: -120rpx;
width: 320rpx;
height: 320rpx;
background: rgba(255, 255, 255, 0.9);
}
.page-header,
.filters,
.scroll-container,
.fab {
position: relative;
z-index: 1;
}
.page-header {
padding: 32rpx 32rpx 8rpx;
}
.page-eyebrow {
display: block;
font-size: 20rpx;
font-weight: 700;
letter-spacing: 4rpx;
text-transform: uppercase;
color: #98a2b3;
}
.page-title {
display: block;
margin-top: 10rpx;
font-size: 42rpx;
line-height: 1.18;
font-weight: 700;
color: #111827;
}
.page-subtitle {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
line-height: 1.5;
color: #667085;
}
.filters {
display: flex;
align-items: center;
gap: 16rpx;
padding: 16rpx 32rpx 8rpx;
}
.tabs {
display: flex;
flex: 1;
background: rgba(255, 255, 255, 0.76);
border-radius: 24rpx;
padding: 6rpx;
box-shadow: 0 12rpx 28rpx rgba(15, 23, 42, 0.06);
border: 2rpx solid rgba(255, 255, 255, 0.66);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
}
.tab {
flex: 1;
text-align: center;
padding: 14rpx 0;
border-radius: 16rpx;
font-size: 26rpx;
color: #6B7280;
transition: all 0.2s;
font-weight: 600;
}
.tab-active {
background: rgba(255, 255, 255, 0.92);
color: #111827;
box-shadow: 0 8rpx 18rpx rgba(15, 23, 42, 0.06);
}
.filter-btn {
width: 72rpx;
height: 72rpx;
border-radius: 20rpx;
background-color: #FFFFFF;
display: flex;
align-items: center;
justify-content: center;
border: 2rpx solid #ECFDF3;
box-shadow: 0 6rpx 14rpx rgba(16, 185, 129, 0.1);
}
.filter-icon {
font-size: 32rpx;
}
.scroll-container {
height: calc(100vh - 10rpx);
padding: 0 32rpx 40rpx;
box-sizing: border-box;
}
/* 骨架屏 */
.skeleton {
padding-top: 24rpx;
}
.skeleton-item {
position: relative;
padding-left: 80rpx;
margin-bottom: 32rpx;
}
.skeleton-dot {
position: absolute;
left: 0;
top: 16rpx;
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: linear-gradient(90deg, #E5E7EB 25%, #F3F4F6 50%, #E5E7EB 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-card {
background: rgba(255, 255, 255, 0.82);
border-radius: 28rpx;
padding: 24rpx;
border: 2rpx solid rgba(255, 255, 255, 0.66);
}
.skeleton-line {
height: 24rpx;
background: linear-gradient(90deg, #E5E7EB 25%, #F3F4F6 50%, #E5E7EB 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 8rpx;
margin-bottom: 16rpx;
}
.skeleton-line-title {
width: 60%;
height: 32rpx;
}
.skeleton-line-text {
width: 80%;
}
.skeleton-line-text.short {
width: 40%;
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
/* 列表分组 */
.log-group {
margin-bottom: 32rpx;
}
.group-header {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 16rpx;
}
.group-title {
font-size: 30rpx;
font-weight: 700;
color: #0F172A;
}
.group-count {
font-size: 22rpx;
color: #667085;
background-color: rgba(255, 255, 255, 0.76);
padding: 6rpx 16rpx;
border-radius: 999rpx;
}
.group-items {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.log-card {
position: relative;
background: rgba(255, 255, 255, 0.82);
border-radius: 28rpx;
padding: 24rpx 24rpx 20rpx 24rpx;
box-shadow: 0 14rpx 32rpx rgba(15, 23, 42, 0.06);
display: flex;
gap: 20rpx;
overflow: hidden;
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
}
.log-card-resisted {
background: linear-gradient(135deg, rgba(245, 255, 251, 0.95), rgba(255, 255, 255, 0.88));
border: 2rpx solid rgba(255, 255, 255, 0.7);
}
.log-card-smoke {
background: linear-gradient(135deg, rgba(255, 248, 242, 0.95), rgba(255, 255, 255, 0.88));
border: 2rpx solid rgba(255, 255, 255, 0.7);
}
.log-bar {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 8rpx;
background-color: #10B981;
}
.log-card-smoke .log-bar {
background-color: #F97316;
}
.log-icon {
width: 80rpx;
height: 80rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 26rpx;
font-weight: 700;
flex-shrink: 0;
}
.icon-resisted {
background-color: rgba(16, 185, 129, 0.16);
color: #0F766E;
}
.icon-smoke {
background-color: rgba(249, 115, 22, 0.15);
color: #C2410C;
}
.log-main {
flex: 1;
min-width: 0;
}
.log-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin-bottom: 10rpx;
}
.log-time-tag {
display: flex;
align-items: center;
gap: 12rpx;
}
.log-time {
font-size: 30rpx;
font-weight: 700;
color: #0F172A;
}
.log-tag {
font-size: 22rpx;
padding: 6rpx 14rpx;
border-radius: 12rpx;
font-weight: 700;
}
.tag-smoke {
background-color: #FEE2E2;
color: #DC2626;
}
.tag-resisted {
background-color: #DCFCE7;
color: #16A34A;
}
.log-right {
display: flex;
align-items: center;
gap: 8rpx;
flex-shrink: 0;
}
.count-pill {
font-size: 22rpx;
color: #DC2626;
background-color: #FEE2E2;
padding: 6rpx 14rpx;
border-radius: 12rpx;
font-weight: 700;
}
.thumb-pill {
font-size: 24rpx;
background-color: #DCFCE7;
color: #16A34A;
padding: 6rpx 12rpx;
border-radius: 12rpx;
}
.log-desc {
font-size: 24rpx;
color: #475467;
line-height: 1.5;
margin-bottom: 10rpx;
}
.log-meta-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
flex-wrap: wrap;
}
.level-text {
font-size: 22rpx;
font-weight: 600;
}
.log-interval {
font-size: 22rpx;
color: #667085;
background-color: rgba(255, 255, 255, 0.72);
padding: 4rpx 12rpx;
border-radius: 999rpx;
}
.log-actions {
display: flex;
justify-content: flex-end;
gap: 16rpx;
margin-top: 12rpx;
}
.action-btn {
font-size: 22rpx;
padding: 6rpx 14rpx;
border-radius: 999rpx;
background-color: rgba(255, 255, 255, 0.8);
border: 2rpx solid rgba(15, 23, 42, 0.08);
color: #667085;
}
.edit-btn {
color: #2563EB;
border-color: #DBEAFE;
background-color: #EFF6FF;
}
.delete-btn {
color: #DC2626;
border-color: #FECACA;
background-color: #FEF2F2;
}
.level-unknown {
color: #64748B;
}
.level-1 {
color: #16A34A;
}
.level-2 {
color: #0EA5E9;
}
.level-3 {
color: #F59E0B;
}
.level-4 {
color: #F97316;
}
.level-5 {
color: #EF4444;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 32rpx;
}
.empty-icon {
width: 112rpx;
height: 112rpx;
border-radius: 36rpx;
background: rgba(255, 255, 255, 0.82);
border: 2rpx solid rgba(255, 255, 255, 0.68);
display: flex;
align-items: center;
justify-content: center;
font-size: 40rpx;
font-weight: 700;
color: #98a2b3;
margin-bottom: 24rpx;
}
.empty-text {
font-size: 32rpx;
color: #6B7280;
font-weight: 500;
margin-bottom: 12rpx;
}
.empty-hint {
font-size: 26rpx;
color: #9CA3AF;
}
/* 加载状态 */
.loading-more, .no-more {
text-align: center;
padding: 32rpx;
}
.loading-text, .no-more-text {
font-size: 24rpx;
color: #9CA3AF;
}
/* 浮动按钮 */
.fab {
position: fixed;
right: 32rpx;
bottom: 140rpx;
width: 96rpx;
height: 96rpx;
background: linear-gradient(180deg, #32c59d 0%, #1aa37a 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 16rpx 34rpx rgba(26, 163, 122, 0.24);
transition: all 0.3s;
z-index: 100;
}
.fab:active {
transform: scale(0.95);
}
.fab-icon {
font-size: 48rpx;
color: #FFFFFF;
font-weight: 300;
}
</style>
+223
View File
@@ -0,0 +1,223 @@
<template>
<view class="page">
<view class="nav-placeholder" :style="{ height: navBarHeight + 'px' }"></view>
<view class="content">
<view class="hero">
<text class="eyebrow">首次进入先选模式</text>
<text class="title">你现在想怎么用这个小程序</text>
<text class="subtitle">先选戒烟打卡记录抽烟后续可以在个人中心随时切换</text>
</view>
<view
class="mode-card"
:class="{ 'mode-card-active': currentMode === 'quit' }"
@tap="selectMode('quit')"
>
<view class="mode-icon mode-icon-quit"></view>
<view class="mode-main">
<text class="mode-title">戒烟打卡</text>
<text class="mode-desc">按天记录今天没抽用连续天数驱动坚持</text>
</view>
<text class="mode-arrow"></text>
</view>
<view
class="mode-card"
:class="{ 'mode-card-active': currentMode === 'record' }"
@tap="selectMode('record')"
>
<view class="mode-icon mode-icon-record"></view>
<view class="mode-main">
<text class="mode-title">记录抽烟</text>
<text class="mode-desc">继续按支数记录观察自己的频率和变化趋势</text>
</view>
<text class="mode-arrow"></text>
</view>
<text class="footer-tip">当前选择{{ currentModeText }}</text>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useLogin } from '@/hooks/useLogin'
import { useUserStore } from '@/stores/user'
import { useProfileStore } from '@/stores/profile'
const userStore = useUserStore()
const profileStore = useProfileStore()
const { waitForLogin } = useLogin()
const navBarHeight = ref(0)
const submitting = ref(false)
const currentMode = computed(() => userStore.mode)
const currentModeText = computed(() => {
if (userStore.mode === 'quit') return '戒烟打卡'
if (userStore.mode === 'record') return '记录抽烟'
return '未选择'
})
function setupNavBar() {
const sys = uni.getSystemInfoSync()
const statusBarH = sys.statusBarHeight || 0
try {
const menuBtn = uni.getMenuButtonBoundingClientRect()
navBarHeight.value = menuBtn.bottom + (menuBtn.top - statusBarH)
} catch (e) {
navBarHeight.value = statusBarH + 44
}
}
async function selectMode(mode) {
if (submitting.value) return
submitting.value = true
userStore.setMode(mode)
try {
const profileData = await profileStore.saveProfile({ mode })
const profile = profileData.profile
const isCompleted = profileData.is_completed ||
(profile && profile.onboarding_completed_at) ||
(profile && profile.baseline_cigs_per_day > 0)
if (!profileData.exists || !isCompleted) {
uni.redirectTo({ url: '/pages/onboarding/index' })
return
}
uni.switchTab({ url: '/pages/index/index' })
} catch (e) {
console.error('selectMode error:', e)
uni.switchTab({ url: '/pages/index/index' })
} finally {
submitting.value = false
}
}
onMounted(async () => {
setupNavBar()
await waitForLogin()
})
</script>
<style scoped>
.page {
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(52, 200, 160, 0.16), transparent 34%),
radial-gradient(circle at top right, rgba(255, 255, 255, 0.92), transparent 24%),
linear-gradient(180deg, #edf2f8 0%, #f5f7fb 38%, #fbfdff 100%);
}
.nav-placeholder {
background: transparent;
}
.content {
padding: 40rpx 32rpx 56rpx;
}
.hero {
margin-bottom: 40rpx;
}
.eyebrow {
display: inline-flex;
padding: 8rpx 18rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.78);
color: #667085;
font-size: 22rpx;
margin-bottom: 20rpx;
}
.title {
display: block;
font-size: 48rpx;
font-weight: 700;
color: #111827;
line-height: 1.28;
}
.subtitle {
display: block;
margin-top: 16rpx;
font-size: 28rpx;
line-height: 1.6;
color: #667085;
}
.mode-card {
display: flex;
align-items: center;
gap: 24rpx;
padding: 30rpx 28rpx;
margin-bottom: 24rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.8);
border: 2rpx solid rgba(255, 255, 255, 0.66);
box-shadow: 0 16rpx 44rpx rgba(15, 23, 42, 0.08);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
}
.mode-card-active {
border-color: rgba(255, 255, 255, 0.72);
box-shadow: 0 20rpx 52rpx rgba(26, 163, 122, 0.12);
}
.mode-icon {
width: 92rpx;
height: 92rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 34rpx;
font-weight: 700;
flex-shrink: 0;
}
.mode-icon-quit {
background: linear-gradient(135deg, rgba(245, 255, 251, 0.95), rgba(219, 252, 231, 0.9));
}
.mode-icon-record {
background: linear-gradient(135deg, rgba(255, 248, 242, 0.95), rgba(254, 231, 214, 0.9));
}
.mode-main {
flex: 1;
}
.mode-title {
display: block;
font-size: 34rpx;
font-weight: 700;
color: #111827;
}
.mode-desc {
display: block;
margin-top: 10rpx;
font-size: 25rpx;
line-height: 1.5;
color: #6b7280;
}
.mode-arrow {
font-size: 42rpx;
color: #9ca3af;
}
.footer-tip {
display: block;
margin-top: 20rpx;
font-size: 24rpx;
color: #6b7280;
text-align: center;
}
</style>
+583
View File
@@ -0,0 +1,583 @@
<template>
<view class="page">
<view class="nav-area" :style="{ paddingTop: navBarHeight + 'px' }">
<view class="nav-row">
<text class="step-indicator">{{ step }} / {{ totalSteps }}</text>
</view>
<view class="progress-bar">
<view class="progress-fill" :style="{ width: progressWidth }"></view>
</view>
</view>
<view class="content">
<view class="mode-section">
<text class="mode-section-label">使用模式</text>
<view class="mode-switch">
<view
v-for="item in modeOptions"
:key="item.value"
class="mode-switch-item"
:class="{ 'mode-switch-item-active': currentMode === item.value }"
@tap="selectMode(item.value)"
>
<text class="mode-switch-title">{{ item.label }}</text>
<text class="mode-switch-desc">{{ item.desc }}</text>
</view>
</view>
</view>
<view v-if="step === 1" class="step">
<text class="step-title">你每天抽多少支烟</text>
<text class="step-desc">{{ baselineDesc }}</text>
<view class="input-group">
<view class="input-row">
<view class="input-btn" @tap="decreaseCigs">-</view>
<text class="input-value">{{ formData.baseline_cigs_per_day }}</text>
<view class="input-btn" @tap="increaseCigs">+</view>
</view>
<text class="input-unit">/</text>
</view>
</view>
<view v-if="step === 2" class="step">
<text class="step-title">你的烟龄是多久</text>
<text class="step-desc">了解你的吸烟历史有助于更好地帮助你</text>
<view class="options">
<view
v-for="option in smokingYearsOptions"
:key="option.value"
class="option"
:class="{ 'option-active': formData.smoking_years === option.value }"
@tap="formData.smoking_years = option.value"
>
{{ option.label }}
</view>
</view>
</view>
<view v-if="step === 3" class="step">
<text class="step-title">{{ motivationTitle }}</text>
<text class="step-desc">{{ motivationDesc }}</text>
<view class="options options-wrap">
<view
v-for="option in quitMotivationOptions"
:key="option"
class="option option-tag"
:class="{ 'option-active': formData.quit_motivations.includes(option) }"
@tap="toggleMotivation(option)"
>
{{ option }}
</view>
</view>
</view>
<view v-if="step === 4" class="step">
<text class="step-title">你通常什么时候起床和睡觉</text>
<text class="step-desc">我们会在你的休息时间避免打扰你</text>
<view class="time-row">
<view class="time-item">
<text class="time-label">起床时间</text>
<picker mode="time" :value="formData.wake_up_time" @change="onWakeTimeChange">
<view class="time-picker">{{ formData.wake_up_time }}</view>
</picker>
</view>
<view class="time-item">
<text class="time-label">睡觉时间</text>
<picker mode="time" :value="formData.sleep_time" @change="onSleepTimeChange">
<view class="time-picker">{{ formData.sleep_time }}</view>
</picker>
</view>
</view>
</view>
<view v-if="step === 5" class="step">
<text class="step-title">每包烟多少钱</text>
<text class="step-desc">我们会帮你计算省下的钱</text>
<view class="input-group">
<view class="price-input">
<text class="price-prefix">¥</text>
<input
type="digit"
v-model="priceYuan"
class="price-field"
placeholder="0"
placeholder-style="color: #6B7280"
/>
</view>
<text class="input-unit">/</text>
</view>
</view>
</view>
<view class="footer">
<view v-if="step > 1" class="btn-secondary" @tap="prevStep">上一步</view>
<view class="btn-primary" :class="{ 'btn-full': step === 1 }" @tap="nextStep">
{{ step === 5 ? finishButtonText : '下一步' }}
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onShareAppMessage } from '@dcloudio/uni-app'
import { useProfileStore } from '@/stores/profile'
import { useUserStore } from '@/stores/user'
import { useLogin } from '@/hooks/useLogin'
const profileStore = useProfileStore()
const userStore = useUserStore()
const { waitForLogin } = useLogin()
const navBarHeight = ref(0)
const step = ref(1)
const totalSteps = 5
const modeSaving = ref(false)
const modeOptions = [
{ value: 'quit', label: '戒烟打卡', desc: '按天记录今天没抽' },
{ value: 'record', label: '记录抽烟', desc: '按支数记录变化' }
]
const formData = ref({
mode: 'record',
baseline_cigs_per_day: 10,
smoking_years: 5,
quit_motivations: [],
smoke_motivations: [],
wake_up_time: '07:30',
sleep_time: '23:00',
pack_price_cent: 2500
})
const priceYuan = ref('25')
const progressWidth = computed(() => `${(step.value / totalSteps) * 100}%`)
const currentMode = computed(() => formData.value.mode || userStore.mode || 'record')
const isRecordMode = computed(() => currentMode.value === 'record')
const baselineDesc = computed(() => isRecordMode.value ? '这会成为你后续记录和统计的基线' : '这将帮助我们为你制定更合适的戒烟节奏')
const motivationTitle = computed(() => isRecordMode.value ? '你为什么想先开始记录抽烟?' : '你为什么想戒烟?')
const motivationDesc = computed(() => isRecordMode.value ? '选择最符合你当前状态的原因(可多选)' : '选择对你最重要的原因(可多选)')
const finishButtonText = computed(() => isRecordMode.value ? '开始记录之旅' : '开始戒烟之旅')
const smokingYearsOptions = [
{ label: '少于1年', value: 1 },
{ label: '1-3年', value: 2 },
{ label: '3-5年', value: 4 },
{ label: '5-10年', value: 7 },
{ label: '10年以上', value: 15 }
]
const quitMotivationOptions = [
'身体健康',
'家人孩子',
'省钱',
'形象气质',
'工作需要',
'伴侣要求'
]
function increaseCigs() {
formData.value.baseline_cigs_per_day++
}
function decreaseCigs() {
if (formData.value.baseline_cigs_per_day > 1) {
formData.value.baseline_cigs_per_day--
}
}
function toggleMotivation(option) {
const index = formData.value.quit_motivations.indexOf(option)
if (index > -1) {
formData.value.quit_motivations.splice(index, 1)
} else {
formData.value.quit_motivations.push(option)
}
}
async function selectMode(mode) {
formData.value.mode = mode
userStore.setMode(mode)
if (!profileStore.exists || modeSaving.value) return
modeSaving.value = true
try {
await profileStore.saveProfile({ mode })
} catch (e) {
console.error('saveModeInOnboarding error:', e)
} finally {
modeSaving.value = false
}
}
function onWakeTimeChange(e) {
formData.value.wake_up_time = e.detail.value
}
function onSleepTimeChange(e) {
formData.value.sleep_time = e.detail.value
}
function prevStep() {
if (step.value > 1) {
step.value--
}
}
async function nextStep() {
if (step.value < totalSteps) {
step.value++
return
}
formData.value.pack_price_cent = Math.round(parseFloat(priceYuan.value || '0') * 100)
try {
uni.showLoading({ title: '保存中...' })
await profileStore.saveProfile(formData.value)
uni.hideLoading()
if (!formData.value.mode) {
uni.redirectTo({ url: '/pages/mode-select/index' })
return
}
uni.switchTab({ url: '/pages/index/index' })
} catch (e) {
uni.hideLoading()
uni.showToast({ title: '保存失败', icon: 'none' })
}
}
onMounted(async () => {
const sys = uni.getSystemInfoSync()
const statusBarH = sys.statusBarHeight || 0
try {
const menuBtn = uni.getMenuButtonBoundingClientRect()
navBarHeight.value = menuBtn.bottom + (menuBtn.top - statusBarH)
} catch (e) {
navBarHeight.value = statusBarH + 44
}
await waitForLogin()
try {
const profileData = await profileStore.fetchProfile()
if (profileData?.profile) {
const profile = profileData.profile
formData.value = {
...formData.value,
mode: profile.mode || userStore.mode || formData.value.mode,
baseline_cigs_per_day: profile.baseline_cigs_per_day || formData.value.baseline_cigs_per_day,
smoking_years: profile.smoking_years || formData.value.smoking_years,
quit_motivations: Array.isArray(profile.quit_motivations) ? profile.quit_motivations : formData.value.quit_motivations,
smoke_motivations: Array.isArray(profile.smoke_motivations) ? profile.smoke_motivations : formData.value.smoke_motivations,
wake_up_time: profile.wake_up_time || formData.value.wake_up_time,
sleep_time: profile.sleep_time || formData.value.sleep_time,
pack_price_cent: profile.pack_price_cent || formData.value.pack_price_cent
}
if (profile.pack_price_cent) {
priceYuan.value = String((profile.pack_price_cent / 100).toFixed(2)).replace(/\.00$/, '')
}
} else if (userStore.mode) {
formData.value.mode = userStore.mode
}
} catch (e) {
console.error('loadProfileForOnboarding error:', e)
}
})
onShareAppMessage(() => {
return {
title: '戒烟助手 - 帮你定制戒烟计划',
path: 'pages/index/index'
}
})
</script>
<style scoped>
.page {
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(52, 200, 160, 0.16), transparent 30%),
radial-gradient(circle at top right, rgba(255, 255, 255, 0.92), transparent 22%),
linear-gradient(180deg, #edf2f8 0%, #f5f7fb 38%, #fbfdff 100%);
display: flex;
flex-direction: column;
}
.nav-area {
padding-left: 32rpx;
padding-right: 32rpx;
background: transparent;
}
.nav-row {
display: flex;
justify-content: center;
align-items: center;
padding: 12rpx 0;
}
.step-indicator {
font-size: 24rpx;
font-weight: 600;
color: #667085;
background-color: rgba(255, 255, 255, 0.76);
padding: 6rpx 24rpx;
border-radius: 999rpx;
}
.progress-bar {
height: 6rpx;
background-color: rgba(255, 255, 255, 0.5);
border-radius: 999rpx;
margin-top: 8rpx;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #32c59d, #1aa37a);
border-radius: 999rpx;
transition: width 0.3s ease;
}
.content {
flex: 1;
padding: 0 48rpx;
display: flex;
flex-direction: column;
justify-content: center;
}
.mode-section {
margin-bottom: 40rpx;
}
.mode-section-label {
display: block;
margin-bottom: 16rpx;
font-size: 24rpx;
font-weight: 600;
color: #667085;
}
.mode-switch {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16rpx;
}
.mode-switch-item {
padding: 24rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.8);
border: 2rpx solid rgba(255, 255, 255, 0.66);
box-shadow: 0 12rpx 28rpx rgba(15, 23, 42, 0.06);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
}
.mode-switch-item-active {
background: rgba(255, 255, 255, 0.92);
border-color: rgba(255, 255, 255, 0.78);
}
.mode-switch-title {
display: block;
font-size: 28rpx;
font-weight: 700;
color: #111827;
}
.mode-switch-desc {
display: block;
margin-top: 10rpx;
font-size: 22rpx;
line-height: 1.5;
color: #6b7280;
}
.step {
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.step-title {
font-size: 44rpx;
font-weight: 700;
color: #111827;
display: block;
margin-bottom: 16rpx;
line-height: 1.3;
}
.step-desc {
font-size: 28rpx;
color: #6B7280;
display: block;
margin-bottom: 56rpx;
}
.input-group {
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
}
.input-row {
display: flex;
align-items: center;
gap: 48rpx;
}
.input-btn {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.86);
display: flex;
align-items: center;
justify-content: center;
font-size: 48rpx;
color: #1aa37a;
border: 2rpx solid rgba(255, 255, 255, 0.72);
box-shadow: 0 10rpx 24rpx rgba(15, 23, 42, 0.06);
}
.input-value {
font-size: 96rpx;
font-weight: 700;
color: #111827;
min-width: 160rpx;
text-align: center;
}
.input-unit {
font-size: 28rpx;
color: #6B7280;
}
.options {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.options-wrap {
flex-direction: row;
flex-wrap: wrap;
}
.option {
padding: 28rpx 36rpx;
background-color: rgba(255, 255, 255, 0.84);
border-radius: 16rpx;
font-size: 30rpx;
color: #111827;
border: 2rpx solid rgba(255, 255, 255, 0.72);
box-shadow: 0 8rpx 20rpx rgba(15, 23, 42, 0.05);
}
.option-tag {
padding: 20rpx 28rpx;
border-radius: 32rpx;
}
.option-active {
background-color: rgba(255, 255, 255, 0.96);
border-color: rgba(255, 255, 255, 0.82);
color: #1a7f61;
}
.time-row {
display: flex;
gap: 32rpx;
}
.time-item { flex: 1; }
.time-label {
font-size: 26rpx;
color: #6B7280;
display: block;
margin-bottom: 12rpx;
}
.time-picker {
background-color: rgba(255, 255, 255, 0.86);
padding: 32rpx;
border-radius: 16rpx;
font-size: 40rpx;
color: #111827;
text-align: center;
border: 2rpx solid rgba(255, 255, 255, 0.72);
box-shadow: 0 8rpx 20rpx rgba(15, 23, 42, 0.05);
}
.price-input {
display: flex;
align-items: center;
background-color: rgba(255, 255, 255, 0.86);
padding: 24rpx 32rpx;
border-radius: 16rpx;
gap: 8rpx;
border: 2rpx solid rgba(255, 255, 255, 0.72);
box-shadow: 0 8rpx 20rpx rgba(15, 23, 42, 0.05);
}
.price-prefix {
font-size: 48rpx;
color: #9CA3AF;
}
.price-field {
font-size: 64rpx;
font-weight: 700;
color: #111827;
width: 200rpx;
text-align: center;
}
.footer {
display: flex;
gap: 24rpx;
padding: 32rpx 48rpx;
padding-bottom: 64rpx;
}
.btn-primary {
flex: 1;
height: 96rpx;
background: linear-gradient(180deg, #32c59d 0%, #1aa37a 100%);
border-radius: 48rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
font-weight: 600;
color: #FFFFFF;
box-shadow: 0 12rpx 28rpx rgba(26, 163, 122, 0.22);
}
.btn-full { flex: 1; }
.btn-secondary {
height: 96rpx;
padding: 0 48rpx;
background-color: rgba(255, 255, 255, 0.86);
border-radius: 48rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
color: #111827;
border: 2rpx solid rgba(15, 23, 42, 0.08);
}
</style>
+618
View File
@@ -0,0 +1,618 @@
<template>
<view class="page">
<view class="page-glow page-glow-a"></view>
<view class="page-glow page-glow-b"></view>
<view class="nav-placeholder" :style="{ height: navBarHeight + 'px' }"></view>
<view class="page-header">
<text class="header-eyebrow">Account</text>
<text class="header-title">个人中心</text>
<text class="header-subtitle">模式切换分享与基础设置</text>
</view>
<view class="user-section">
<image class="avatar" :src="userAvatar" mode="aspectFill"></image>
<view class="user-copy">
<text class="user-name">{{ userName }}</text>
<text class="user-desc">已连接戒烟记录与统计数据</text>
<view class="user-meta">
<text class="user-pill">{{ modeText }}</text>
<text class="user-pill user-pill-muted">{{ shareToken ? '分享已启用' : '分享未生成' }}</text>
</view>
</view>
</view>
<view class="section">
<view class="mode-card">
<view class="mode-card-header">
<view class="menu-icon menu-icon-accent">
<text class="menu-glyph"></text>
</view>
<view class="menu-content">
<text class="menu-label">打卡模式</text>
<text class="menu-desc">直接切换成戒烟打卡记录抽烟</text>
</view>
</view>
<view class="mode-switch">
<view
v-for="item in modeOptions"
:key="item.value"
class="mode-switch-item"
:class="{ 'mode-switch-item-active': userStore.mode === item.value }"
@tap="changeMode(item.value)"
>
<text class="mode-switch-title">{{ item.label }}</text>
<text class="mode-switch-desc">{{ item.desc }}</text>
</view>
</view>
<text class="mode-hint">当前{{ modeText }}</text>
</view>
<view class="menu-list">
<view class="menu-item">
<view class="menu-icon menu-icon-accent">
<text class="menu-glyph"></text>
</view>
<view class="menu-content">
<text class="menu-label">分享戒烟记录</text>
<text class="menu-desc">{{ shareDesc }}</text>
<view class="menu-actions">
<text class="menu-action" @tap.stop="previewSharePage">预览分享页</text>
<text class="menu-action-sep">·</text>
<text class="menu-action" @tap.stop="handleRefreshShare">刷新分享链接</text>
</view>
</view>
<button class="share-btn" open-type="share" :disabled="shareLoading || !shareToken">
{{ shareLoading ? '生成中' : '分享' }}
</button>
</view>
<view class="menu-divider"></view>
<view class="menu-item" @tap="goOnboarding">
<view class="menu-icon menu-icon-accent">
<text class="menu-glyph"></text>
</view>
<view class="menu-content">
<text class="menu-label">重新填写问卷</text>
<text class="menu-desc">修改吸烟基线与个人信息</text>
</view>
<text class="menu-arrow"></text>
</view>
</view>
</view>
<view class="section">
<view class="menu-list">
<view class="menu-item" @tap="clearCache">
<view class="menu-icon menu-icon-muted">
<text class="menu-glyph"></text>
</view>
<view class="menu-content">
<text class="menu-label">清除缓存</text>
<text class="menu-desc">仅清理本地缓存不影响云端记录</text>
</view>
<text class="menu-arrow"></text>
</view>
<view class="menu-divider"></view>
<view class="menu-item" @tap="copyInfo">
<view class="menu-icon menu-icon-muted">
<text class="menu-glyph"></text>
</view>
<view class="menu-content">
<text class="menu-label">意见反馈</text>
<text class="menu-desc">复制反馈邮箱发送使用建议或问题</text>
</view>
<text class="menu-arrow"></text>
</view>
</view>
</view>
<text class="version">版本 1.0.0</text>
</view>
</template>
<script setup>
import { computed, ref, onMounted } from 'vue'
import { onShareAppMessage, onShow } from '@dcloudio/uni-app'
import * as api from '@/api'
import { useProfileStore } from '@/stores/profile'
import { useUserStore } from '@/stores/user'
import { useLogin } from '@/hooks/useLogin'
const profileStore = useProfileStore()
const userStore = useUserStore()
const { waitForLogin } = useLogin()
const shareToken = ref('')
const shareExpireAt = ref('')
const shareLoading = ref(false)
const modeSaving = ref(false)
const navBarHeight = ref(0)
const modeOptions = [
{ value: 'quit', label: '戒烟打卡', desc: '按天记录今天没抽' },
{ value: 'record', label: '记录抽烟', desc: '按支数记录变化' }
]
const userName = computed(() => userStore.user?.nickname || '戒烟用户')
const userAvatar = computed(() => userStore.user?.avatar_url || 'https://linghu-wmr.oss-cn-beijing.aliyuncs.com/smt/avatar.png')
const modeText = computed(() => {
if (userStore.mode === 'quit') return '戒烟打卡'
if (userStore.mode === 'record') return '记录抽烟'
return '未选择'
})
const shareDesc = computed(() => {
if (!shareToken.value) {
return shareLoading.value ? '正在生成分享信息...' : '先生成分享令牌后即可分享给朋友'
}
return `有效期至 ${formatExpire(shareExpireAt.value)},仅查看权限`
})
const sharePath = computed(() => {
if (!shareToken.value) {
return 'pages/index/index'
}
return `pages/share/index?share_token=${shareToken.value}`
})
function setupNavBar() {
const systemInfo = uni.getSystemInfoSync()
const statusBarH = systemInfo.statusBarHeight || 0
try {
const menuBtn = uni.getMenuButtonBoundingClientRect()
navBarHeight.value = menuBtn.bottom + (menuBtn.top - statusBarH)
} catch (e) {
navBarHeight.value = statusBarH + 44
}
}
function formatExpire(value) {
if (!value) return '--'
const d = new Date(value)
if (Number.isNaN(d.getTime())) return value
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
return `${y}-${m}-${day} ${hh}:${mm}`
}
async function prepareShareToken(showToast = false) {
if (shareLoading.value) return
shareLoading.value = true
try {
const res = await api.createShare({ days: 7 })
shareToken.value = res.data?.share_token || ''
shareExpireAt.value = res.data?.expire_at || ''
if (showToast) {
uni.showToast({ title: '分享链接已刷新', icon: 'success' })
}
} catch (e) {
console.error('prepareShareToken error:', e)
if (showToast) {
uni.showToast({ title: '生成分享失败', icon: 'none' })
}
} finally {
shareLoading.value = false
}
}
function handleRefreshShare() {
prepareShareToken(true)
}
function previewSharePage() {
if (!shareToken.value) {
uni.showToast({ title: '分享令牌尚未生成', icon: 'none' })
return
}
uni.navigateTo({
url: `/pages/share/index?share_token=${shareToken.value}`
})
}
async function changeMode(nextMode) {
if (!nextMode || nextMode === userStore.mode || modeSaving.value) return
modeSaving.value = true
try {
uni.showLoading({ title: '切换中...' })
await profileStore.saveProfile({ mode: nextMode })
uni.hideLoading()
uni.showToast({ title: '模式已切换', icon: 'success' })
setTimeout(() => {
uni.switchTab({ url: '/pages/index/index' })
}, 250)
} catch (e) {
uni.hideLoading()
uni.showToast({ title: '切换失败', icon: 'none' })
} finally {
modeSaving.value = false
}
}
function goOnboarding() {
uni.navigateTo({ url: '/pages/onboarding/index' })
}
function clearCache() {
uni.showModal({
title: '清除缓存',
content: '将清除本地缓存数据,不会影响云端记录',
success: (res) => {
if (res.confirm) {
try {
uni.clearStorageSync()
uni.showToast({ title: '缓存已清除', icon: 'success' })
} catch (e) {
uni.showToast({ title: '清除失败', icon: 'none' })
}
}
}
})
}
function copyInfo() {
uni.setClipboardData({
data: '806669289@qq.com',
success: () => {
uni.showToast({ title: '反馈邮箱已复制', icon: 'success' })
}
})
}
onShareAppMessage(() => {
return {
title: `${userName.value}的戒烟记录(仅查看)`,
path: sharePath.value
}
})
onMounted(() => {
setupNavBar()
})
onShow(async () => {
await waitForLogin()
await profileStore.fetchProfile()
await prepareShareToken(false)
})
</script>
<style scoped>
.page {
min-height: 100vh;
position: relative;
background:
radial-gradient(circle at top left, rgba(52, 200, 160, 0.16), transparent 30%),
radial-gradient(circle at top right, rgba(255, 255, 255, 0.92), transparent 22%),
linear-gradient(180deg, #edf2f8 0%, #f5f7fb 38%, #fbfdff 100%);
padding: 0 24rpx 168rpx;
box-sizing: border-box;
overflow: hidden;
}
.page-glow {
position: absolute;
border-radius: 50%;
filter: blur(24rpx);
opacity: 0.72;
pointer-events: none;
}
.page-glow-a {
top: 100rpx;
left: -140rpx;
width: 360rpx;
height: 360rpx;
background: rgba(52, 200, 160, 0.15);
}
.page-glow-b {
top: 340rpx;
right: -120rpx;
width: 320rpx;
height: 320rpx;
background: rgba(255, 255, 255, 0.86);
}
.nav-placeholder,
.page-header,
.user-section,
.section,
.version {
position: relative;
z-index: 1;
}
.page-header {
padding: 24rpx 6rpx 18rpx;
}
.header-eyebrow {
display: block;
font-size: 20rpx;
font-weight: 700;
letter-spacing: 4rpx;
text-transform: uppercase;
color: #98a2b3;
}
.header-title {
display: block;
margin-top: 10rpx;
font-size: 42rpx;
line-height: 1.18;
font-weight: 700;
color: #111827;
}
.header-subtitle {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
line-height: 1.5;
color: #667085;
}
.user-section {
display: flex;
align-items: center;
gap: 24rpx;
padding: 12rpx 28rpx 32rpx;
margin-bottom: 24rpx;
background: rgba(255, 255, 255, 0.74);
border: 2rpx solid rgba(255, 255, 255, 0.66);
border-radius: 32rpx;
box-shadow: 0 16rpx 42rpx rgba(15, 23, 42, 0.08);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
}
.avatar {
width: 132rpx;
height: 132rpx;
border-radius: 50%;
border: 4rpx solid rgba(255, 255, 255, 0.82);
background-color: rgba(255, 255, 255, 0.7);
}
.user-copy {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.user-name {
font-size: 40rpx;
font-weight: 700;
color: #111827;
}
.user-desc {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
line-height: 1.5;
color: #667085;
}
.user-meta {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 18rpx;
}
.user-pill {
padding: 10rpx 20rpx;
border-radius: 999rpx;
background: rgba(52, 200, 160, 0.12);
border: 2rpx solid rgba(255, 255, 255, 0.64);
font-size: 22rpx;
font-weight: 600;
color: #17795c;
}
.user-pill-muted {
background: rgba(255, 255, 255, 0.78);
color: #667085;
}
.section {
margin-bottom: 24rpx;
}
.mode-card {
background: rgba(255, 255, 255, 0.76);
border-radius: 32rpx;
padding: 30rpx 24rpx;
border: 2rpx solid rgba(255, 255, 255, 0.66);
box-shadow: 0 16rpx 42rpx rgba(15, 23, 42, 0.08);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
margin-bottom: 16rpx;
}
.mode-card-header {
display: flex;
align-items: center;
gap: 24rpx;
margin-bottom: 20rpx;
}
.menu-list {
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.8);
border-radius: 32rpx;
border: 2rpx solid rgba(255, 255, 255, 0.66);
box-shadow: 0 16rpx 42rpx rgba(15, 23, 42, 0.08);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
overflow: hidden;
}
.menu-item {
display: flex;
align-items: center;
gap: 24rpx;
padding: 28rpx 24rpx;
}
.menu-divider {
margin: 0 24rpx;
height: 2rpx;
background: rgba(15, 23, 42, 0.06);
}
.menu-icon {
width: 64rpx;
height: 64rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
border: 2rpx solid rgba(255, 255, 255, 0.7);
}
.menu-icon-accent {
background: rgba(52, 200, 160, 0.14);
}
.menu-icon-muted {
background: rgba(255, 255, 255, 0.82);
}
.menu-glyph {
font-size: 24rpx;
font-weight: 700;
color: #111827;
}
.menu-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rpx;
}
.menu-label {
font-size: 30rpx;
color: #111827;
font-weight: 600;
}
.menu-desc {
font-size: 24rpx;
line-height: 1.5;
color: #667085;
}
.menu-actions {
margin-top: 6rpx;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8rpx;
font-size: 24rpx;
color: #1aa37a;
}
.menu-action {
color: #1aa37a;
}
.menu-action-sep {
color: #98a2b3;
}
.menu-arrow {
font-size: 36rpx;
color: #98a2b3;
}
.menu-value {
font-size: 24rpx;
font-weight: 600;
color: #1aa37a;
}
.mode-switch {
display: flex;
gap: 12rpx;
padding: 8rpx;
border-radius: 24rpx;
background: rgba(247, 249, 252, 0.92);
border: 2rpx solid rgba(15, 23, 42, 0.05);
}
.mode-switch-item {
flex: 1;
padding: 22rpx 18rpx;
border-radius: 20rpx;
background: transparent;
border: 2rpx solid transparent;
}
.mode-switch-item-active {
background: rgba(255, 255, 255, 0.94);
border-color: rgba(255, 255, 255, 0.76);
box-shadow: 0 8rpx 18rpx rgba(15, 23, 42, 0.06);
}
.mode-switch-title {
display: block;
font-size: 28rpx;
font-weight: 700;
color: #111827;
}
.mode-switch-desc {
display: block;
margin-top: 8rpx;
font-size: 22rpx;
line-height: 1.5;
color: #667085;
}
.mode-hint {
display: block;
margin-top: 16rpx;
font-size: 22rpx;
color: #1aa37a;
}
.share-btn {
margin: 0;
padding: 12rpx 22rpx;
line-height: 1.4;
font-size: 24rpx;
border: none;
border-radius: 999rpx;
color: #FFFFFF;
background: linear-gradient(180deg, #32c59d 0%, #1aa37a 100%);
box-shadow: 0 12rpx 28rpx rgba(26, 163, 122, 0.2);
}
.share-btn[disabled] {
background: #98a2b3;
color: #FFFFFF;
}
.share-btn::after {
border: none;
}
.version {
display: block;
text-align: center;
font-size: 22rpx;
color: #98a2b3;
margin-top: 32rpx;
}
</style>
+848
View File
@@ -0,0 +1,848 @@
<template>
<view class="page">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="container">
<view v-if="pageLoading" class="skeleton">
<view class="skeleton-card"></view>
<view class="skeleton-card"></view>
<view class="skeleton-list">
<view v-for="i in 3" :key="i" class="skeleton-row"></view>
</view>
</view>
<view v-else>
<!-- 无计划状态 -->
<view v-if="!planData" class="no-plan-card">
<view class="no-plan-icon"></view>
<text class="no-plan-title">暂无戒烟计划</text>
<text class="no-plan-desc">生成专属30天戒烟计划按阶段轻松戒烟</text>
<view class="generate-btn" @tap="handleGenerate">
<text class="generate-btn-text">生成戒烟计划</text>
</view>
</view>
<!-- 有计划状态 -->
<view v-else>
<!-- 计划总览卡片 -->
<view class="stage-card">
<view class="stage-badge"> {{ currentDay }}/30 </view>
<text class="stage-label">戒烟计划进度</text>
<text class="stage-name">{{ stageName }}</text>
<text class="stage-days">{{ stageDesc }}</text>
<view class="stage-progress-row">
<text class="stage-progress-label">计划进度</text>
<text class="stage-progress-value">{{ Math.round(planProgress * 100) }}%</text>
</view>
<view class="stage-progress-bar">
<view class="stage-progress-fill" :style="{ width: planProgress * 100 + '%' }"></view>
</view>
</view>
<!-- 阶段说明卡片 -->
<view class="section">
<view class="section-header">
<text class="section-title">当前阶段</text>
</view>
<view class="stage-info-card">
<view class="stage-item" :class="{ 'stage-item-active': planData.current_stage === 'recording' }">
<view class="stage-number">1</view>
<view class="stage-content">
<text class="stage-item-title">记录期</text>
<text class="stage-item-desc">记录每日吸烟情况了解习惯</text>
</view>
</view>
<view class="stage-line"></view>
<view class="stage-item" :class="{ 'stage-item-active': planData.current_stage === 'reducing' }">
<view class="stage-number">2</view>
<view class="stage-content">
<text class="stage-item-title">减量期</text>
<text class="stage-item-desc">逐步减少吸烟数量</text>
</view>
</view>
<view class="stage-line"></view>
<view class="stage-item" :class="{ 'stage-item-active': planData.current_stage === 'consolidating' }">
<view class="stage-number">3</view>
<view class="stage-content">
<text class="stage-item-title">巩固期</text>
<text class="stage-item-desc">保持成果彻底戒烟</text>
</view>
</view>
</view>
</view>
<!-- 每日目标和建议 -->
<view class="section">
<view class="section-header">
<text class="section-title">每日目标</text>
<text class="section-badge">{{ todayTarget }}</text>
</view>
<view class="daily-tips-card">
<text class="daily-tips-title">今日建议</text>
<text class="daily-tips-text">{{ dailyTip }}</text>
</view>
</view>
<!-- 每日计划列表 -->
<view class="section">
<view class="section-header">
<text class="section-title">每日计划详情</text>
</view>
<view v-if="daysLoading" class="days-loading">
<text class="days-loading-text">加载中...</text>
</view>
<view v-else-if="daysList.length > 0" class="days-list">
<view
v-for="day in daysList"
:key="day.day"
class="day-item"
:class="{ 'day-item-today': day.isToday, 'day-item-past': day.isPast }"
@tap="showDayDetail(day)"
>
<view class="day-header">
<text class="day-number"> {{ day.day }} </text>
<text v-if="day.isToday" class="day-today-badge">今天</text>
<text v-else-if="day.isPast" class="day-past-badge">已完成</text>
</view>
<view class="day-target">
<text class="day-target-label">目标:</text>
<text class="day-target-value">{{ day.target_cigs }} </text>
</view>
<view v-if="day.tip" class="day-tip">
<text class="day-tip-text">{{ day.tip }}</text>
</view>
</view>
</view>
<view v-else class="days-empty">
<text class="days-empty-text">暂无计划详情</text>
</view>
</view>
<!-- 操作按钮 -->
<view class="actions">
<view class="reset-btn" @tap="handleReset">
<text class="reset-btn-text">重置计划</text>
</view>
</view>
</view>
</view>
</view>
<!-- 每日详情弹窗 -->
<view v-if="showDayModal" class="modal-mask" @tap="closeDayModal">
<view class="modal-content" @tap.stop>
<view class="modal-header">
<text class="modal-title"> {{ selectedDay.day }} 天计划</text>
<text class="modal-close" @tap="closeDayModal">×</text>
</view>
<view class="modal-body">
<view class="modal-item">
<text class="modal-label">目标吸烟量</text>
<text class="modal-value">{{ selectedDay.target_cigs }} </text>
</view>
<view v-if="selectedDay.tip" class="modal-item">
<text class="modal-label">建议</text>
<text class="modal-value">{{ selectedDay.tip }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onShareAppMessage } from '@dcloudio/uni-app'
import { useLogin } from '@/hooks/useLogin'
import * as api from '@/api'
const { waitForLogin } = useLogin()
const statusBarHeight = ref(0)
const pageLoading = ref(true)
const daysLoading = ref(false)
const generating = ref(false)
const planData = ref(null)
const daysList = ref([])
const showDayModal = ref(false)
const selectedDay = ref({})
// 阶段名称映射
const stageNames = {
recording: '记录期',
reducing: '减量期',
consolidating: '巩固期'
}
const stageDescs = {
recording: '记录每日吸烟情况,了解您的吸烟习惯',
reducing: '按计划逐步减少吸烟数量',
consolidating: '保持戒烟成果,彻底摆脱烟瘾'
}
// 计算当前是第几天
const currentDay = computed(() => {
if (!planData.value?.plan_start_date) return 1
const start = new Date(planData.value.plan_start_date)
const now = new Date()
const diff = Math.floor((now - start) / (24 * 60 * 60 * 1000))
return Math.min(Math.max(diff + 1, 1), 30)
})
// 计划进度
const planProgress = computed(() => {
return currentDay.value / 30
})
// 当前阶段名称
const stageName = computed(() => {
if (!planData.value) return ''
return stageNames[planData.value.current_stage] || '记录期'
})
// 阶段描述
const stageDesc = computed(() => {
if (!planData.value) return ''
return stageDescs[planData.value.current_stage] || ''
})
// 今日目标
const todayTarget = computed(() => {
const today = daysList.value.find(d => d.isToday)
return today ? `${today.target_cigs}` : '--'
})
// 每日建议
const dailyTip = computed(() => {
const today = daysList.value.find(d => d.isToday)
return today?.tip || '按计划执行,保持决心!'
})
// 获取戒烟计划
async function fetchQuitPlan() {
try {
const res = await api.getQuitPlan()
planData.value = res?.data || null
if (planData.value?.id) {
await fetchDays()
}
} catch (e) {
console.error('fetchQuitPlan error:', e)
planData.value = null
}
}
// 获取每日计划
async function fetchDays() {
if (!planData.value?.id) return
daysLoading.value = true
try {
const res = await api.getQuitPlanDays(planData.value.id)
const days = res?.data || []
// 计算今天的日期
const today = new Date()
today.setHours(0, 0, 0, 0)
daysList.value = days.map(day => {
const dayDate = new Date(planData.value.plan_start_date)
dayDate.setDate(dayDate.getDate() + day.day - 1)
dayDate.setHours(0, 0, 0, 0)
return {
...day,
isToday: dayDate.getTime() === today.getTime(),
isPast: dayDate.getTime() < today.getTime()
}
})
} catch (e) {
console.error('fetchDays error:', e)
daysList.value = []
} finally {
daysLoading.value = false
}
}
// 生成计划
async function handleGenerate() {
if (generating.value) return
generating.value = true
try {
await api.generateQuitPlan()
uni.showToast({
title: '计划生成成功',
icon: 'success'
})
await fetchQuitPlan()
} catch (e) {
uni.showToast({
title: e?.message || '生成失败',
icon: 'none'
})
} finally {
generating.value = false
}
}
// 重置计划
function handleReset() {
uni.showModal({
title: '确认重置',
content: '重置后将清除当前计划,重新开始,确定要重置吗?',
success: async (res) => {
if (res.confirm) {
try {
await api.resetQuitPlan()
uni.showToast({
title: '计划已重置',
icon: 'success'
})
planData.value = null
daysList.value = []
} catch (e) {
uni.showToast({
title: e?.message || '重置失败',
icon: 'none'
})
}
}
}
})
}
// 显示每日详情
function showDayDetail(day) {
selectedDay.value = day
showDayModal.value = true
}
// 关闭弹窗
function closeDayModal() {
showDayModal.value = false
}
// 初始化页面
async function initPage() {
pageLoading.value = true
try {
const sys = uni.getSystemInfoSync()
statusBarHeight.value = sys.statusBarHeight || 0
await waitForLogin()
await fetchQuitPlan()
} catch (e) {
console.error('initPage error:', e)
} finally {
pageLoading.value = false
}
}
onMounted(() => {
initPage()
})
onShareAppMessage(() => {
return {
title: '戒烟助手 - 30天戒烟计划',
path: 'pages/index/index'
}
})
</script>
<style scoped>
.page {
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(52, 200, 160, 0.16), transparent 30%),
radial-gradient(circle at top right, rgba(255, 255, 255, 0.92), transparent 24%),
linear-gradient(180deg, #edf2f8 0%, #f5f7fb 38%, #fbfdff 100%);
box-sizing: border-box;
}
.status-bar {
background: transparent;
}
.container {
padding: 24rpx 32rpx 180rpx;
}
.skeleton {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.skeleton-card,
.skeleton-row {
background: linear-gradient(90deg, #E5E7EB 25%, #F3F4F6 50%, #E5E7EB 75%);
background-size: 200% 100%;
animation: shimmer 1.6s infinite;
}
.skeleton-card {
height: 260rpx;
border-radius: 24rpx;
}
.skeleton-list {
padding: 24rpx;
background-color: #FFFFFF;
border-radius: 24rpx;
}
.skeleton-row {
height: 92rpx;
border-radius: 18rpx;
margin-bottom: 16rpx;
}
.skeleton-row:last-child {
margin-bottom: 0;
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
/* 无计划状态 */
.no-plan-card {
background: rgba(255, 255, 255, 0.82);
border-radius: 32rpx;
padding: 60rpx 40rpx;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 16rpx 36rpx rgba(15, 23, 42, 0.06);
border: 2rpx solid rgba(255, 255, 255, 0.66);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
}
.no-plan-icon {
width: 104rpx;
height: 104rpx;
border-radius: 32rpx;
background: rgba(247, 249, 252, 0.92);
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
font-weight: 700;
color: #1a7f61;
margin-bottom: 24rpx;
}
.no-plan-title {
font-size: 36rpx;
font-weight: 700;
color: #111827;
margin-bottom: 16rpx;
}
.no-plan-desc {
font-size: 26rpx;
color: #6B7280;
text-align: center;
margin-bottom: 40rpx;
line-height: 1.6;
}
.generate-btn {
background: linear-gradient(180deg, #32c59d 0%, #1aa37a 100%);
padding: 24rpx 60rpx;
border-radius: 48rpx;
box-shadow: 0 8rpx 20rpx rgba(16, 185, 129, 0.3);
}
.generate-btn-text {
font-size: 32rpx;
font-weight: 600;
color: #FFFFFF;
}
/* 阶段卡片 */
.stage-card {
background: rgba(255, 255, 255, 0.82);
border-radius: 32rpx;
padding: 32rpx;
margin-bottom: 32rpx;
position: relative;
box-shadow: 0 16rpx 36rpx rgba(15, 23, 42, 0.06);
border: 2rpx solid rgba(255, 255, 255, 0.66);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
}
.stage-badge {
position: absolute;
top: 24rpx;
right: 24rpx;
background-color: #1aa37a;
color: #FFFFFF;
padding: 8rpx 20rpx;
border-radius: 20rpx;
font-size: 24rpx;
font-weight: 600;
}
.stage-label {
font-size: 24rpx;
color: #059669;
display: block;
margin-bottom: 8rpx;
}
.stage-name {
font-size: 42rpx;
font-weight: 700;
color: #111827;
display: block;
margin-bottom: 8rpx;
}
.stage-days {
font-size: 24rpx;
color: #6B7280;
display: block;
margin-bottom: 24rpx;
}
.stage-progress-row {
display: flex;
justify-content: space-between;
margin-bottom: 12rpx;
}
.stage-progress-label {
font-size: 24rpx;
color: #6B7280;
}
.stage-progress-value {
font-size: 24rpx;
font-weight: 600;
color: #10B981;
}
.stage-progress-bar {
height: 12rpx;
background-color: #E5E7EB;
border-radius: 6rpx;
overflow: hidden;
}
.stage-progress-fill {
height: 100%;
background: linear-gradient(90deg, #32c59d, #1aa37a);
border-radius: 6rpx;
}
/* 阶段信息 */
.section {
margin-bottom: 32rpx;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 600;
color: #111827;
}
.section-badge {
font-size: 24rpx;
color: #059669;
background-color: #ECFDF3;
padding: 8rpx 16rpx;
border-radius: 16rpx;
}
.stage-info-card {
background-color: rgba(255, 255, 255, 0.82);
border-radius: 28rpx;
padding: 28rpx;
border: 2rpx solid rgba(255, 255, 255, 0.66);
box-shadow: 0 14rpx 30rpx rgba(15, 23, 42, 0.05);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
}
.stage-item {
display: flex;
align-items: flex-start;
gap: 20rpx;
}
.stage-item-active .stage-number {
background-color: #10B981;
color: #FFFFFF;
border-color: #10B981;
}
.stage-item-active .stage-item-title {
color: #10B981;
}
.stage-number {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
border: 3rpx solid #D1D5DB;
display: flex;
align-items: center;
justify-content: center;
font-size: 26rpx;
font-weight: 600;
color: #6B7280;
flex-shrink: 0;
}
.stage-content {
flex: 1;
}
.stage-item-title {
font-size: 28rpx;
font-weight: 600;
color: #111827;
display: block;
margin-bottom: 6rpx;
}
.stage-item-desc {
font-size: 24rpx;
color: #6B7280;
line-height: 1.4;
}
.stage-line {
width: 2rpx;
height: 32rpx;
background-color: #E5E7EB;
margin: 16rpx 0 16rpx 22rpx;
}
/* 每日目标 */
.daily-tips-card {
background-color: rgba(255, 255, 255, 0.82);
border-radius: 28rpx;
padding: 28rpx;
border: 2rpx solid rgba(255, 255, 255, 0.66);
box-shadow: 0 14rpx 30rpx rgba(15, 23, 42, 0.05);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
}
.daily-tips-title {
font-size: 26rpx;
font-weight: 600;
color: #059669;
display: block;
margin-bottom: 12rpx;
}
.daily-tips-text {
font-size: 28rpx;
color: #111827;
line-height: 1.6;
}
/* 每日计划列表 */
.days-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.day-item {
background-color: rgba(255, 255, 255, 0.82);
border-radius: 24rpx;
padding: 24rpx;
border: 2rpx solid rgba(255, 255, 255, 0.66);
box-shadow: 0 12rpx 24rpx rgba(15, 23, 42, 0.05);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
}
.day-item-today {
border-color: rgba(255, 255, 255, 0.82);
background: linear-gradient(135deg, rgba(245, 255, 251, 0.95), rgba(255, 255, 255, 0.88));
}
.day-item-past {
opacity: 0.7;
}
.day-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.day-number {
font-size: 28rpx;
font-weight: 600;
color: #111827;
}
.day-today-badge {
font-size: 22rpx;
color: #FFFFFF;
background-color: #10B981;
padding: 4rpx 12rpx;
border-radius: 12rpx;
}
.day-past-badge {
font-size: 22rpx;
color: #6B7280;
background-color: #F3F4F6;
padding: 4rpx 12rpx;
border-radius: 12rpx;
}
.day-target {
display: flex;
align-items: center;
gap: 8rpx;
margin-bottom: 8rpx;
}
.day-target-label {
font-size: 24rpx;
color: #6B7280;
}
.day-target-value {
font-size: 28rpx;
font-weight: 600;
color: #10B981;
}
.day-tip {
padding-top: 12rpx;
border-top: 1rpx solid #F3F4F6;
}
.day-tip-text {
font-size: 24rpx;
color: #6B7280;
line-height: 1.4;
}
.days-loading,
.days-empty {
background-color: rgba(255, 255, 255, 0.82);
border-radius: 28rpx;
padding: 40rpx;
text-align: center;
}
.days-loading-text,
.days-empty-text {
font-size: 26rpx;
color: #6B7280;
}
/* 操作按钮 */
.actions {
margin-top: 32rpx;
padding-bottom: 40rpx;
}
.reset-btn {
background-color: rgba(255, 255, 255, 0.86);
border: 2rpx solid #EF4444;
padding: 24rpx;
border-radius: 24rpx;
text-align: center;
}
.reset-btn-text {
font-size: 28rpx;
color: #EF4444;
}
/* 弹窗 */
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(15, 23, 42, 0.26);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background-color: rgba(248, 250, 252, 0.92);
border-radius: 28rpx;
width: 600rpx;
max-height: 70vh;
overflow: hidden;
border: 2rpx solid rgba(255, 255, 255, 0.72);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx 28rpx 24rpx;
border-bottom: 1rpx solid #F3F4F6;
}
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: #111827;
}
.modal-close {
font-size: 40rpx;
color: #9CA3AF;
line-height: 1;
}
.modal-body {
padding: 28rpx;
}
.modal-item {
margin-bottom: 24rpx;
}
.modal-item:last-child {
margin-bottom: 0;
}
.modal-label {
font-size: 24rpx;
color: #6B7280;
display: block;
margin-bottom: 8rpx;
}
.modal-value {
font-size: 28rpx;
color: #111827;
line-height: 1.5;
}
</style>
+597
View File
@@ -0,0 +1,597 @@
<template>
<view class="page">
<view v-if="loading" class="state-wrap">
<text class="state-text">加载分享数据中...</text>
</view>
<view v-else-if="errorText" class="state-wrap">
<text class="state-text">{{ errorText }}</text>
<button class="retry-btn" @tap="reload">重新加载</button>
</view>
<view v-else>
<view class="owner-card">
<image class="avatar" :src="owner.avatar_url || defaultAvatar" mode="aspectFill"></image>
<view class="owner-main">
<text class="owner-name">{{ owner.nickname || '戒烟用户' }}</text>
<text class="owner-desc">分享了自己的戒烟记录只读</text>
</view>
</view>
<view class="overview-grid">
<view class="overview-item">
<text class="overview-label">今日吸烟</text>
<text class="overview-value">{{ overview.today_count || 0 }}</text>
</view>
<view class="overview-item">
<text class="overview-label">今日忍住</text>
<text class="overview-value">{{ overview.resisted_count || 0 }}</text>
</view>
<view class="overview-item">
<text class="overview-label">连续记录</text>
<text class="overview-value">{{ overview.streak_days || 0 }}</text>
</view>
<view class="overview-item">
<text class="overview-label">较昨日变化</text>
<text class="overview-value" :class="overview.exceeded_yesterday ? 'warning' : 'success'">
{{ overview.exceeded_yesterday ? '+' : '-' }}{{ overview.reduced_from_yesterday || 0 }}
</text>
</view>
</view>
<view class="section">
<view class="section-header">
<text class="section-title">统计报表</text>
<view class="range-tabs">
<view
v-for="item in rangeTabs"
:key="item.value"
class="range-item"
:class="{ active: range === item.value }"
@tap="switchRange(item.value)"
>
{{ item.label }}
</view>
</view>
</view>
<view class="stats-row">
<view class="stats-block">
<text class="stats-key">日均支数</text>
<text class="stats-val">{{ stats.daily_average || 0 }}</text>
</view>
<view class="stats-block">
<text class="stats-key">变化幅度</text>
<text class="stats-val">{{ stats.change_percent || 0 }}%</text>
</view>
<view class="stats-block">
<text class="stats-key">范围忍住</text>
<text class="stats-val">{{ stats.resisted_total || 0 }}</text>
</view>
</view>
<view class="trend-wrap">
<view v-for="(item, index) in stats.trend || []" :key="index" class="trend-item">
<text class="trend-label">{{ item.label }}</text>
<view class="trend-bar-bg">
<view class="trend-bar" :style="{ width: trendWidth(item.count) + '%' }"></view>
</view>
<text class="trend-count">{{ item.count }}</text>
</view>
</view>
</view>
<view class="section">
<view class="section-header">
<text class="section-title">记录详情只读</text>
<view class="range-tabs">
<view
v-for="item in logTypeTabs"
:key="item.value"
class="range-item"
:class="{ active: logType === item.value }"
@tap="switchLogType(item.value)"
>
{{ item.label }}
</view>
</view>
</view>
<view v-if="logs.length === 0" class="empty-box">
<text class="empty-text">暂无记录</text>
</view>
<view v-else class="log-list">
<view v-for="item in logs" :key="item.id" class="log-item" @tap="showDetail(item)">
<view class="log-top">
<text class="log-time">{{ displayTime(item) }}</text>
<text class="log-type" :class="resolveType(item) === 'resisted' ? 'resisted' : 'smoke'">
{{ resolveType(item) === 'resisted' ? '已忍住' : '已抽烟' }}
</text>
</view>
<view class="log-meta">
<text>数量{{ item.num ?? 0 }}</text>
<text>强度{{ levelLabel(item.level) }}</text>
</view>
<text class="log-remark">{{ (item.remark && String(item.remark).trim()) || '无备注' }}</text>
</view>
</view>
<button
v-if="logs.length < total"
class="load-more"
:disabled="loadingMore"
@tap="loadMore"
>
{{ loadingMore ? '加载中...' : '加载更多' }}
</button>
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onLoad, onShareAppMessage } from '@dcloudio/uni-app'
import { getShareData } from '@/api'
const defaultAvatar = 'https://linghu-wmr.oss-cn-beijing.aliyuncs.com/smt/avatar.png'
const loading = ref(true)
const loadingMore = ref(false)
const errorText = ref('')
const shareToken = ref('')
const range = ref('week')
const logType = ref('all')
const owner = ref({})
const overview = ref({})
const stats = ref({})
const logs = ref([])
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const rangeTabs = [
{ label: '周', value: 'week' },
{ label: '月', value: 'month' },
{ label: '年', value: 'year' }
]
const logTypeTabs = [
{ label: '全部', value: 'all' },
{ label: '已抽烟', value: 'smoke' },
{ label: '已忍住', value: 'resisted' }
]
const maxTrend = computed(() => {
const values = (stats.value?.trend || []).map((item) => Number(item.count) || 0)
const max = Math.max(...values, 0)
return max <= 0 ? 1 : max
})
function trendWidth(count) {
const n = Number(count) || 0
return Math.max(8, Math.round((n / maxTrend.value) * 100))
}
function resolveType(item) {
if ((item?.level || 0) === 0 && (item?.num || 0) === 0) {
return 'resisted'
}
return 'smoke'
}
function levelLabel(level) {
const value = Number(level)
if (Number.isNaN(value)) return '未知'
if (value <= 1) return '轻微'
if (value === 2) return '中等'
if (value === 3) return '明显'
if (value === 4) return '强烈'
return '极强'
}
function displayTime(item) {
if (item?.smoke_at) {
return String(item.smoke_at).replace('T', ' ').slice(0, 19)
}
if (item?.smoke_time) {
return String(item.smoke_time).slice(0, 10)
}
if (item?.createtime) {
const ts = Number(item.createtime)
if (!Number.isNaN(ts) && ts > 0) {
const d = new Date(ts * 1000)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const h = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
const s = String(d.getSeconds()).padStart(2, '0')
return `${y}-${m}-${day} ${h}:${mm}:${s}`
}
}
return '--'
}
async function fetchShare(resetLogs = false) {
if (!shareToken.value) {
errorText.value = '分享参数缺失'
loading.value = false
return
}
if (resetLogs) {
page.value = 1
logs.value = []
}
const params = {
range: range.value,
type: logType.value,
page: page.value,
page_size: pageSize.value
}
try {
const res = await getShareData(shareToken.value, params)
const payload = res.data || {}
owner.value = payload.owner || {}
overview.value = payload.overview || {}
stats.value = payload.stats || {}
const logPayload = payload.logs || {}
const items = logPayload.items || []
if (resetLogs) {
logs.value = items
} else {
logs.value = [...logs.value, ...items]
}
total.value = Number(logPayload.total || 0)
page.value = Number(logPayload.page || page.value)
} catch (e) {
console.error('fetchShare error:', e)
errorText.value = e?.message || '分享已失效或不可访问'
} finally {
loading.value = false
loadingMore.value = false
}
}
async function switchRange(next) {
if (range.value === next) return
range.value = next
loading.value = true
errorText.value = ''
await fetchShare(true)
}
async function switchLogType(next) {
if (logType.value === next) return
logType.value = next
loading.value = true
errorText.value = ''
await fetchShare(true)
}
async function loadMore() {
if (loadingMore.value || logs.value.length >= total.value) return
loadingMore.value = true
page.value += 1
await fetchShare(false)
}
function showDetail(item) {
uni.showModal({
title: '记录详情',
showCancel: false,
content: [
`时间:${displayTime(item)}`,
`类型:${resolveType(item) === 'resisted' ? '已忍住' : '已抽烟'}`,
`数量:${item.num ?? 0}`,
`强度:${levelLabel(item.level)}`,
`备注:${(item.remark && String(item.remark).trim()) || '无备注'}`
].join('\n')
})
}
async function reload() {
loading.value = true
errorText.value = ''
await fetchShare(true)
}
onLoad(async (options) => {
shareToken.value = String(options?.share_token || '').trim()
await fetchShare(true)
})
onShareAppMessage(() => {
return {
title: '戒烟助手 - 查看我的戒烟记录',
path: shareToken.value
? `pages/share/index?share_token=${shareToken.value}`
: 'pages/index/index'
}
})
</script>
<style scoped>
.page {
min-height: 100vh;
padding: 24rpx;
box-sizing: border-box;
background:
radial-gradient(circle at top left, rgba(52, 200, 160, 0.14), transparent 28%),
linear-gradient(180deg, #edf2f8 0%, #f5f7fb 38%, #fbfdff 100%);
}
.state-wrap {
padding: 120rpx 40rpx;
text-align: center;
}
.state-text {
font-size: 28rpx;
color: #6b7280;
}
.retry-btn {
margin-top: 24rpx;
font-size: 26rpx;
background: linear-gradient(180deg, #32c59d 0%, #1aa37a 100%);
color: #fff;
border: none;
border-radius: 16rpx;
}
.owner-card {
display: flex;
align-items: center;
gap: 20rpx;
padding: 24rpx;
background: rgba(255, 255, 255, 0.82);
border-radius: 28rpx;
border: 2rpx solid rgba(255, 255, 255, 0.68);
box-shadow: 0 16rpx 36rpx rgba(15, 23, 42, 0.06);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
}
.avatar {
width: 90rpx;
height: 90rpx;
border-radius: 50%;
background: #e5e7eb;
}
.owner-main {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.owner-name {
font-size: 32rpx;
font-weight: 700;
color: #111827;
}
.owner-desc {
font-size: 24rpx;
color: #6b7280;
}
.overview-grid {
margin-top: 20rpx;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
}
.overview-item {
padding: 20rpx;
background: rgba(255, 255, 255, 0.82);
border-radius: 24rpx;
border: 2rpx solid rgba(255, 255, 255, 0.68);
box-shadow: 0 12rpx 28rpx rgba(15, 23, 42, 0.05);
}
.overview-label {
font-size: 22rpx;
color: #6b7280;
}
.overview-value {
margin-top: 10rpx;
display: block;
font-size: 34rpx;
font-weight: 700;
color: #111827;
}
.overview-value.success {
color: #10b981;
}
.overview-value.warning {
color: #ef4444;
}
.section {
margin-top: 20rpx;
padding: 20rpx;
background: rgba(255, 255, 255, 0.82);
border-radius: 28rpx;
border: 2rpx solid rgba(255, 255, 255, 0.68);
box-shadow: 0 16rpx 36rpx rgba(15, 23, 42, 0.06);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 700;
color: #111827;
}
.range-tabs {
display: flex;
gap: 8rpx;
}
.range-item {
padding: 8rpx 18rpx;
font-size: 22rpx;
color: #6b7280;
border-radius: 999rpx;
background: #f3f4f6;
}
.range-item.active {
background: rgba(255, 255, 255, 0.92);
color: #111827;
box-shadow: 0 8rpx 18rpx rgba(15, 23, 42, 0.06);
}
.stats-row {
margin-top: 18rpx;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12rpx;
}
.stats-block {
padding: 14rpx;
border-radius: 18rpx;
background: rgba(247, 249, 252, 0.92);
}
.stats-key {
font-size: 20rpx;
color: #6b7280;
}
.stats-val {
display: block;
margin-top: 8rpx;
font-size: 30rpx;
font-weight: 700;
color: #111827;
}
.trend-wrap {
margin-top: 18rpx;
display: flex;
flex-direction: column;
gap: 10rpx;
}
.trend-item {
display: grid;
grid-template-columns: 1.5fr 3fr 0.8fr;
align-items: center;
gap: 10rpx;
}
.trend-label,
.trend-count {
font-size: 22rpx;
color: #374151;
}
.trend-bar-bg {
height: 16rpx;
border-radius: 999rpx;
background: #e5e7eb;
overflow: hidden;
}
.trend-bar {
height: 100%;
background: linear-gradient(90deg, #34d399, #10b981);
}
.empty-box {
padding: 40rpx 0;
text-align: center;
}
.empty-text {
font-size: 24rpx;
color: #6b7280;
}
.log-list {
margin-top: 14rpx;
display: flex;
flex-direction: column;
gap: 14rpx;
}
.log-item {
padding: 18rpx;
border-radius: 20rpx;
background: rgba(247, 249, 252, 0.92);
}
.log-top {
display: flex;
justify-content: space-between;
align-items: center;
}
.log-time {
font-size: 24rpx;
font-weight: 600;
color: #111827;
}
.log-type {
font-size: 20rpx;
padding: 6rpx 12rpx;
border-radius: 999rpx;
}
.log-type.smoke {
background: #ffedd5;
color: #c2410c;
}
.log-type.resisted {
background: #dcfce7;
color: #047857;
}
.log-meta {
margin-top: 10rpx;
display: flex;
gap: 18rpx;
font-size: 22rpx;
color: #4b5563;
}
.log-remark {
margin-top: 8rpx;
font-size: 22rpx;
color: #6b7280;
}
.load-more {
margin-top: 16rpx;
font-size: 24rpx;
border-radius: 14rpx;
border: none;
background: rgba(255, 255, 255, 0.88);
color: #1a7f61;
}
</style>
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 902 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 912 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

+72
View File
@@ -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
}
}
})
+10
View File
@@ -0,0 +1,10 @@
import { createPinia } from 'pinia'
const pinia = createPinia()
export default pinia
export * from './user'
export * from './dashboard'
export * from './profile'
export * from './logs'
+315
View File
@@ -0,0 +1,315 @@
import { defineStore } from 'pinia'
import * as api from '@/api'
export const useLogsStore = defineStore('logs', {
state: () => ({
logs: [], // 记录列表
total: 0, // 总条数
page: 1, // 当前页
pageSize: 20, // 每页数量
hasMore: true, // 是否有更多
loading: false, // 加载状态
refreshing: false, // 刷新状态
queryType: 'all' // 当前筛选类型
}),
getters: {
// 按日期分组
groupedByDate: (state) => {
const groups = {}
state.logs.forEach(log => {
const date = log.smoke_time?.split('T')[0] || ''
if (!groups[date]) {
groups[date] = []
}
groups[date].push(log)
})
return groups
},
// 抽烟记录数量
smokeCount: (state) => {
return state.logs.filter(log => normalizeLogType(log) === 'smoke').length
},
// 忍住记录数量
resistedCount: (state) => {
return state.logs.filter(log => normalizeLogType(log) === 'resisted').length
},
// 格式化记录列表(按时间倒序,最新的在前)
formattedLogs: (state) => {
if (!state.logs || state.logs.length === 0) {
return []
}
// 获取时间戳的辅助函数(统一处理 smoke_at / smoke_time / createtime
const getTime = (log) => {
if (log.smoke_at) {
return new Date(log.smoke_at).getTime()
}
if (log.smoke_time) {
return new Date(log.smoke_time).getTime()
}
if (log.createtime) {
return typeof log.createtime === 'number'
? log.createtime * 1000
: new Date(log.createtime).getTime()
}
return 0
}
// 先按时间正序(最早在前)计算「距上次抽烟」的时间间隔,
// 再按时间倒序用于页面展示,保证间隔只和上一次「抽烟」记录有关
const logsAsc = [...state.logs].sort((a, b) => {
const timeA = getTime(a)
const timeB = getTime(b)
return timeA - timeB
})
const intervalById = new Map()
let lastSmokeTime = null
logsAsc.forEach((log) => {
const type = normalizeLogType(log)
const currentTime = getTime(log)
let interval = ''
// 已存在「上次抽烟」时间,计算与其的间隔
if (lastSmokeTime !== null && currentTime > lastSmokeTime) {
const diff = currentTime - lastSmokeTime
const hours = Math.floor(diff / (1000 * 60 * 60))
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
if (hours > 0) {
interval = `${hours}小时${minutes}`
} else if (minutes > 0) {
interval = `${minutes}分钟`
} else {
interval = '刚刚'
}
}
intervalById.set(log.id, interval)
// 仅当当前记录是「抽烟」时,更新「上次抽烟时间」
if (type === 'smoke' && currentTime > 0) {
lastSmokeTime = currentTime
}
})
// 再按时间倒序排序用于展示
const sortedLogs = [...state.logs].sort((a, b) => {
const timeA = getTime(a)
const timeB = getTime(b)
return timeB - timeA // 倒序:最新的在前
})
return sortedLogs.map((log) => {
const type = normalizeLogType(log)
const interval = intervalById.get(log.id) || ''
// 获取显示日期(用本地日期,避免 UTC 导致差一天)
let displayDate = ''
if (log.smoke_time) {
displayDate = log.smoke_time.split('T')[0]
} else if (log.createtime) {
const date = typeof log.createtime === 'number'
? new Date(log.createtime * 1000)
: new Date(log.createtime)
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
displayDate = `${y}-${m}-${d}`
}
return {
...log,
type,
interval,
displayTime: formatLogTime(log.smoke_at || log.smoke_time || log.createtime),
displayDate
}
})
}
},
actions: {
// 获取记录列表
async fetchLogs(refresh = false, type) {
if (this.loading) return
this.loading = true
if (refresh) {
this.refreshing = true
this.page = 1
this.logs = []
this.queryType = type || 'all'
}
try {
const res = await api.getLogs({
page: this.page,
page_size: this.pageSize,
type: this.queryType
})
if (res.data) {
let newLogs = res.data.items || []
// 按时间倒序排序(最新的在前)
newLogs = newLogs.sort((a, b) => {
const timeA = new Date(a.smoke_at || a.smoke_time || (a.createtime ? a.createtime * 1000 : 0)).getTime()
const timeB = new Date(b.smoke_at || b.smoke_time || (b.createtime ? b.createtime * 1000 : 0)).getTime()
return timeB - timeA
})
if (refresh) {
this.logs = newLogs
} else {
// 合并并去重(按 id
const existingIds = new Set(this.logs.map(log => log.id))
const uniqueNewLogs = newLogs.filter(log => !existingIds.has(log.id))
this.logs = [...this.logs, ...uniqueNewLogs]
// 再次排序确保顺序
this.logs.sort((a, b) => {
const timeA = new Date(a.smoke_at || a.smoke_time || (a.createtime ? a.createtime * 1000 : 0)).getTime()
const timeB = new Date(b.smoke_at || b.smoke_time || (b.createtime ? b.createtime * 1000 : 0)).getTime()
return timeB - timeA
})
}
this.total = res.data.total || 0
this.hasMore = newLogs.length >= this.pageSize
}
} catch (e) {
console.error('fetchLogs error:', e)
uni.showToast({
title: '加载失败',
icon: 'none'
})
} finally {
this.loading = false
this.refreshing = false
}
},
// 加载更多
async loadMore() {
if (!this.hasMore || this.loading) return
this.page++
await this.fetchLogs(false)
},
// 删除记录
async deleteLog(id) {
try {
await api.deleteLog(id)
// 乐观更新:先从列表中移除
const index = this.logs.findIndex(log => log.id === id)
if (index > -1) {
this.logs.splice(index, 1)
this.total--
}
uni.showToast({
title: '删除成功',
icon: 'success'
})
return true
} catch (e) {
console.error('deleteLog error:', e)
uni.showToast({
title: '删除失败',
icon: 'none'
})
// 失败时刷新列表恢复数据
await this.fetchLogs(true)
return false
}
},
// 更新记录
async updateLog(id, data) {
try {
await api.updateLog(id, data)
// 更新本地数据
const index = this.logs.findIndex(log => log.id === id)
if (index > -1) {
this.logs[index] = {
...this.logs[index],
...data
}
}
uni.showToast({
title: '更新成功',
icon: 'success'
})
return true
} catch (e) {
console.error('updateLog error:', e)
uni.showToast({
title: '更新失败',
icon: 'none'
})
return false
}
},
// 清空列表
clearLogs() {
this.logs = []
this.total = 0
this.page = 1
this.hasMore = true
this.queryType = 'all'
}
}
})
// 辅助函数:格式化时间
function formatLogTime(timeStr) {
if (!timeStr) return '--:--'
let date
if (typeof timeStr === 'number') {
// 如果是时间戳(秒)
date = new Date(timeStr * 1000)
} else if (typeof timeStr === 'string') {
// 如果是字符串
date = new Date(timeStr)
} else {
return '--:--'
}
// 检查日期是否有效
if (isNaN(date.getTime())) {
return '--:--'
}
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${hours}:${minutes}`
}
function normalizeLogType(log) {
const rawType = log?.type
if (typeof rawType === 'string') {
const value = rawType.toLowerCase()
if (value === 'resisted' || value === 'resist') return 'resisted'
if (value === 'smoke' || value === 'log_smoke') return 'smoke'
}
if (typeof rawType === 'number') {
if (rawType === 0) return 'resisted'
if (rawType === 1) return 'smoke'
}
if (log?.num === 0) return 'resisted'
return 'smoke'
}
+66
View File
@@ -0,0 +1,66 @@
import { defineStore } from 'pinia'
import { storage, PROFILE_KEY } from '@/utils/storage'
import { getProfile, updateProfile } from '@/api/profile'
import { useUserStore } from '@/stores/user'
export const useProfileStore = defineStore('profile', {
state: () => ({
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()
const userStore = useUserStore()
this.exists = res.data.exists
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)
if (res.data.profile.mode) {
userStore.setMode(res.data.profile.mode)
}
this.isCompleted = res.data.is_completed ||
!!res.data.profile.onboarding_completed_at ||
res.data.profile.baseline_cigs_per_day > 0
} else {
this.isCompleted = res.data.is_completed
}
return res.data
} catch (e) {
console.error('fetchProfile error:', e)
throw e
}
},
async saveProfile(data) {
try {
const res = await updateProfile(data)
const userStore = useUserStore()
this.exists = res.data.exists
this.isCompleted = res.data.is_completed
this.profile = res.data.profile
storage.set(PROFILE_KEY, res.data.profile)
if (res.data.profile?.mode) {
userStore.setMode(res.data.profile.mode)
}
return res.data
} catch (e) {
console.error('saveProfile error:', e)
throw e
}
}
}
})
+37
View File
@@ -0,0 +1,37 @@
import { defineStore } from 'pinia'
import { storage, USER_KEY, SESSION_KEY, USER_MODE_KEY, QUIT_CHECKIN_KEY } from '@/utils/storage'
export const useUserStore = defineStore('user', {
state: () => ({
user: storage.get(USER_KEY),
sessionKey: storage.get(SESSION_KEY),
isLoggedIn: !!storage.get(SESSION_KEY),
mode: storage.get(USER_MODE_KEY)
}),
actions: {
setUser(user, sessionKey) {
this.user = user
this.sessionKey = sessionKey
this.isLoggedIn = true
storage.set(USER_KEY, user)
storage.set(SESSION_KEY, sessionKey)
},
setMode(mode) {
this.mode = mode
storage.set(USER_MODE_KEY, mode)
},
logout() {
this.user = null
this.sessionKey = null
this.isLoggedIn = false
this.mode = null
storage.remove(USER_KEY)
storage.remove(SESSION_KEY)
storage.remove(USER_MODE_KEY)
storage.remove(QUIT_CHECKIN_KEY)
}
}
})
+13
View File
@@ -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])
});
});
},
});
+76
View File
@@ -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;
+41
View File
@@ -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%'
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './storage'
export * from './time'
export * from './format'
+43
View File
@@ -0,0 +1,43 @@
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'
export const USER_MODE_KEY = 'user_mode'
export const QUIT_CHECKIN_KEY = 'quit_checkin'
+82
View File
@@ -0,0 +1,82 @@
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) {
return dateStr === formatDate(new Date())
}
export function isYesterday(dateStr) {
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
return dateStr === formatDate(yesterday)
}
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()]
}