feat(logs): add quick record flow and reason tags

This commit is contained in:
nepiedg
2026-04-16 11:12:13 +08:00
parent fefda9ec97
commit cbe1fdb035
6 changed files with 550 additions and 132 deletions
@@ -11,6 +11,8 @@
- ✅ 从底部弹出动画效果 - ✅ 从底部弹出动画效果
- ✅ 半屏展示,优化用户体验 - ✅ 半屏展示,优化用户体验
- ✅ 支持两种模式:抽烟记录 / 忍住记录 - ✅ 支持两种模式:抽烟记录 / 忍住记录
- ✅ 快捷标签、多选原因与补充备注
-`quickMode` 快速记录模式
- ✅ 完整的表单功能 - ✅ 完整的表单功能
- ✅ 已配置 easycom 自动导入 - ✅ 已配置 easycom 自动导入
@@ -58,6 +60,8 @@ function handleSubmit(data) {
|------|------|--------|------| |------|------|--------|------|
| show | Boolean | false | 控制弹框显示/隐藏(支持 v-model) | | show | Boolean | false | 控制弹框显示/隐藏(支持 v-model) |
| type | String | 'smoke' | 记录类型:'smoke'(抽烟) 或 'resisted'(忍住) | | type | String | 'smoke' | 记录类型:'smoke'(抽烟) 或 'resisted'(忍住) |
| initialData | Object | null | 编辑模式下的初始值 |
| quickMode | Boolean | false | 是否启用快速记录模式,默认隐藏高级项 |
## 🎪 Events ## 🎪 Events
@@ -72,6 +76,7 @@ function handleSubmit(data) {
{ {
smoke_time: "2025-01-25", // 日期 smoke_time: "2025-01-25", // 日期
smoke_at: "2025-01-25 14:30:00", // 完整时间 smoke_at: "2025-01-25 14:30:00", // 完整时间
reason_tags: ["stress"], // 原因标签(可选)
remark: "压力大", // 备注(可选) remark: "压力大", // 备注(可选)
level: 2, // 烟瘾等级 1-5 level: 2, // 烟瘾等级 1-5
num: 3 // 数量(忍住时为0 num: 3 // 数量(忍住时为0
@@ -90,6 +95,7 @@ function handleSubmit(data) {
<smoke-record-dialog <smoke-record-dialog
v-model:show="showDialog" v-model:show="showDialog"
type="smoke" type="smoke"
:quick-mode="true"
@submit="onSmokeSubmit" @submit="onSmokeSubmit"
/> />
</view> </view>
@@ -179,3 +185,4 @@ async function onResistedSubmit(data) {
3. 已配置 easycom,无需手动导入 3. 已配置 easycom,无需手动导入
4. 提交后弹框会自动关闭 4. 提交后弹框会自动关闭
5. 表单数据会在打开时自动初始化为当前时间 5. 表单数据会在打开时自动初始化为当前时间
6. 当后端尚未消费 `reason_tags` 时,组件会把已选标签合并进 `remark`,避免信息丢失
@@ -3,84 +3,130 @@
<view class="dialog-container" :class="{ 'dialog-show': showAnimation }" @tap.stop> <view class="dialog-container" :class="{ 'dialog-show': showAnimation }" @tap.stop>
<view class="dialog-handle"></view> <view class="dialog-handle"></view>
<view class="dialog-header"> <view class="dialog-header">
<text class="dialog-title">{{ title }}</text> <view>
<text class="dialog-title">{{ title }}</text>
<text v-if="quickModeActive" class="dialog-subtitle">{{ quickModeSummary }}</text>
</view>
<view class="dialog-close" @tap="close">×</view> <view class="dialog-close" @tap="close">×</view>
</view> </view>
<view class="dialog-body"> <view class="dialog-body">
<view class="form-row"> <view v-if="quickModeActive" class="quick-banner">
<picker class="picker-card" mode="date" :value="formData.smoke_time" @change="onDateChange"> <view class="quick-banner-chip">
<view class="input-card"> <text class="quick-banner-chip-label">默认时间</text>
<text class="input-label">日期</text> <text class="quick-banner-chip-value">{{ formData.smoke_time_only }}</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>
<view class="counter"> <view class="quick-banner-chip" v-if="type === 'smoke'">
<view class="counter-btn" @tap="decreaseNum">-</view> <text class="quick-banner-chip-label">默认数量</text>
<text class="counter-value">{{ formData.num }}</text> <text class="quick-banner-chip-value">{{ formData.num }} </text>
<view class="counter-btn" @tap="increaseNum">+</view>
</view> </view>
<text class="quick-banner-tip">先选原因就能快速保存需要时再展开高级项</text>
</view> </view>
<view class="section-card"> <view class="section-card">
<view class="level-header"> <view class="section-heading">
<text class="section-title">{{ type === 'smoke' ? '烟瘾程度' : '忍住强度' }}</text> <text class="section-title">{{ reasonSectionTitle }}</text>
<view class="level-badge">Level {{ formData.level }}</view> <text class="section-caption">可多选</text>
</view> </view>
<slider <view class="reason-chip-grid">
class="level-slider" <view
:value="formData.level" v-for="item in quickReasonOptions"
:min="1" :key="item.key"
:max="5" class="reason-chip"
:step="1" :class="{ 'reason-chip-active': isReasonSelected(item.key) }"
activeColor="#22C55E" @tap="toggleReason(item.key)"
backgroundColor="#E5E7EB" >
block-color="#22C55E" <text class="reason-chip-text">{{ item.label }}</text>
:block-size="20" </view>
@change="onLevelChange"
/>
<view class="level-scale">
<text class="level-scale-text">无感</text>
<text class="level-scale-text">强烈</text>
</view> </view>
</view> </view>
<view class="remark-section"> <view class="remark-section">
<text class="section-title">备注可选</text> <view class="section-heading">
<text class="section-title">{{ remarkTitle }}</text>
<text class="section-caption">{{ remarkCaption }}</text>
</view>
<view class="remark-card"> <view class="remark-card">
<textarea <textarea
class="form-textarea" class="form-textarea"
v-model="formData.remark" v-model="formData.remark"
:placeholder="type === 'smoke' ? '记录此时的心情或诱因,如压力大、应酬...' : '记录抵抗心得或诱因...'" :placeholder="remarkPlaceholder"
maxlength="200" maxlength="200"
/> />
</view> </view>
</view> </view>
<view v-if="quickModeActive" class="advanced-toggle" @tap="showAdvanced = !showAdvanced">
<view>
<text class="advanced-toggle-title">{{ showAdvanced ? '收起高级设置' : '展开高级设置' }}</text>
<text class="advanced-toggle-desc">修改时间数量和强度</text>
</view>
<text class="advanced-toggle-arrow">{{ showAdvanced ? '⌃' : '⌄' }}</text>
</view>
<view v-if="showAdvanced || !quickModeActive" class="advanced-fields">
<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>
</view> </view>
<view class="dialog-footer"> <view class="dialog-footer">
<view class="dialog-btn-primary" @tap="submit"> <view class="dialog-btn-primary" @tap="submit">
<view class="btn-icon"></view> <view class="btn-icon"></view>
<text class="btn-text">保存记录</text> <text class="btn-text">{{ quickModeActive ? '快速保存' : '保存记录' }}</text>
</view> </view>
</view> </view>
</view> </view>
@@ -88,6 +134,8 @@
</template> </template>
<script> <script>
import { getReasonOptions, normalizeReasonTags, getReasonLabels } from '@/config/smoke-reasons'
export default { export default {
name: 'SmokeRecordDialog', name: 'SmokeRecordDialog',
props: { props: {
@@ -102,16 +150,22 @@ export default {
initialData: { initialData: {
type: Object, type: Object,
default: null default: null
},
quickMode: {
type: Boolean,
default: false
} }
}, },
data() { data() {
return { return {
showAnimation: false, showAnimation: false,
showAdvanced: false,
formData: { formData: {
smoke_time: '', smoke_time: '',
smoke_time_only: '', smoke_time_only: '',
smoke_at: '', smoke_at: '',
remark: '', remark: '',
reason_tags: [],
level: 2, level: 2,
num: 1 num: 1
} }
@@ -119,7 +173,40 @@ export default {
}, },
computed: { computed: {
title() { title() {
return '添加记录' if (this.type === 'resisted') {
return this.quickModeActive ? '记下这次忍住' : '添加忍住记录'
}
return this.quickModeActive ? '快速记录一根' : '添加抽烟记录'
},
quickModeActive() {
return this.quickMode && !this.initialData
},
quickModeSummary() {
return this.type === 'smoke'
? '默认按当前时间和 1 支记录,可直接选原因后保存'
: '默认按当前时间记录,可直接选原因后保存'
},
quickReasonOptions() {
return getReasonOptions(this.type)
},
reasonSectionTitle() {
return this.type === 'smoke' ? '这次为什么会抽?' : '这次是怎么扛住的?'
},
remarkTitle() {
return this.formData.reason_tags.includes('other') ? '补充说明' : '补充备注'
},
remarkCaption() {
return this.formData.reason_tags.includes('other') ? '已选其他,建议补充一点细节' : '可选'
},
remarkPlaceholder() {
if (this.type === 'smoke') {
return this.formData.reason_tags.includes('other')
? '写下这次抽烟的具体原因...'
: '还想补充当时的场景或心情,可以写在这里'
}
return this.formData.reason_tags.includes('other')
? '写下这次忍住的具体方法或感受...'
: '可以补充这次是怎么撑过去的'
} }
}, },
watch: { watch: {
@@ -136,35 +223,41 @@ export default {
}, },
methods: { methods: {
initFormData() { initFormData() {
// 如果有初始数据(编辑模式),使用初始数据 this.showAdvanced = !this.quickModeActive
if (this.initialData) { if (this.initialData) {
this.formData = { this.formData = {
smoke_time: this.initialData.smoke_time || '', smoke_time: this.initialData.smoke_time || '',
smoke_time_only: this.initialData.smoke_time_only || '', smoke_time_only: this.initialData.smoke_time_only || '',
smoke_at: this.initialData.smoke_at || '', smoke_at: this.initialData.smoke_at || '',
remark: this.initialData.remark || '', remark: this.initialData.remark || '',
reason_tags: normalizeReasonTags(this.initialData.reason_tags),
level: this.initialData.level ?? 2, level: this.initialData.level ?? 2,
num: this.initialData.num ?? 1 num: this.resolveInitialNum(this.initialData)
}
} 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
} }
return
} }
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: '',
reason_tags: [],
level: 2,
num: this.type === 'smoke' ? 1 : 0
}
},
resolveInitialNum(initialData) {
if (this.type !== 'smoke') return 0
return initialData.num ?? 1
}, },
handleMaskClick() { handleMaskClick() {
this.close() this.close()
@@ -197,6 +290,17 @@ export default {
onLevelChange(e) { onLevelChange(e) {
this.formData.level = e.detail.value this.formData.level = e.detail.value
}, },
isReasonSelected(reasonKey) {
return this.formData.reason_tags.includes(reasonKey)
},
toggleReason(reasonKey) {
const exists = this.isReasonSelected(reasonKey)
if (exists) {
this.formData.reason_tags = this.formData.reason_tags.filter(item => item !== reasonKey)
return
}
this.formData.reason_tags = [...this.formData.reason_tags, reasonKey]
},
isTimeValid() { isTimeValid() {
const dateStr = this.formData.smoke_time const dateStr = this.formData.smoke_time
const timeStr = this.formData.smoke_time_only const timeStr = this.formData.smoke_time_only
@@ -221,6 +325,17 @@ export default {
return true return true
}, },
buildRemark() {
const customRemark = (this.formData.remark || '').trim()
const reasonLabels = getReasonLabels(this.formData.reason_tags, this.type).filter(label => label !== '其他')
if (!reasonLabels.length) {
return customRemark
}
if (!customRemark) {
return reasonLabels.join('、')
}
return `${reasonLabels.join('、')}${customRemark}`
},
submit() { submit() {
if (!this.isTimeValid()) { if (!this.isTimeValid()) {
return return
@@ -229,11 +344,12 @@ export default {
const submitData = { const submitData = {
smoke_time: this.formData.smoke_time, smoke_time: this.formData.smoke_time,
smoke_at: this.formData.smoke_at, smoke_at: this.formData.smoke_at,
remark: this.formData.remark, remark: this.buildRemark(),
reason_tags: [...this.formData.reason_tags],
level: this.formData.level, level: this.formData.level,
num: this.type === 'smoke' ? this.formData.num : 0 num: this.type === 'smoke' ? this.formData.num : 0
} }
this.$emit('submit', submitData) this.$emit('submit', submitData)
this.close() this.close()
} }
@@ -283,17 +399,27 @@ export default {
.dialog-header { .dialog-header {
display: flex; display: flex;
align-items: center; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
padding: 24rpx 32rpx 16rpx; padding: 24rpx 32rpx 16rpx;
gap: 16rpx;
} }
.dialog-title { .dialog-title {
display: block;
font-size: 40rpx; font-size: 40rpx;
font-weight: 700; font-weight: 700;
color: #111827; color: #111827;
} }
.dialog-subtitle {
display: block;
margin-top: 10rpx;
font-size: 22rpx;
line-height: 1.5;
color: #6b7280;
}
.dialog-close { .dialog-close {
width: 56rpx; width: 56rpx;
height: 56rpx; height: 56rpx;
@@ -301,10 +427,11 @@ export default {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 44rpx; font-size: 44rpx;
color: #98A2B3; color: #98a2b3;
line-height: 1; line-height: 1;
background-color: rgba(255, 255, 255, 0.78); background-color: rgba(255, 255, 255, 0.78);
border-radius: 50%; border-radius: 50%;
flex-shrink: 0;
} }
.dialog-body { .dialog-body {
@@ -314,6 +441,151 @@ export default {
overflow-y: auto; overflow-y: auto;
} }
.quick-banner {
padding: 18rpx 20rpx;
margin-bottom: 24rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, rgba(232, 249, 241, 0.96) 0%, rgba(246, 251, 249, 0.96) 100%);
border: 2rpx solid rgba(49, 193, 139, 0.14);
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.95);
}
.quick-banner-chip {
display: inline-flex;
align-items: baseline;
gap: 8rpx;
padding: 10rpx 14rpx;
margin-right: 12rpx;
margin-bottom: 12rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.78);
border: 1rpx solid rgba(15, 23, 42, 0.05);
}
.quick-banner-chip-label {
font-size: 20rpx;
color: #6b7280;
}
.quick-banner-chip-value {
font-size: 24rpx;
font-weight: 700;
color: #0f766e;
}
.quick-banner-tip {
display: block;
font-size: 22rpx;
line-height: 1.6;
color: #4b5563;
}
.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-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
margin-bottom: 16rpx;
}
.section-caption {
font-size: 20rpx;
font-weight: 600;
color: #9ca3af;
}
.reason-chip-grid {
display: flex;
flex-wrap: wrap;
gap: 14rpx;
}
.reason-chip {
padding: 16rpx 20rpx;
border-radius: 999rpx;
background: rgba(247, 249, 252, 0.92);
border: 2rpx solid rgba(226, 232, 240, 0.9);
transition: all 0.2s ease;
}
.reason-chip-active {
background: linear-gradient(180deg, rgba(223, 250, 234, 0.96) 0%, rgba(209, 250, 229, 0.96) 100%);
border-color: rgba(26, 163, 122, 0.22);
box-shadow: 0 10rpx 18rpx rgba(26, 163, 122, 0.12);
}
.reason-chip-text {
font-size: 24rpx;
font-weight: 600;
color: #111827;
}
.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;
}
.advanced-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding: 20rpx 22rpx;
margin-bottom: 24rpx;
border-radius: 22rpx;
background: rgba(255, 255, 255, 0.78);
border: 2rpx solid rgba(255, 255, 255, 0.72);
box-shadow: 0 8rpx 18rpx rgba(15, 23, 42, 0.04);
}
.advanced-toggle-title {
display: block;
font-size: 28rpx;
font-weight: 700;
color: #111827;
}
.advanced-toggle-desc {
display: block;
margin-top: 6rpx;
font-size: 22rpx;
color: #6b7280;
}
.advanced-toggle-arrow {
font-size: 28rpx;
color: #14936d;
font-weight: 700;
}
.form-row { .form-row {
display: flex; display: flex;
gap: 20rpx; gap: 20rpx;
@@ -337,7 +609,7 @@ export default {
.input-label { .input-label {
font-size: 22rpx; font-size: 22rpx;
color: #9CA3AF; color: #9ca3af;
margin-bottom: 12rpx; margin-bottom: 12rpx;
font-weight: 500; font-weight: 500;
} }
@@ -352,7 +624,7 @@ export default {
width: 32rpx; width: 32rpx;
height: 32rpx; height: 32rpx;
border-radius: 8rpx; border-radius: 8rpx;
background-color: #DCFCE7; background-color: #dcfce7;
position: relative; position: relative;
} }
@@ -363,7 +635,7 @@ export default {
top: 8rpx; top: 8rpx;
width: 20rpx; width: 20rpx;
height: 16rpx; height: 16rpx;
border: 2rpx solid #22C55E; border: 2rpx solid #22c55e;
border-top-width: 6rpx; border-top-width: 6rpx;
border-radius: 4rpx; border-radius: 4rpx;
} }
@@ -375,7 +647,7 @@ export default {
top: 8rpx; top: 8rpx;
width: 16rpx; width: 16rpx;
height: 16rpx; height: 16rpx;
border: 2rpx solid #22C55E; border: 2rpx solid #22c55e;
border-radius: 50%; border-radius: 50%;
} }
@@ -386,7 +658,7 @@ export default {
top: 12rpx; top: 12rpx;
width: 2rpx; width: 2rpx;
height: 8rpx; height: 8rpx;
background-color: #22C55E; background-color: #22c55e;
transform-origin: bottom; transform-origin: bottom;
} }
@@ -396,15 +668,6 @@ export default {
color: #111827; 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 { .section-card-counter {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -425,7 +688,7 @@ export default {
.section-icon { .section-icon {
width: 64rpx; width: 64rpx;
height: 64rpx; height: 64rpx;
background-color: #DCFCE7; background-color: #dcfce7;
border-radius: 16rpx; border-radius: 16rpx;
position: relative; position: relative;
} }
@@ -438,7 +701,7 @@ export default {
width: 28rpx; width: 28rpx;
height: 28rpx; height: 28rpx;
border-radius: 6rpx; border-radius: 6rpx;
border: 3rpx solid #22C55E; border: 3rpx solid #22c55e;
border-top-color: transparent; border-top-color: transparent;
} }
@@ -456,7 +719,7 @@ export default {
background-color: rgba(247, 249, 252, 0.92); background-color: rgba(247, 249, 252, 0.92);
padding: 12rpx 16rpx; padding: 12rpx 16rpx;
border-radius: 999rpx; border-radius: 999rpx;
border: 2rpx solid #F1F5F9; border: 2rpx solid #f1f5f9;
margin-top: 0; margin-top: 0;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
@@ -511,31 +774,7 @@ export default {
.level-scale-text { .level-scale-text {
font-size: 22rpx; font-size: 22rpx;
color: #9CA3AF; 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 { .dialog-footer {
@@ -570,14 +809,14 @@ export default {
top: 18rpx; top: 18rpx;
width: 12rpx; width: 12rpx;
height: 6rpx; height: 6rpx;
border-left: 4rpx solid #FFFFFF; border-left: 4rpx solid #ffffff;
border-bottom: 4rpx solid #FFFFFF; border-bottom: 4rpx solid #ffffff;
transform: rotate(-45deg); transform: rotate(-45deg);
} }
.btn-text { .btn-text {
font-size: 30rpx; font-size: 30rpx;
font-weight: 600; font-weight: 600;
color: #FFFFFF; color: #ffffff;
} }
</style> </style>
+68
View File
@@ -0,0 +1,68 @@
const SMOKE_REASON_OPTIONS = [
{ key: 'stress', label: '压力大' },
{ key: 'after_meal', label: '饭后习惯' },
{ key: 'social', label: '社交应酬' },
{ key: 'bored', label: '无聊' },
{ key: 'low_mood', label: '情绪低落' },
{ key: 'after_drink', label: '喝酒后' },
{ key: 'triggered', label: '看到别人抽' },
{ key: 'morning', label: '早起习惯' },
{ key: 'other', label: '其他' }
]
const RESISTED_REASON_OPTIONS = [
{ key: 'distracted', label: '转移注意' },
{ key: 'walked', label: '出去走走' },
{ key: 'water', label: '喝水缓解' },
{ key: 'deep_breath', label: '深呼吸' },
{ key: 'snack', label: '吃点东西' },
{ key: 'chat', label: '找人聊聊' },
{ key: 'busy', label: '让自己忙起来' },
{ key: 'willpower', label: '硬撑过去了' },
{ key: 'other', label: '其他' }
]
const REASON_MAP = {
smoke: SMOKE_REASON_OPTIONS,
resisted: RESISTED_REASON_OPTIONS
}
export function getReasonOptions(type = 'smoke') {
return REASON_MAP[type] || REASON_MAP.smoke
}
export function normalizeReasonTags(value) {
if (!value) return []
if (Array.isArray(value)) {
return value.filter(item => typeof item === 'string' && item.trim())
}
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) return []
try {
const parsed = JSON.parse(trimmed)
if (Array.isArray(parsed)) {
return parsed.filter(item => typeof item === 'string' && item.trim())
}
} catch (e) {
// ignore parse failure and fallback to comma split
}
return trimmed
.split(',')
.map(item => item.trim())
.filter(Boolean)
}
return []
}
export function getReasonLabelMap(type = 'smoke') {
return getReasonOptions(type).reduce((acc, item) => {
acc[item.key] = item.label
return acc
}, {})
}
export function getReasonLabels(tags, type = 'smoke') {
const labelMap = getReasonLabelMap(type)
return normalizeReasonTags(tags).map(tag => labelMap[tag] || tag)
}
+80 -5
View File
@@ -189,6 +189,17 @@
</view> </view>
</view> </view>
<view class="quit-slip-entry" :style="hpVisualStyle" @tap="openSmokeDialog">
<view class="quit-slip-copy">
<text class="quit-slip-title">如果刚刚没扛住先记下来</text>
<text class="quit-slip-desc">用快速记录留下时间和诱因等会再说更能帮你找回节奏</text>
</view>
<view class="quit-slip-action">
<text class="quit-slip-action-main">快速记录</text>
<text class="quit-slip-action-sub">一根 / 当前时间</text>
</view>
</view>
<view class="quit-dream-entry" @tap="gotoDreamGoals"> <view class="quit-dream-entry" @tap="gotoDreamGoals">
<view class="quit-dream-left"> <view class="quit-dream-left">
<text class="quit-dream-icon">🎯</text> <text class="quit-dream-icon">🎯</text>
@@ -372,9 +383,9 @@
</view> </view>
<smoke-record-dialog <smoke-record-dialog
v-if="!isQuitMode"
v-model:show="showDialog" v-model:show="showDialog"
:type="dialogType" :type="dialogType"
:quick-mode="true"
@submit="handleSubmit" @submit="handleSubmit"
/> />
</view> </view>
@@ -918,20 +929,30 @@ async function handleSubmit(submitData) {
try { try {
if (dialogType.value === 'smoke') { if (dialogType.value === 'smoke') {
await api.createLog(submitData) await api.createLog(submitData)
timerBaseSeconds.value = 0 if (isQuitMode.value) {
timerSeconds.value = 0 await fetchQuitHomeData()
startTimer() uni.showToast({ title: '已记下这次波动', icon: 'success' })
uni.showToast({ title: '记录成功', icon: 'success' }) } else {
timerBaseSeconds.value = 0
timerSeconds.value = 0
startTimer()
uni.showToast({ title: '记录成功', icon: 'success' })
}
} else { } else {
await api.createResistedLog({ await api.createResistedLog({
smoke_time: submitData.smoke_time, smoke_time: submitData.smoke_time,
smoke_at: submitData.smoke_at, smoke_at: submitData.smoke_at,
remark: submitData.remark, remark: submitData.remark,
reason_tags: submitData.reason_tags,
level: submitData.level, level: submitData.level,
num: submitData.num num: submitData.num
}) })
uni.showToast({ title: '已记下这次忍住', icon: 'success' }) uni.showToast({ title: '已记下这次忍住', icon: 'success' })
} }
if (isQuitMode.value) {
await fetchAchievement()
return
}
await fetchRecordHomeData() await fetchRecordHomeData()
} catch (e) { } catch (e) {
console.error('handleSubmit error:', e) console.error('handleSubmit error:', e)
@@ -1567,6 +1588,60 @@ onShareAppMessage(() => ({
border-color: transparent; border-color: transparent;
} }
.quit-slip-entry {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding: 22rpx 24rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, var(--hp-soft, #def7ec) 0%, rgba(255, 255, 255, 0.96) 100%);
border: 1rpx solid rgba(15, 23, 42, 0.06);
box-shadow: 0 10rpx 24rpx rgba(15, 23, 42, 0.05);
}
.quit-slip-copy {
flex: 1;
min-width: 0;
}
.quit-slip-title {
display: block;
font-size: 28rpx;
font-weight: 700;
color: #111827;
}
.quit-slip-desc {
display: block;
margin-top: 8rpx;
font-size: 22rpx;
line-height: 1.6;
color: #6b7280;
}
.quit-slip-action {
flex-shrink: 0;
padding: 16rpx 18rpx;
border-radius: 18rpx;
background: linear-gradient(180deg, var(--hp-accent, #14936d) 0%, var(--hp-deep, #0f766e) 100%);
box-shadow: 0 14rpx 26rpx rgba(20, 147, 109, 0.2);
}
.quit-slip-action-main {
display: block;
font-size: 24rpx;
font-weight: 700;
color: #ffffff;
}
.quit-slip-action-sub {
display: block;
margin-top: 6rpx;
font-size: 19rpx;
color: rgba(255, 255, 255, 0.82);
}
.quit-milestone-list { .quit-milestone-list {
position: relative; position: relative;
z-index: 1; z-index: 1;
+26
View File
@@ -69,6 +69,14 @@
v-if="log.remark && typeof log.remark === 'string' && log.remark.trim() && log.remark.trim().length > 0" v-if="log.remark && typeof log.remark === 'string' && log.remark.trim() && log.remark.trim().length > 0"
class="log-desc" class="log-desc"
>{{ log.remark.trim() }}</text> >{{ log.remark.trim() }}</text>
<view v-if="log.reasonLabels && log.reasonLabels.length > 0" class="reason-tag-row">
<text
v-for="label in log.reasonLabels"
:key="`${log.id}-${label}`"
class="reason-tag"
>{{ label }}</text>
</view>
<view class="log-meta-row"> <view class="log-meta-row">
<text <text
@@ -219,6 +227,7 @@ function handleEdit(log) {
smoke_time_only: log.displayTime, smoke_time_only: log.displayTime,
smoke_at: log.smoke_at, smoke_at: log.smoke_at,
remark: log.remark || '', remark: log.remark || '',
reason_tags: log.reasonTags || log.reason_tags || [],
level: log.level ?? 2, level: log.level ?? 2,
num: log.num ?? 1 num: log.num ?? 1
} }
@@ -576,6 +585,23 @@ onShareAppMessage(() => {
margin-bottom: 10rpx; margin-bottom: 10rpx;
} }
.reason-tag-row {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
margin-bottom: 10rpx;
}
.reason-tag {
padding: 8rpx 14rpx;
border-radius: 999rpx;
background: rgba(223, 250, 234, 0.82);
border: 1rpx solid rgba(26, 163, 122, 0.16);
color: #0f766e;
font-size: 20rpx;
font-weight: 600;
}
.log-meta-row { .log-meta-row {
display: flex; display: flex;
align-items: center; align-items: center;
+3
View File
@@ -1,5 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import * as api from '@/api' import * as api from '@/api'
import { normalizeReasonTags, getReasonLabels } from '@/config/smoke-reasons'
export const useLogsStore = defineStore('logs', { export const useLogsStore = defineStore('logs', {
state: () => ({ state: () => ({
@@ -126,6 +127,8 @@ export const useLogsStore = defineStore('logs', {
return { return {
...log, ...log,
type, type,
reasonTags: normalizeReasonTags(log.reason_tags),
reasonLabels: getReasonLabels(log.reason_tags, type),
interval, interval,
displayTime: formatLogTime(log.smoke_at || log.smoke_time || log.createtime), displayTime: formatLogTime(log.smoke_at || log.smoke_time || log.createtime),
displayDate displayDate