Implement login functionality and UI updates across the application. Added silent login process in App.vue, updated styles for various components, and integrated smoke record dialog. Enhanced onboarding and profile pages with improved layouts and user experience. Updated manifest and configuration files for deployment. Added easycom configuration for component auto-import.

This commit is contained in:
nepiedg
2026-01-25 14:48:20 +08:00
parent c883ae7b17
commit 661f39dfd7
24 changed files with 4569 additions and 572 deletions
+134
View File
@@ -0,0 +1,134 @@
# 组件使用说明
## smoke-record-dialog - 抽烟记录弹框组件
这是一个可复用的底部弹框组件,用于记录抽烟或抵抗记录。
### 特性
- ✨ 从底部弹出,半屏展示
- 🎨 明亮主题设计
- 📱 支持日期时间选择
- 🔢 支持数量调整和烟瘾等级选择
- 📝 支持备注输入
- 🔄 全局可复用(已配置 easycom 自动导入)
### 使用方法
#### 方式一:easycom 自动导入(推荐)
无需手动导入,直接在模板中使用即可:
```vue
<template>
<view>
<!-- 触发按钮 -->
<button @tap="openDialog">记录抽烟</button>
<!-- 弹框组件 - 自动导入无需 import -->
<smoke-record-dialog
v-model:show="showDialog"
:type="dialogType"
@submit="handleSubmit"
/>
</view>
</template>
<script setup>
import { ref } from 'vue'
import * as api from '@/api'
const showDialog = ref(false)
const dialogType = ref('smoke') // 'smoke' 或 'resisted'
function openDialog() {
dialogType.value = 'smoke' // 或 'resisted'
showDialog.value = true
}
async function handleSubmit(data) {
try {
await api.createLog(data)
uni.showToast({ title: '记录成功', icon: 'success' })
// 更新数据...
} catch (e) {
console.error('提交失败:', e)
}
}
</script>
```
#### 方式二:手动导入(可选)
```vue
<script setup>
import smokeRecordDialog from '@/components/smoke-record-dialog/smoke-record-dialog.vue'
// 其他代码...
</script>
```
### Props
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| show | Boolean | false | 控制弹框显示/隐藏 |
| type | String | 'smoke' | 记录类型:'smoke'(抽烟) 或 'resisted'(忍住) |
### Events
| 事件名 | 参数 | 说明 |
|--------|------|------|
| update:show | Boolean | 弹框显示状态变化时触发 |
| submit | Object | 提交表单时触发,返回表单数据 |
### 提交数据格式
```javascript
{
smoke_time: "2025-12-31", // 日期
smoke_at: "2025-12-31 08:30:00", // 完整时间
remark: "压力大", // 备注
level: 2, // 烟瘾等级(1-5
num: 3 // 数量(忍住时为0
}
```
### 示例场景
#### 场景1:记录抽烟
```vue
<button @tap="recordSmoke">记录抽烟</button>
<smoke-record-dialog
v-model:show="showDialog"
type="smoke"
@submit="handleSmokeSubmit"
/>
```
#### 场景2:记录忍住
```vue
<button @tap="recordResisted">想抽忍住了</button>
<smoke-record-dialog
v-model:show="showDialog"
type="resisted"
@submit="handleResistedSubmit"
/>
```
### 样式定制
组件内部样式已设置为 `scoped`,如需自定义样式,可以通过以下方式:
1. 修改组件内部样式文件
2. 使用深度选择器覆盖样式(不推荐)
### 注意事项
1. 组件使用 v-model:show 双向绑定显示状态
2. type 为 'resisted' 时,num 自动设置为 0
3. 表单数据会在打开弹框时自动初始化为当前时间
4. 提交后弹框会自动关闭
+181
View File
@@ -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,378 @@
<template>
<view v-if="show" class="dialog-mask" @tap="handleMaskClick">
<view class="dialog-container" :class="{ 'dialog-show': showAnimation }" @tap.stop>
<view class="dialog-header">
<text class="dialog-title">{{ title }}</text>
<view class="dialog-close" @tap="close">×</view>
</view>
<view class="dialog-body">
<view class="form-item">
<text class="form-label">时间</text>
<view class="form-input-row">
<picker mode="date" :value="formData.smoke_time" @change="onDateChange">
<view class="picker-value">{{ formData.smoke_time }}</view>
</picker>
<picker mode="time" :value="formData.smoke_time_only" @change="onTimeChange">
<view class="picker-value">{{ formData.smoke_time_only }}</view>
</picker>
</view>
</view>
<view class="form-item" v-if="type === 'smoke'">
<text class="form-label">数量</text>
<view class="form-number">
<view class="form-number-btn" @tap="decreaseNum">-</view>
<input class="form-number-input" type="number" v-model.number="formData.num" />
<view class="form-number-btn" @tap="increaseNum">+</view>
</view>
</view>
<view class="form-item" v-if="type === 'smoke'">
<text class="form-label">烟瘾等级</text>
<view class="form-level">
<view
v-for="level in 5"
:key="level"
class="level-item"
:class="{ 'level-active': formData.level === level }"
@tap="selectLevel(level)"
>
{{ level }}
</view>
</view>
</view>
<view class="form-item">
<text class="form-label">备注</text>
<textarea
class="form-textarea"
v-model="formData.remark"
:placeholder="type === 'smoke' ? '记录抽烟原因...' : '记录抵抗心得...'"
maxlength="200"
/>
</view>
</view>
<view class="dialog-footer">
<view class="dialog-btn dialog-btn-cancel" @tap="close">取消</view>
<view class="dialog-btn dialog-btn-confirm" @tap="submit">确定</view>
</view>
</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 this.type === 'smoke' ? '记录抽烟' : '想抽忍住了'
}
},
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 {
// 新建模式,使用当前时间
const now = new Date()
const dateStr = now.toISOString().split('T')[0]
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++
},
selectLevel(level) {
this.formData.level = level
},
submit() {
const submitData = {
smoke_time: this.formData.smoke_time,
smoke_at: this.formData.smoke_at,
remark: this.formData.remark,
level: this.type === 'smoke' ? this.formData.level : 2,
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(0, 0, 0, 0.5);
z-index: 9999;
display: flex;
align-items: flex-end;
}
.dialog-container {
width: 100%;
max-height: 80vh;
background-color: #FFFFFF;
border-radius: 32rpx 32rpx 0 0;
overflow: hidden;
transform: translateY(100%);
transition: transform 0.3s ease-out;
}
.dialog-show {
transform: translateY(0);
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
border-bottom: 2rpx solid #F3F4F6;
}
.dialog-title {
font-size: 32rpx;
font-weight: 600;
color: #1F2937;
}
.dialog-close {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 48rpx;
color: #9CA3AF;
line-height: 1;
}
.dialog-body {
padding: 32rpx;
max-height: 60vh;
overflow-y: auto;
}
.form-item {
margin-bottom: 32rpx;
}
.form-item:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
font-size: 28rpx;
color: #6B7280;
margin-bottom: 16rpx;
font-weight: 500;
}
.form-input-row {
display: flex;
gap: 16rpx;
}
.picker-value {
flex: 1;
height: 80rpx;
background-color: #F9FAFB;
border-radius: 16rpx;
padding: 0 24rpx;
display: flex;
align-items: center;
font-size: 28rpx;
color: #1F2937;
border: 2rpx solid #E5E7EB;
}
.form-number {
display: flex;
align-items: center;
gap: 16rpx;
}
.form-number-btn {
width: 80rpx;
height: 80rpx;
background-color: #F9FAFB;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
color: #10B981;
font-weight: 600;
border: 2rpx solid #E5E7EB;
}
.form-number-input {
flex: 1;
height: 80rpx;
background-color: #F9FAFB;
border-radius: 16rpx;
padding: 0 24rpx;
text-align: center;
font-size: 32rpx;
color: #1F2937;
border: 2rpx solid #E5E7EB;
}
.form-level {
display: flex;
gap: 16rpx;
}
.level-item {
flex: 1;
height: 80rpx;
background-color: #F9FAFB;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
color: #6B7280;
border: 2rpx solid #E5E7EB;
transition: all 0.3s;
}
.level-active {
background-color: #10B981;
color: #FFFFFF;
border-color: #10B981;
font-weight: 600;
}
.form-textarea {
width: 100%;
min-height: 160rpx;
background-color: #F9FAFB;
border-radius: 16rpx;
padding: 24rpx;
font-size: 28rpx;
color: #1F2937;
border: 2rpx solid #E5E7EB;
box-sizing: border-box;
}
.dialog-footer {
display: flex;
gap: 16rpx;
padding: 32rpx;
border-top: 2rpx solid #F3F4F6;
background-color: #FFFFFF;
}
.dialog-btn {
flex: 1;
height: 88rpx;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
font-weight: 500;
}
.dialog-btn-cancel {
background-color: #F3F4F6;
color: #6B7280;
}
.dialog-btn-confirm {
background-color: #10B981;
color: #FFFFFF;
}
</style>