feat: refresh UI and add vite ci workflow
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './auth'
|
||||
export * from './smoke'
|
||||
export * from './profile'
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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]
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 902 B |
|
After Width: | Height: | Size: 912 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 464 B |
|
After Width: | Height: | Size: 1.3 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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])
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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%'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './storage'
|
||||
export * from './time'
|
||||
export * from './format'
|
||||
@@ -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'
|
||||
@@ -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()]
|
||||
}
|
||||