feat: 添加积分兑换商品功能及优化订单显示

- 在request.js中添加积分兑换商品API
- 在shop页面实现积分兑换功能及UI优化
- 在orders页面优化订单显示逻辑,支持优惠券和道具卡标签
- 在mine页面调整订单导航逻辑,支持跳转至cabinet指定tab
- 优化道具卡和优惠券的显示及状态处理
This commit is contained in:
邹方成 2025-12-22 21:06:54 +08:00
parent be57eda392
commit a350bcc4ed
8 changed files with 505 additions and 94 deletions

View File

@ -1133,15 +1133,22 @@ async function fetchPropCards() {
const user_id = uni.getStorageSync('user_id')
if (!user_id) return
try {
const res = await getItemCards(user_id, 0)
// Status 1 = Unused
const res = await getItemCards(user_id, 1)
let list = []
if (Array.isArray(res)) list = res
else if (res && Array.isArray(res.list)) list = res.list
else if (res && Array.isArray(res.data)) list = res.data
propCards.value = list.map((i, idx) => ({
id: i.id ?? i.card_id ?? i.item_card_id ?? String(idx),
name: i.name ?? i.title ?? i.card_name ?? '道具卡'
}))
propCards.value = list.map((i, idx) => {
const count = i.count ?? i.remaining ?? 1
const name = i.name ?? i.title ?? i.card_name ?? '道具卡'
return {
id: i.id ?? i.card_id ?? i.item_card_id ?? String(idx),
name: `${name} (×${count})`,
rawName: name,
count: count
}
})
} catch (e) {
propCards.value = []
}

View File

@ -159,6 +159,15 @@ const isAllSelected = computed(() => {
})
onShow(() => {
// Check for external tab switch request
try {
const targetTab = uni.getStorageSync('cabinet_target_tab')
if (targetTab !== '' && targetTab !== null && targetTab !== undefined) {
currentTab.value = Number(targetTab)
uni.removeStorageSync('cabinet_target_tab')
}
} catch (e) {}
const token = uni.getStorageSync('token')
const phoneBound = !!uni.getStorageSync('phone_bound')
console.log('cabinet onShow token:', token, 'isLogin:', !!token, 'phoneBound:', phoneBound)

View File

@ -93,30 +93,34 @@
<text class="section-more" @click="toOrders('all')">全部订单 </text>
</view>
<view class="grid-row">
<view class="grid-item" @click="toOrders('pending')">
<view class="icon-wrapper">
<image class="grid-icon-img" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0OCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiNGRjZCMDAiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0yMSA4YTIgMiAwIDAgMC0xLTEuNzNsLTctNGEyIDIgMCAwIDAtMiAwbC03IDRBMiAyIDAgMCAwIDMgOHY4YTIgMiAwIDAgMCAxIDEuNzNsNyA0YTIgMiAwIDAgMCAyIDBsNy00QTIgMiAwIDAgMCAyMSAxNlY4eiIvPjxwYXRoIGQ9Ik0zLjI3IDYuOTZMMTIgMTIuMDFsMTAgLTUuMDUiLz48cGF0aCBkPSJNMTIgMjIuMDhWMTIiLz48L3N2Zz4=" mode="aspectFit"></image>
</view>
<text class="grid-label">盒柜</text>
</view>
<!-- 1. 待付款 -->
<view class="grid-item" @click="toOrders('pending')">
<view class="icon-wrapper">
<image class="grid-icon-img" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0OCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiNGRjZCMDAiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxyZWN0IHg9IjEiIHk9IjQiIHdpZHRoPSIyMiIgaGVpZ2h0PSIxNiIgcng9IjIiIHJ5PSIyIj48L3JlY3Q+PGxpbmUgeDE9IjEiIHkxPSIxMCIgeDI9IjIzIiB5Mj0iMTAiPjwvbGluZT48L3N2Zz4=" mode="aspectFit"></image>
</view>
<text class="grid-label">待付款</text>
</view>
<view class="grid-item" @click="toOrders('pending')">
<!-- 2. 待发货 (Jump to Cabinet Tab 1) -->
<view class="grid-item" @click="toCabinetTab(1)">
<view class="icon-wrapper">
<image class="grid-icon-img" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0OCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiNGRjZCMDAiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0xMiAyMmg1LjlhMiAyIDAgMCAwIDEuOS0xLjQybDMuMTMtMTEuMDlhMiAyIDAgMCAwLTEuOS0yLjUxaC00LjE2IiAvPjxwYXRoIGQ9Ik0xOC40MiA5aC02LjMyIiAvPjxwYXRoIGQ9Ik0xNSA2VjNhMSAxIDAgMCAwLTEtMUg2YTEgMSAwIDAgMC0xIDF2MTQiIC8+PHBhdGggZD0iTTkgMTNoNyIgLz48L3N2Zz4=" mode="aspectFit"></image>
</view>
<text class="grid-label">待发货</text>
</view>
<view class="grid-item" @click="toOrders('completed')">
<!-- 3. 已发货 (Jump to Cabinet Tab 1) -->
<view class="grid-item" @click="toCabinetTab(1)">
<view class="icon-wrapper">
<image class="grid-icon-img" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0OCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiNGRjZCMDAiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxyZWN0IHg9IjEiIHk9IjMiIHdpZHRoPSIxNSIgaGVpZ2h0PSIxMyIgcng9IjIiIHJ5PSIyIj48L3JlY3Q+PHBvbHlsaW5lIHBvaW50cz0iMTYgOCAyMCA4IDIwIDIxIDIgMjEgMiAxNiA2IDE2IiAvPjwvc3ZnPg==" mode="aspectFit"></image>
</view>
<text class="grid-label">已发货</text>
</view>
<!-- 4. 全部订单 (Was Box Cabinet) -->
<view class="grid-item" @click="toOrders('completed')">
<view class="icon-wrapper">
<image class="grid-icon-img" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0OCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiNGRjZCMDAiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0yMSA4YTIgMiAwIDAgMC0xLTEuNzNsLTctNGEyIDIgMCAwIDAtMiAwbC03IDRBMiAyIDAgMCAwIDMgOHY4YTIgMiAwIDAgMCAxIDEuNzNsNyA0YTIgMiAwIDAgMCAyIDBsNy00QTIgMiAwIDAgMCAyMSAxNlY4eiIvPjxwYXRoIGQ9Ik0zLjI3IDYuOTZMMTIgMTIuMDFsMTAgLTUuMDUiLz48cGF0aCBkPSJNMTIgMjIuMDhWMTIiLz48L3N2Zz4=" mode="aspectFit"></image>
</view>
<text class="grid-label">全部订单</text>
</view>
</view>
</view>
@ -254,7 +258,12 @@
</view>
<view class="coupon-footer-row">
<text class="coupon-expire-v2">{{ formatCouponExpiry(item) }}</text>
<view class="coupon-footer-left">
<text class="coupon-expire-v2">{{ formatCouponExpiry(item) }}</text>
<!-- 使用时间 (已使用状态) -->
<text class="coupon-used-time" v-if="couponsTab === 2 && item.used_at">使用时间{{ formatDateTime(item.used_at) }}</text>
</view>
<view class="coupon-action-v2" v-if="couponsTab === 1">
<text class="use-btn-v2">去使用</text>
</view>
@ -262,9 +271,6 @@
<text class="status-tag" :class="couponsTab === 2 ? 'used' : 'expired'">{{ couponsTab === 2 ? '已使用' : '已过期' }}</text>
</view>
</view>
<!-- 使用时间 (已使用状态) -->
<text class="coupon-used-time" v-if="couponsTab === 2 && item.used_at">使用时间{{ formatDateTime(item.used_at) }}</text>
</view>
</view>
@ -598,6 +604,10 @@ export default {
toOrders(status) {
uni.navigateTo({ url: `/pages/orders/index?status=${status}` })
},
toCabinetTab(tabIndex) {
uni.setStorageSync('cabinet_target_tab', tabIndex)
uni.switchTab({ url: '/pages/cabinet/index' })
},
toAddresses() {
uni.navigateTo({ url: '/pages/address/index' })
},
@ -790,23 +800,27 @@ export default {
this.itemCardsLoading = true
this.itemCardsList = []
try {
// Mocking used status via local filter if API doesn't support
// Or assume API supports status param.
const res = await getItemCards(this.userId)
// Assuming res is list of cards with counts.
// For "used", we might need a different API or field.
// For now, just show all for unused, and empty for used as mock.
let list = Array.isArray(res) ? res : (res.list || [])
// Pass status: 0(tab)=>1(unused), 1(tab)=>2(used)
const status = this.itemCardsTab === 0 ? 1 : 2
const res = await getItemCards(this.userId, status)
// Robustly get the list (support res.list or res.data)
let list = Array.isArray(res) ? res : (res.list || res.data || [])
this.itemCardsList = list.map(item => {
return {
...item,
// Ensure count exists, default to 1 if undefined
count: item.count ?? item.remaining ?? 1
}
})
// For unused tab, filter out items with 0 count if any
if (this.itemCardsTab === 0) {
// Show cards with count > 0
this.itemCardsList = list.filter(i => (i.count || i.remaining) > 0)
} else {
// Mock used history or filter
this.itemCardsList = []
this.itemCardsList = this.itemCardsList.filter(i => i.count > 0)
}
} catch (e) {
console.error(e)
console.error('loadItemCards error:', e)
} finally {
this.itemCardsLoading = false
}
@ -882,8 +896,24 @@ export default {
},
formatDateTime(ts) {
if (!ts) return ''
const d = new Date(ts * 1000)
return `${this.formatDate(ts)} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`
// Convert string timestamp (e.g. "2023-01-01T...") to timestamp if needed
let d
if (typeof ts === 'string') {
// iOS compatibility for ISO strings
const safeTs = ts.replace(/-/g, '/')
d = new Date(safeTs)
if (isNaN(d.getTime())) {
// Fallback for non-ISO strings or just numbers as strings
const n = Number(ts)
if (!isNaN(n)) d = new Date(n * 1000)
}
} else {
d = new Date(ts * 1000)
}
if (!d || isNaN(d.getTime())) return ''
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`
}
}
}
@ -1166,11 +1196,67 @@ export default {
line-height: 1;
padding: 10rpx;
}
.status-text, .empty-state {
padding: 60rpx; text-align: center; color: $text-sub; font-size: 26rpx;
}
.empty-icon { font-size: 80rpx; display: block; margin-bottom: 20rpx; }
.no-more {
text-align: center;
font-size: 24rpx;
color: $text-tertiary;
padding: 30rpx 0;
display: flex;
align-items: center;
justify-content: center;
.text {
margin: 0 16rpx;
}
.divider {
width: 60rpx;
height: 1px;
background: #e0e0e0;
}
}
/* 弹窗 Tab 栏 */
.popup-tabs {
display: flex;
justify-content: space-around;
padding: 0 20rpx;
background: #fff;
border-bottom: 1rpx solid $border-color-light;
margin-bottom: 20rpx;
}
.popup-tab {
flex: 1;
text-align: center;
font-size: 28rpx;
color: $text-sub;
padding: 24rpx 0;
position: relative;
font-weight: 500;
transition: all 0.2s;
}
.popup-tab.active {
color: $text-main;
font-weight: 700;
}
.popup-tab.active::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 40rpx;
height: 6rpx;
background: $brand-primary;
border-radius: 6rpx;
}
/* 列表滚动区 */
.points-list, .coupon-scroll, .item-cards-scroll, .task-list-scroll, .invite-list-scroll {
flex: 1; min-height: 400rpx; background: $bg-grey; padding: 20rpx;
@ -1197,20 +1283,103 @@ export default {
.coupon-left-v2 {
width: 180rpx; background: linear-gradient(135deg, #FFF5E6, #fff);
display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 20rpx; border-right: 2rpx dashed #E0E0E0;
padding: 20rpx;
position: relative;
}
.coupon-remaining { color: $brand-primary; font-weight: 900; }
.coupon-symbol { font-size: 24rpx; }
.coupon-amount-num { font-size: 56rpx; line-height: 1; }
.coupon-label { font-size: 20rpx; color: $brand-primary; margin-top: 8rpx; border: 1px solid $brand-primary; padding: 2rpx 8rpx; border-radius: 6rpx; }
.coupon-right-v2 { flex: 1; padding: 24rpx; display: flex; flex-direction: column; justify-content: space-between; }
.coupon-name-v2 { font-size: $font-md; font-weight: 700; color: $text-main; margin-bottom: 8rpx; }
/* 优惠券分割线 */
.coupon-divider-v2 {
width: 30rpx;
position: relative;
background: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
}
.divider-notch {
width: 24rpx;
height: 24rpx;
background: $bg-grey; /* Match scroll container bg */
border-radius: 50%;
position: absolute;
left: 50%;
transform: translateX(-50%);
z-index: 2;
}
.divider-notch.top { top: -12rpx; }
.divider-notch.bottom { bottom: -12rpx; }
.divider-dash {
width: 0;
height: 80%;
border-left: 2rpx dashed #eee;
}
.coupon-right-v2 {
flex: 1;
padding: 24rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
overflow: hidden; /* Ensure content stays inside */
}
.coupon-name-v2 {
font-size: $font-md;
font-weight: 700;
color: $text-main;
margin-bottom: 8rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.coupon-original { font-size: 20rpx; color: $text-tertiary; text-decoration: line-through; margin-left: 8rpx; display: inline-block; }
.coupon-rules { font-size: $font-xs; color: $text-sub; margin-bottom: 16rpx; }
.coupon-footer-row { display: flex; justify-content: space-between; align-items: center; margin-top: auto; }
/* 优惠券进度条 */
.coupon-progress-wrap {
margin-bottom: 12rpx;
}
.coupon-progress-bar {
height: 6rpx;
background: $bg-secondary;
border-radius: 100rpx;
overflow: hidden;
margin-bottom: 4rpx;
}
.coupon-progress-fill {
height: 100%;
background: $brand-primary;
border-radius: 100rpx;
}
.coupon-progress-text {
font-size: 18rpx;
color: $text-tertiary;
}
.coupon-footer-row { display: flex; justify-content: space-between; align-items: flex-end; margin-top: auto; }
.coupon-footer-left { display: flex; flex-direction: column; }
.coupon-expire-v2 { font-size: 20rpx; color: $text-tertiary; }
.use-btn-v2 { background: $brand-primary; color: #fff; font-size: 22rpx; padding: 8rpx 24rpx; border-radius: 100rpx; }
.status-tag { font-size: 22rpx; color: $text-tertiary; background: #F5F5F5; padding: 4rpx 12rpx; border-radius: 6rpx; }
.coupon-used-time { font-size: 18rpx; color: $text-tertiary; margin-top: 4rpx; text-align: left; }
/* 过期/已使用状态 */
.coupon-used .coupon-left-v2, .coupon-expired .coupon-left-v2 {
background: #f9f9f9;
}
.coupon-used .coupon-remaining, .coupon-expired .coupon-remaining,
.coupon-used .coupon-label, .coupon-expired .coupon-label {
color: $text-tertiary;
border-color: $text-tertiary;
}
.coupon-used .coupon-name-v2, .coupon-expired .coupon-name-v2 {
color: $text-sub;
}
/* 道具卡 */
.item-cards-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20rpx; }
@ -1227,6 +1396,41 @@ export default {
.card-count-badge { position: absolute; top: 20rpx; right: 20rpx; background: rgba(0,0,0,0.05); padding: 4rpx 12rpx; border-radius: 100rpx; }
.count-num { font-size: 22rpx; font-weight: 700; color: $text-main; }
/* 道具卡已使用状态 */
.item-card.used {
background: #fafafa;
}
.item-card.used .card-name {
color: $text-sub;
}
.item-card.used .card-desc {
color: $text-tertiary;
}
.item-card.used .card-icon-wrap {
background: #f0f0f0;
opacity: 0.6;
}
.card-used-badge {
position: absolute;
top: 20rpx;
right: 20rpx;
background: #eee;
padding: 4rpx 12rpx;
border-radius: 100rpx;
}
.used-text {
font-size: 22rpx;
color: $text-tertiary;
}
.card-use-time {
font-size: 18rpx;
color: $text-tertiary;
margin-top: 12rpx;
display: block;
border-top: 1rpx dashed #eee;
padding-top: 8rpx;
}
/* 任务中心弹窗 */
.task-center-popup { background: #F8F9FA; }
.overall-progress {

View File

@ -63,8 +63,8 @@
</view>
<view class="item-meta">
<view class="price-wrap">
<text class="currency">¥</text>
<text class="price">{{ formatPrice(item.price) }}</text>
<text class="currency" v-if="item.price > 0">¥</text>
<text class="price">{{ item.price > 0 ? formatPrice(item.price) : '奖品' }}</text>
</view>
<text class="item-quantity">x{{ item.quantity }}</text>
</view>
@ -87,8 +87,8 @@
</view>
<view class="item-meta">
<view class="price-wrap">
<text class="currency">¥</text>
<text class="price">{{ formatPrice(order.actual_amount) }}</text>
<text class="currency" v-if="order.actual_amount > 0">¥</text>
<text class="price">{{ order.actual_amount > 0 ? formatPrice(order.actual_amount) : '奖品' }}</text>
</view>
<text class="item-quantity">x1</text>
</view>
@ -130,19 +130,39 @@
<text class="label">商品总额</text>
<text class="value">¥{{ formatPrice(order.total_amount) }}</text>
</view>
<view class="info-row" v-if="order.discount_amount">
<text class="label">优惠金额</text>
<!-- 优惠券 -->
<view class="info-row" v-if="order.coupon_info">
<text class="label">优惠券</text>
<view class="value-wrap">
<text class="tag-small coupon">{{ order.coupon_info.name }}</text>
<text class="value discount">-¥{{ formatPrice(order.coupon_info.value) }}</text>
</view>
</view>
<!-- 道具卡 -->
<view class="info-row" v-if="order.item_card_info">
<text class="label">道具卡</text>
<view class="value-wrap">
<text class="tag-small card">{{ order.item_card_info.name }}</text>
<text class="value" v-if="order.item_card_info.effect_type === 1">双倍奖励</text>
<text class="value" v-else>已使用</text>
</view>
</view>
<view class="info-row" v-if="order.discount_amount && !order.coupon_info">
<text class="label">其他优惠</text>
<text class="value discount">-¥{{ formatPrice(order.discount_amount) }}</text>
</view>
<view class="divider"></view>
<view class="total-row">
<text class="total-label">实付款</text>
<view class="total-price-wrap">
<text class="currency">¥</text>
<text class="total-price">{{ formatPrice(order.actual_amount) }}</text>
</view>
<text class="total-label">{{ order.actual_amount > 0 ? '实付款' : '状态' }}</text>
<view class="total-price-wrap">
<text class="currency" v-if="order.actual_amount > 0">¥</text>
<text class="total-price">{{ order.actual_amount > 0 ? formatPrice(order.actual_amount) : '无需支付' }}</text>
</view>
</view>
</view>
<!-- 抽奖凭证有凭证数据时显示 -->
<view class="section-card proof-section" v-if="order.draw_receipts && order.draw_receipts.length > 0">
@ -656,6 +676,24 @@ function showProofHelp() {
background: $bg-secondary;
}
}
.tag-small {
font-size: 20rpx;
padding: 2rpx 8rpx;
border-radius: 6rpx;
&.coupon {
color: #FF6B6B;
background: rgba(255, 107, 107, 0.1);
border: 1rpx solid rgba(255, 107, 107, 0.2);
}
&.card {
color: #6C5CE7;
background: rgba(108, 92, 231, 0.1);
border: 1rpx solid rgba(108, 92, 231, 0.2);
}
}
}
.divider {

View File

@ -82,6 +82,8 @@
<view class="product-meta">
<text class="meta-item" v-if="item.activity_name">{{ item.activity_name }}</text>
<text class="meta-item" v-if="item.issue_number">{{ item.issue_number }}</text>
<text class="meta-item coupon-tag" v-if="item.coupon_info">: {{ item.coupon_info.name }}</text>
<text class="meta-item card-tag" v-if="item.item_card_info">: {{ item.item_card_info.name }}</text>
</view>
<text class="order-time">{{ formatTime(item.created_at) }}</text>
</view>
@ -94,9 +96,9 @@
<text class="no-value">{{ item.order_no }}</text>
</view>
<view class="order-amount">
<text class="amount-label">实付</text>
<text class="amount-value">{{ formatAmount(item.actual_amount || item.total_amount) }}</text>
</view>
<text class="amount-label" v-if="shouldShowAmountLabel(item)">实付</text>
<text class="amount-value">{{ getAmountText(item) }}</text>
</view>
</view>
<!-- 快捷操作 -->
@ -167,6 +169,22 @@ function formatAmount(a) {
return `¥${yuan.toFixed(2)}`
}
function shouldShowAmountLabel(item) {
const amount = item.actual_amount || item.total_amount
return amount > 0
}
function getAmountText(item) {
const amount = item.actual_amount || item.total_amount
if (amount > 0) return formatAmount(amount)
// 0
if (item.source_type === 3 || item.source_type === 2) {
return '奖品'
}
return '免费'
}
function getOrderTitle(item) {
// 1. 使 items
if (item.items && item.items.length > 0 && item.items[0].title) {
@ -724,6 +742,14 @@ onReachBottom(() => {
background: $bg-secondary;
padding: 4rpx 12rpx;
border-radius: $radius-sm;
.coupon-tag {
color: #FF6B6B;
background: rgba(255, 107, 107, 0.1);
}
.card-tag {
color: #6C5CE7;
background: rgba(108, 92, 231, 0.1);
}
}
.order-time {
font-size: $font-xs;

View File

@ -7,14 +7,25 @@
<view class="info-card">
<view class="title">{{ detail.title || detail.name || '-' }}</view>
<view class="price-row">
<text class="price">¥{{ formatPrice(detail.price_sale || detail.price) }}</text>
<text class="points" v-if="detail.points_required">{{ detail.points_required }}积分</text>
<view class="points-wrap" v-if="detail.points_required">
<text class="points-val">{{ detail.points_required }}</text>
<text class="points-unit">积分</text>
</view>
<text class="price" v-else>¥{{ formatPrice(detail.price_sale || detail.price) }}</text>
</view>
<view class="stock" v-if="detail.stock !== null && detail.stock !== undefined">库存{{ detail.stock }}</view>
<view class="desc" v-if="detail.description">{{ detail.description }}</view>
</view>
</view>
<view v-else class="empty">商品不存在</view>
<!-- Action Bar Moved Outside info-card -->
<view class="action-bar-placeholder" v-if="detail.id"></view>
<view class="action-bar" v-if="detail.id">
<view class="action-btn redeem" v-if="detail.points_required" @tap="onRedeem">立即兑换</view>
<view class="action-btn buy" v-else @tap="onBuy">立即购买</view>
</view>
</view>
</template>
@ -22,6 +33,7 @@
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getProductDetail } from '../../api/appUser'
import { redeemProductByPoints } from '../../utils/request.js'
const detail = ref({})
const loading = ref(false)
@ -43,6 +55,52 @@ async function fetchDetail(id) {
}
}
function onBuy() {
uni.showToast({ title: '暂未开放购买', icon: 'none' })
}
async function onRedeem() {
const p = detail.value
if (!p || !p.id) return
const token = uni.getStorageSync('token')
if (!token) {
uni.showModal({
title: '提示',
content: '请先登录',
confirmText: '去登录',
success: (res) => { if (res.confirm) uni.navigateTo({ url: '/pages/login/index' }) }
})
return
}
uni.showModal({
title: '确认兑换',
content: `是否消耗 ${p.points_required} 积分兑换 ${p.title}`,
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '兑换中...' })
try {
const userId = uni.getStorageSync('user_id')
if (!userId) throw new Error('用户ID不存在')
await redeemProductByPoints(userId, p.id, 1)
uni.showToast({ title: '兑换成功', icon: 'success' })
// Refresh detail
setTimeout(() => {
fetchDetail(p.id)
}, 1500)
} catch (e) {
uni.showToast({ title: e.message || '兑换失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
}
})
}
onLoad((opts) => {
const id = opts && opts.id
if (id) fetchDetail(id)
@ -81,15 +139,14 @@ onLoad((opts) => {
}
.info-card {
margin: $spacing-lg;
margin-top: -60rpx;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20rpx);
border-radius: $radius-xl;
background: #fff;
border-radius: $radius-xl $radius-xl 0 0;
padding: $spacing-xl;
box-shadow: $shadow-lg;
box-shadow: 0 -4rpx 20rpx rgba(0,0,0,0.05);
position: relative;
z-index: 2;
margin-top: -40rpx; /* Slight overlap */
min-height: 50vh;
}
.title {
@ -120,12 +177,19 @@ onLoad((opts) => {
}
}
.points {
font-size: $font-sm;
color: $brand-primary;
padding: 6rpx $spacing-md;
background: rgba($brand-primary, 0.1);
border-radius: 100rpx;
.points-wrap {
display: flex; align-items: baseline;
}
.points-val {
font-size: 48rpx;
font-weight: 900;
color: #FF9800;
font-family: 'DIN Alternate', sans-serif;
}
.points-unit {
font-size: 24rpx;
color: #FF9800;
margin-left: 6rpx;
font-weight: 600;
}
@ -156,6 +220,33 @@ onLoad((opts) => {
}
}
.action-bar-placeholder { height: 120rpx; }
.action-bar {
position: fixed;
bottom: 0; left: 0; right: 0;
padding: 20rpx 40rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
background: rgba(255,255,255,0.9);
backdrop-filter: blur(10px);
box-shadow: 0 -4rpx 20rpx rgba(0,0,0,0.05);
display: flex;
justify-content: flex-end;
z-index: 10;
}
.action-btn {
width: 100%;
height: 88rpx;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
font-weight: 700;
color: #fff;
}
.action-btn.redeem { background: linear-gradient(135deg, #FFB74D, #FF9800); box-shadow: 0 8rpx 20rpx rgba(255, 152, 0, 0.3); }
.action-btn.buy { background: linear-gradient(135deg, #FF6B6B, #FF3B30); box-shadow: 0 8rpx 20rpx rgba(255, 59, 48, 0.3); }
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(40rpx); }
to { opacity: 1; transform: translateY(0); }

View File

@ -37,14 +37,15 @@
<view class="product-info">
<text class="product-title">{{ p.title }}</text>
<view class="product-bottom">
<view class="price-row">
<text class="price-symbol"></text>
<text class="price-val">{{ p.price }}</text>
</view>
<view class="points-badge" v-if="p.points">
<view class="price-row" v-if="p.points">
<text class="points-val">{{ p.points }}</text>
<text class="points-unit">积分</text>
</view>
<view class="price-row" v-else>
<text class="price-symbol"></text>
<text class="price-val">{{ p.price }}</text>
</view>
<view class="redeem-btn" v-if="p.points" @tap.stop="onRedeemTap(p)">兑换</view>
</view>
</view>
</view>
@ -108,6 +109,46 @@ function onProductTap(p) {
}
}
async function onRedeemTap(p) {
const token = uni.getStorageSync('token')
if (!token) {
uni.showModal({
title: '提示',
content: '请先登录',
confirmText: '去登录',
success: (res) => { if (res.confirm) uni.navigateTo({ url: '/pages/login/index' }) }
})
return
}
uni.showModal({
title: '确认兑换',
content: `是否消耗 ${p.points} 积分兑换 ${p.title}`,
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '兑换中...' })
try {
// Get user_id from storage
const userId = uni.getStorageSync('user_id')
if (!userId) throw new Error('用户ID不存在')
await redeemProductByPoints(userId, p.id, 1)
uni.showToast({ title: '兑换成功', icon: 'success' })
// Refresh products to update stock/points if needed
setTimeout(() => {
loadProducts()
}, 1500)
} catch (e) {
uni.showToast({ title: e.message || '兑换失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
}
})
}
// Filter logic
const allProducts = ref([]) // Store all fetched products for client-side filtering
@ -473,33 +514,20 @@ onShareTimeline(() => {
gap: 8rpx;
}
.price-row {
color: $accent-red;
font-weight: 700;
display: flex;
align-items: baseline;
display: flex; align-items: baseline;
}
.price-symbol {
.points-val { font-size: 36rpx; font-weight: 700; color: #FF9800; }
.points-unit { font-size: 22rpx; color: #FF9800; margin-left: 4rpx; }
.price-symbol { font-size: 24rpx; color: #FF3B30; }
.price-val { font-size: 36rpx; font-weight: 700; color: #FF3B30; }
.redeem-btn {
background: #FF9800;
color: #fff;
font-size: 24rpx;
}
.price-val {
font-size: 34rpx;
}
.points-badge {
background: rgba($brand-primary, 0.1);
color: $brand-primary;
border: 1px solid rgba($brand-primary, 0.2);
border-radius: 8rpx;
padding: 2rpx 10rpx;
display: flex;
align-items: center;
gap: 4rpx;
}
.points-val {
font-size: 24rpx;
font-weight: 700;
}
.points-unit {
font-size: 20rpx;
padding: 8rpx 20rpx;
border-radius: 30rpx;
margin-left: auto;
}
/* Loading & Empty */

View File

@ -66,6 +66,14 @@ export function authRequest(options) {
return request({ ...options, header })
}
export function redeemProductByPoints(user_id, product_id, count) {
return authRequest({
url: '/api/app/users/points/redeem-product',
method: 'POST',
data: { user_id, product_id, count }
})
}
function getLanguage() {
try { return (uni.getSystemInfoSync().language || 'zh-CN') } catch (_) { return 'zh-CN' }
}