feat: 梦想清单页与戒烟相关 API
- 梦想清单:系统导航栏、浮动添加、图标来自后台预设 - dream-presets API、pages.json 导航样式 Made-with: Cursor
This commit is contained in:
@@ -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 })
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
{
|
||||
"path": "pages/dream-goals/index",
|
||||
"style": {
|
||||
"navigationStyle": "default",
|
||||
"navigationBarTitleText": "梦想清单"
|
||||
}
|
||||
}
|
||||
|
||||
+80
-191
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user