feat: 梦想清单页与戒烟相关 API

- 梦想清单:系统导航栏、浮动添加、图标来自后台预设
- dream-presets API、pages.json 导航样式

Made-with: Cursor
This commit is contained in:
nepiedg
2026-04-04 14:55:58 +08:00
parent 4066bd31fa
commit 14c0d4752d
3 changed files with 91 additions and 196 deletions
+5
View File
@@ -114,6 +114,11 @@ export function upsertQuitCheckinProfile(data) {
return request.request({ url: '/profile', method: 'POST', data, baseUrl: BASE_URL_V2 })
}
// 预设梦想目标
export function listDreamPresets() {
return request.request({ url: '/dream-presets', method: 'GET', baseUrl: BASE_URL_V2 })
}
// 梦想目标 API
export function listRewardGoals(status = 'all') {
return request.request({ url: '/reward-goals', method: 'GET', data: { status }, baseUrl: BASE_URL_V2 })
+1
View File
@@ -75,6 +75,7 @@
{
"path": "pages/dream-goals/index",
"style": {
"navigationStyle": "default",
"navigationBarTitleText": "梦想清单"
}
}
+80 -191
View File
@@ -1,15 +1,5 @@
<template>
<view class="page">
<view class="header">
<view class="header-left" @tap="goBack">
<text class="header-back"></text>
</view>
<text class="header-title">梦想清单</text>
<view class="header-right" @tap="openCreate">
<text class="header-add">+</text>
</view>
</view>
<view class="saved-banner">
<view class="saved-banner-inner">
<text class="saved-label">戒烟已省下</text>
@@ -63,9 +53,14 @@
</view>
</view>
<!-- 创建/编辑弹窗 -->
<view v-if="showForm" class="modal-mask" @tap.self="showForm = false">
<view class="modal-body">
<!-- 浮动添加按钮 -->
<view class="fab" @tap="openCreate">
<text class="fab-icon">+</text>
</view>
<!-- 创建弹窗 -->
<view v-if="showForm" class="modal-mask" @tap="closeForm">
<view class="modal-body" @tap.stop>
<text class="modal-title">添加梦想目标</text>
<view class="form-group">
@@ -80,34 +75,29 @@
<view class="form-group">
<text class="form-label">选择图标</text>
<view class="icon-grid">
<view v-if="iconsLoading" class="icons-loading">
<text class="icons-loading-text">加载中...</text>
</view>
<view v-else-if="iconList.length === 0" class="icons-empty">
<text class="icons-empty-text">暂无图标</text>
</view>
<view v-else class="icon-grid">
<view
v-for="icon in systemIcons"
:key="icon.key"
v-for="icon in iconList"
:key="icon.id"
class="icon-item"
:class="{ 'icon-item-active': form.coverImage === icon.key }"
@tap="form.coverImage = icon.key"
:class="{ 'icon-item-active': form.coverImage === icon.cover_image }"
@tap.stop="form.coverImage = icon.cover_image"
>
<text class="icon-emoji">{{ icon.emoji }}</text>
<text class="icon-name">{{ icon.name }}</text>
</view>
</view>
</view>
<view class="form-group">
<text class="form-label">或上传图片</text>
<view class="upload-area" @tap="chooseImage">
<image v-if="form.uploadedUrl" :src="form.uploadedUrl" mode="aspectFill" class="upload-preview" />
<view v-else class="upload-placeholder">
<text class="upload-icon">📷</text>
<text class="upload-text">选择图片3MB</text>
<image v-if="icon.cover_image && !icon.cover_image.startsWith('icon:')" :src="icon.cover_image" mode="aspectFill" class="icon-img" />
<text v-else class="icon-emoji">{{ (icon.cover_image || '').replace('icon:', '') || '🎁' }}</text>
</view>
</view>
</view>
<view class="modal-actions">
<text class="modal-btn modal-btn-cancel" @tap="showForm = false">取消</text>
<text class="modal-btn modal-btn-confirm" @tap="submitGoal">确定</text>
<text class="modal-btn modal-btn-cancel" @tap.stop="closeForm">取消</text>
<text class="modal-btn modal-btn-confirm" @tap.stop="submitGoal">确定</text>
</view>
</view>
</view>
@@ -117,7 +107,6 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import * as api from '@/api'
import { getUploadToken } from '@/api/auth'
const loading = ref(true)
const goals = ref([])
@@ -125,28 +114,10 @@ const currentTab = ref('active')
const showForm = ref(false)
const savedMoneyCent = ref(0)
const dailySaveCent = ref(0)
const iconList = ref([])
const iconsLoading = ref(false)
const form = ref({
title: '',
priceYuan: '',
coverImage: '',
uploadedUrl: ''
})
const systemIcons = [
{ key: 'icon:🎧', emoji: '🎧', name: '耳机' },
{ key: 'icon:👟', emoji: '👟', name: '球鞋' },
{ key: 'icon:📱', emoji: '📱', name: '手机' },
{ key: 'icon:⌚', emoji: '⌚', name: '手表' },
{ key: 'icon:🎮', emoji: '🎮', name: '游戏机' },
{ key: 'icon:📷', emoji: '📷', name: '相机' },
{ key: 'icon:💻', emoji: '💻', name: '电脑' },
{ key: 'icon:🎸', emoji: '🎸', name: '吉他' },
{ key: 'icon:🏖️', emoji: '🏖️', name: '旅行' },
{ key: 'icon:🎂', emoji: '🎂', name: '蛋糕' },
{ key: 'icon:🎁', emoji: '🎁', name: '礼物' },
{ key: 'icon:🚲', emoji: '🚲', name: '自行车' },
]
const form = ref({ title: '', priceYuan: '', coverImage: '' })
const savedMoneyYuan = computed(() => Math.round(savedMoneyCent.value / 100))
@@ -167,69 +138,24 @@ function getEtaText(goal) {
return `${daysNeeded} 天可达成`
}
function goBack() {
uni.navigateBack()
}
function openCreate() {
form.value = { title: '', priceYuan: '', coverImage: '', uploadedUrl: '' }
async function openCreate() {
form.value = { title: '', priceYuan: '', coverImage: '' }
showForm.value = true
}
async function chooseImage() {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
const tempPath = res.tempFilePaths[0]
const fileSize = res.tempFiles[0].size
if (fileSize > 3 * 1024 * 1024) {
uni.showToast({ title: '图片不能超过 3MB', icon: 'none' })
return
}
if (iconList.value.length === 0) {
iconsLoading.value = true
try {
const tokenRes = await getUploadToken(res.tempFiles[0].name || 'image.jpg')
const tokenData = tokenRes.data
if (tokenData.oss_access_key_id) {
await ossUpload(tokenData, tempPath)
} else {
uni.showToast({ title: '暂不支持当前上传方式', icon: 'none' })
}
const res = await api.listDreamPresets()
iconList.value = res.data?.items || []
} catch (e) {
console.error('upload error:', e)
uni.showToast({ title: '上传失败', icon: 'none' })
console.error('fetch icons error:', e)
} finally {
iconsLoading.value = false
}
}
})
}
function ossUpload(tokenData, filePath) {
return new Promise((resolve, reject) => {
uni.uploadFile({
url: tokenData.upload_url,
filePath,
name: 'file',
formData: {
key: tokenData.key,
OSSAccessKeyId: tokenData.oss_access_key_id,
policy: tokenData.oss_policy,
Signature: tokenData.oss_signature,
success_action_status: '200'
},
success: (uploadRes) => {
if (uploadRes.statusCode === 200) {
const url = `${tokenData.cdn_domain}/${tokenData.key}`
form.value.uploadedUrl = url
form.value.coverImage = ''
resolve(url)
} else {
reject(new Error('上传失败'))
}
},
fail: reject
})
})
function closeForm() {
showForm.value = false
}
async function submitGoal() {
@@ -243,13 +169,11 @@ async function submitGoal() {
uni.showToast({ title: '请输入有效价格', icon: 'none' })
return
}
const coverImage = form.value.uploadedUrl || form.value.coverImage || ''
try {
await api.createRewardGoal({
title,
target_amount_cent: Math.round(price * 100),
cover_image: coverImage
cover_image: form.value.coverImage || ''
})
showForm.value = false
uni.showToast({ title: '添加成功', icon: 'success' })
@@ -313,46 +237,11 @@ onMounted(async () => {
.page {
min-height: 100vh;
background: #F8FAFB;
padding-bottom: 60rpx;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 28rpx;
padding-top: calc(env(safe-area-inset-top) + 24rpx);
}
.header-back {
font-size: 36rpx;
color: #374151;
font-weight: 600;
}
.header-title {
font-size: 32rpx;
font-weight: 700;
color: #111827;
}
.header-add {
font-size: 40rpx;
color: #14936d;
font-weight: 600;
}
.header-left, .header-right {
width: 60rpx;
display: flex;
align-items: center;
}
.header-right {
justify-content: flex-end;
padding-bottom: 140rpx;
}
.saved-banner {
margin-top: 20rpx;
margin: 0 24rpx 20rpx;
border-radius: 28rpx;
overflow: hidden;
@@ -644,21 +533,32 @@ onMounted(async () => {
box-sizing: border-box;
}
.icons-loading, .icons-empty {
padding: 24rpx 0;
text-align: center;
}
.icons-loading-text, .icons-empty-text {
font-size: 24rpx;
color: #9ca3af;
}
.icon-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(5, 1fr);
gap: 16rpx;
}
.icon-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6rpx;
padding: 16rpx 8rpx;
justify-content: center;
width: 100%;
aspect-ratio: 1;
border-radius: 16rpx;
background: #f9fafb;
border: 2rpx solid transparent;
overflow: hidden;
transition: all 0.15s;
}
@@ -668,45 +568,12 @@ onMounted(async () => {
}
.icon-emoji {
font-size: 36rpx;
}
.icon-name {
font-size: 20rpx;
color: #6b7280;
}
.upload-area {
width: 100%;
height: 200rpx;
border-radius: 16rpx;
overflow: hidden;
background: #f9fafb;
border: 2rpx dashed rgba(15, 23, 42, 0.12);
display: flex;
align-items: center;
justify-content: center;
}
.upload-preview {
width: 100%;
height: 100%;
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
}
.upload-icon {
font-size: 40rpx;
}
.upload-text {
font-size: 22rpx;
color: #9ca3af;
.icon-img {
width: 100%;
height: 100%;
}
.modal-actions {
@@ -734,4 +601,26 @@ onMounted(async () => {
color: #fff;
box-shadow: 0 8rpx 20rpx rgba(20, 147, 109, 0.2);
}
.fab {
position: fixed;
right: 40rpx;
bottom: calc(env(safe-area-inset-bottom) + 60rpx);
width: 100rpx;
height: 100rpx;
border-radius: 50%;
background: linear-gradient(135deg, #14936d, #10B981);
box-shadow: 0 12rpx 30rpx rgba(20, 147, 109, 0.35);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.fab-icon {
font-size: 48rpx;
color: #fff;
font-weight: 300;
line-height: 1;
}
</style>