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 }) 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 // 梦想目标 API
export function listRewardGoals(status = 'all') { export function listRewardGoals(status = 'all') {
return request.request({ url: '/reward-goals', method: 'GET', data: { status }, baseUrl: BASE_URL_V2 }) 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", "path": "pages/dream-goals/index",
"style": { "style": {
"navigationStyle": "default",
"navigationBarTitleText": "梦想清单" "navigationBarTitleText": "梦想清单"
} }
} }
+85 -196
View File
@@ -1,15 +1,5 @@
<template> <template>
<view class="page"> <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">
<view class="saved-banner-inner"> <view class="saved-banner-inner">
<text class="saved-label">戒烟已省下</text> <text class="saved-label">戒烟已省下</text>
@@ -63,9 +53,14 @@
</view> </view>
</view> </view>
<!-- 创建/编辑弹窗 --> <!-- 浮动添加按钮 -->
<view v-if="showForm" class="modal-mask" @tap.self="showForm = false"> <view class="fab" @tap="openCreate">
<view class="modal-body"> <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> <text class="modal-title">添加梦想目标</text>
<view class="form-group"> <view class="form-group">
@@ -80,34 +75,29 @@
<view class="form-group"> <view class="form-group">
<text class="form-label">选择图标</text> <text class="form-label">选择图标</text>
<view class="icon-grid"> <view v-if="iconsLoading" class="icons-loading">
<view <text class="icons-loading-text">加载中...</text>
v-for="icon in systemIcons"
:key="icon.key"
class="icon-item"
:class="{ 'icon-item-active': form.coverImage === icon.key }"
@tap="form.coverImage = icon.key"
>
<text class="icon-emoji">{{ icon.emoji }}</text>
<text class="icon-name">{{ icon.name }}</text>
</view>
</view> </view>
</view> <view v-else-if="iconList.length === 0" class="icons-empty">
<text class="icons-empty-text">暂无图标</text>
<view class="form-group"> </view>
<text class="form-label">或上传图片</text> <view v-else class="icon-grid">
<view class="upload-area" @tap="chooseImage"> <view
<image v-if="form.uploadedUrl" :src="form.uploadedUrl" mode="aspectFill" class="upload-preview" /> v-for="icon in iconList"
<view v-else class="upload-placeholder"> :key="icon.id"
<text class="upload-icon">📷</text> class="icon-item"
<text class="upload-text">选择图片3MB</text> :class="{ 'icon-item-active': form.coverImage === icon.cover_image }"
@tap.stop="form.coverImage = icon.cover_image"
>
<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>
</view> </view>
<view class="modal-actions"> <view class="modal-actions">
<text class="modal-btn modal-btn-cancel" @tap="showForm = false">取消</text> <text class="modal-btn modal-btn-cancel" @tap.stop="closeForm">取消</text>
<text class="modal-btn modal-btn-confirm" @tap="submitGoal">确定</text> <text class="modal-btn modal-btn-confirm" @tap.stop="submitGoal">确定</text>
</view> </view>
</view> </view>
</view> </view>
@@ -117,7 +107,6 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import * as api from '@/api' import * as api from '@/api'
import { getUploadToken } from '@/api/auth'
const loading = ref(true) const loading = ref(true)
const goals = ref([]) const goals = ref([])
@@ -125,28 +114,10 @@ const currentTab = ref('active')
const showForm = ref(false) const showForm = ref(false)
const savedMoneyCent = ref(0) const savedMoneyCent = ref(0)
const dailySaveCent = ref(0) const dailySaveCent = ref(0)
const iconList = ref([])
const iconsLoading = ref(false)
const form = ref({ const form = ref({ title: '', priceYuan: '', coverImage: '' })
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 savedMoneyYuan = computed(() => Math.round(savedMoneyCent.value / 100)) const savedMoneyYuan = computed(() => Math.round(savedMoneyCent.value / 100))
@@ -167,69 +138,24 @@ function getEtaText(goal) {
return `${daysNeeded} 天可达成` return `${daysNeeded} 天可达成`
} }
function goBack() { async function openCreate() {
uni.navigateBack() form.value = { title: '', priceYuan: '', coverImage: '' }
}
function openCreate() {
form.value = { title: '', priceYuan: '', coverImage: '', uploadedUrl: '' }
showForm.value = true showForm.value = true
} if (iconList.value.length === 0) {
iconsLoading.value = true
async function chooseImage() { try {
uni.chooseImage({ const res = await api.listDreamPresets()
count: 1, iconList.value = res.data?.items || []
sizeType: ['compressed'], } catch (e) {
sourceType: ['album', 'camera'], console.error('fetch icons error:', e)
success: async (res) => { } finally {
const tempPath = res.tempFilePaths[0] iconsLoading.value = false
const fileSize = res.tempFiles[0].size
if (fileSize > 3 * 1024 * 1024) {
uni.showToast({ title: '图片不能超过 3MB', icon: 'none' })
return
}
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' })
}
} catch (e) {
console.error('upload error:', e)
uni.showToast({ title: '上传失败', icon: 'none' })
}
} }
}) }
} }
function ossUpload(tokenData, filePath) { function closeForm() {
return new Promise((resolve, reject) => { showForm.value = false
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
})
})
} }
async function submitGoal() { async function submitGoal() {
@@ -243,13 +169,11 @@ async function submitGoal() {
uni.showToast({ title: '请输入有效价格', icon: 'none' }) uni.showToast({ title: '请输入有效价格', icon: 'none' })
return return
} }
const coverImage = form.value.uploadedUrl || form.value.coverImage || ''
try { try {
await api.createRewardGoal({ await api.createRewardGoal({
title, title,
target_amount_cent: Math.round(price * 100), target_amount_cent: Math.round(price * 100),
cover_image: coverImage cover_image: form.value.coverImage || ''
}) })
showForm.value = false showForm.value = false
uni.showToast({ title: '添加成功', icon: 'success' }) uni.showToast({ title: '添加成功', icon: 'success' })
@@ -313,46 +237,11 @@ onMounted(async () => {
.page { .page {
min-height: 100vh; min-height: 100vh;
background: #F8FAFB; background: #F8FAFB;
padding-bottom: 60rpx; padding-bottom: 140rpx;
}
.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;
} }
.saved-banner { .saved-banner {
margin-top: 20rpx;
margin: 0 24rpx 20rpx; margin: 0 24rpx 20rpx;
border-radius: 28rpx; border-radius: 28rpx;
overflow: hidden; overflow: hidden;
@@ -644,21 +533,32 @@ onMounted(async () => {
box-sizing: border-box; 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 { .icon-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(5, 1fr);
gap: 16rpx; gap: 16rpx;
} }
.icon-item { .icon-item {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
gap: 6rpx; justify-content: center;
padding: 16rpx 8rpx; width: 100%;
aspect-ratio: 1;
border-radius: 16rpx; border-radius: 16rpx;
background: #f9fafb; background: #f9fafb;
border: 2rpx solid transparent; border: 2rpx solid transparent;
overflow: hidden;
transition: all 0.15s; transition: all 0.15s;
} }
@@ -668,45 +568,12 @@ onMounted(async () => {
} }
.icon-emoji { .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; font-size: 40rpx;
} }
.upload-text { .icon-img {
font-size: 22rpx; width: 100%;
color: #9ca3af; height: 100%;
} }
.modal-actions { .modal-actions {
@@ -734,4 +601,26 @@ onMounted(async () => {
color: #fff; color: #fff;
box-shadow: 0 8rpx 20rpx rgba(20, 147, 109, 0.2); 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> </style>