bindbox-mini/pages/shop/index.vue
邹方成 a350bcc4ed feat: 添加积分兑换商品功能及优化订单显示
- 在request.js中添加积分兑换商品API
- 在shop页面实现积分兑换功能及UI优化
- 在orders页面优化订单显示逻辑,支持优惠券和道具卡标签
- 在mine页面调整订单导航逻辑,支持跳转至cabinet指定tab
- 优化道具卡和优惠券的显示及状态处理
2025-12-22 21:06:54 +08:00

581 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="page">
<view class="bg-decoration"></view>
<!-- 顶部固定区域 -->
<view class="header-section">
<view class="search-box">
<view class="search-input-wrap">
<text class="search-icon">🔍</text>
<input class="search-input" v-model="keyword" placeholder="搜索心仪的商品" placeholder-class="placeholder-style" confirm-type="search" @confirm="onSearchConfirm" />
</view>
</view>
<view class="filter-row">
<view class="price-range">
<text class="price-label">价格区间</text>
<input class="price-input" type="number" v-model="minPrice" placeholder="最低" placeholder-class="price-ph" />
<text class="price-sep">-</text>
<input class="price-input" type="number" v-model="maxPrice" placeholder="最高" placeholder-class="price-ph" />
</view>
<button class="filter-btn" hover-class="btn-hover" @tap="onApplyFilters">筛选</button>
</view>
</view>
<!-- 占位防止内容被头部遮挡 -->
<view class="header-placeholder"></view>
<view v-if="loading && !products.length" class="loading-wrap"><view class="spinner"></view></view>
<view class="products-container" v-else>
<view v-if="products.length > 0" class="products-grid">
<view class="product-item" v-for="p in products" :key="p.id" @tap="onProductTap(p)">
<view class="product-card">
<view class="thumb-wrap">
<image class="product-thumb" :src="p.image" mode="aspectFill" lazy-load="true" />
<view class="stock-tag" v-if="p.stock !== null && p.stock < 10">仅剩{{p.stock}}</view>
</view>
<view class="product-info">
<text class="product-title">{{ p.title }}</text>
<view class="product-bottom">
<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>
</view>
</view>
<view v-else class="empty-state">
<image class="empty-img" src="/static/empty.png" mode="widthFix" />
<text class="empty-text">暂无相关商品</text>
</view>
</view>
</view>
</template>
<script setup>
import { onShow } from '@dcloudio/uni-app'
import { ref, computed } from 'vue'
import { request, authRequest } from '../../utils/request.js'
const products = ref([])
const CACHE_KEY = 'products_cache_v1'
const TTL_MS = 10 * 60 * 1000
const loading = ref(false)
const keyword = ref('')
const minPrice = ref('')
const maxPrice = ref('')
function apiGet(url, data = {}) {
const token = uni.getStorageSync('token')
const fn = token ? authRequest : request
return fn({ url, method: 'GET', data })
}
function cleanUrl(u) {
const s = String(u || '').trim()
const m = s.match(/https?:\/\/[^\s'"`]+/)
if (m && m[0]) return m[0]
return s.replace(/[`'\"]/g, '').trim()
}
function normalizeProducts(list) {
if (!Array.isArray(list)) return []
return list.map((i, idx) => ({
id: i.id ?? i.productId ?? i._id ?? i.sku_id ?? String(idx),
image: cleanUrl(i.main_image ?? i.imageUrl ?? i.image_url ?? i.image ?? i.img ?? i.pic ?? ''),
title: i.title ?? i.name ?? i.product_name ?? i.sku_name ?? '',
price: (() => { const raw = i.price_sale ?? i.price ?? i.price_min ?? i.amount ?? null; return raw == null ? null : (Number(raw) / 100) })(),
points: i.points_required ?? i.points ?? i.integral ?? null,
stock: i.stock ?? i.inventory ?? i.quantity ?? null,
link: cleanUrl(i.linkUrl ?? i.link_url ?? i.link ?? i.url ?? '')
})).filter(i => i.image || i.title)
}
function onProductTap(p) {
const id = p && p.id
if (id !== undefined && id !== null && id !== '') {
uni.navigateTo({ url: `/pages/shop/detail?id=${id}` })
return
}
if (p.link && /^\/.+/.test(p.link)) {
uni.navigateTo({ url: p.link })
}
}
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
function applyFilters() {
const k = String(keyword.value || '').trim().toLowerCase()
const min = Number(minPrice.value)
const max = Number(maxPrice.value)
const hasMin = !isNaN(min) && String(minPrice.value).trim() !== ''
const hasMax = !isNaN(max) && String(maxPrice.value).trim() !== ''
const list = allProducts.value
products.value = list.filter(p => {
const title = String(p.title || '').toLowerCase()
if (k && !title.includes(k)) return false
const priceNum = typeof p.price === 'number' ? p.price : Number(p.price)
if (hasMin) {
if (isNaN(priceNum)) return false
if (priceNum < min) return false
}
if (hasMax) {
if (isNaN(priceNum)) return false
if (priceNum > max) return false
}
return true
})
}
function onSearchConfirm() { applyFilters() }
function onApplyFilters() { applyFilters() }
async function loadProducts() {
try {
const cached = uni.getStorageSync(CACHE_KEY)
if (cached && cached.data && Date.now() - cached.ts < TTL_MS) {
allProducts.value = cached.data
applyFilters()
return
}
const first = await apiGet('/api/app/products', { page: 1 })
// Simple extraction
let list = []
let total = 0
if (first && first.list) { list = first.list; total = first.total }
else if (first && first.data && first.data.list) { list = first.data.list; total = first.data.total }
// If not too many, fetch all for better client UX
const pageSize = 20
const totalPages = Math.ceil((total || 0) / pageSize)
if (totalPages > 1) {
const tasks = []
for (let p = 2; p <= totalPages; p++) {
tasks.push(apiGet('/api/app/products', { page: p }))
}
const results = await Promise.allSettled(tasks)
results.forEach(r => {
if (r.status === 'fulfilled') {
const val = r.value
const subList = (val && val.list) || (val && val.data && val.data.list) || []
if (Array.isArray(subList)) list = list.concat(subList)
}
})
}
const normalized = normalizeProducts(list)
allProducts.value = normalized
applyFilters()
uni.setStorageSync(CACHE_KEY, { data: normalized, ts: Date.now() })
} catch (e) {
console.error(e)
products.value = []
}
}
onShow(async () => {
const token = uni.getStorageSync('token')
const phoneBound = !!uni.getStorageSync('phone_bound')
if (!token || !phoneBound) {
uni.showModal({
title: '提示',
content: '请先登录并绑定手机号',
confirmText: '去登录',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/login/index' })
}
}
})
return
}
loading.value = true
await loadProducts()
loading.value = false
})
// 分享功能
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
onShareAppMessage(() => {
const inviteCode = uni.getStorageSync('invite_code') || (uni.getStorageSync('user_info') || {}).invite_code || ''
return {
title: '柯大鸭潮玩商城 - 好物等你来兑',
path: `/pages/index/index?invite_code=${inviteCode}`,
imageUrl: '/static/logo.png'
}
})
onShareTimeline(() => {
const inviteCode = uni.getStorageSync('invite_code') || (uni.getStorageSync('user_info') || {}).invite_code || ''
return {
title: '柯大鸭潮玩商城 - 好物等你来兑',
query: `invite_code=${inviteCode}`,
imageUrl: '/static/logo.png'
}
})
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background-color: $bg-page;
padding-bottom: 40rpx;
position: relative;
overflow-x: hidden;
}
.bg-decoration {
position: fixed;
top: 0; left: 0; width: 100%; height: 100vh;
pointer-events: none;
z-index: 0;
&::before {
content: '';
position: absolute;
top: -100rpx; left: -100rpx;
width: 600rpx; height: 600rpx;
background: radial-gradient(circle, rgba($brand-primary, 0.1) 0%, transparent 70%);
filter: blur(60rpx);
border-radius: 50%;
opacity: 0.6;
animation: float 10s ease-in-out infinite;
}
&::after {
content: '';
position: absolute;
bottom: 10%; right: -100rpx;
width: 500rpx; height: 500rpx;
background: radial-gradient(circle, rgba($brand-secondary, 0.1) 0%, transparent 70%);
filter: blur(50rpx);
border-radius: 50%;
opacity: 0.5;
animation: float 15s ease-in-out infinite reverse;
}
}
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(30rpx, 50rpx); }
}
/* 顶部 Header */
.header-section {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: $bg-glass;
backdrop-filter: blur(20rpx);
padding: 0 24rpx 24rpx;
box-shadow: $shadow-sm;
border-bottom: 1rpx solid $border-color-light;
}
.header-placeholder {
height: 160rpx; /* 根据 header 高度调整 */
}
.page-title {
font-size: 36rpx;
font-weight: 800;
color: $text-main;
padding: 20rpx 0;
}
/* 搜索框 */
.search-box {
margin-bottom: 20rpx;
margin-top: 20rpx;
}
.search-input-wrap {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(255, 255, 255, 0.8);
border-radius: $radius-round;
padding: 18rpx 24rpx;
transition: all 0.3s;
}
.search-input-wrap:focus-within {
background: $bg-card;
border-color: $brand-primary;
box-shadow: 0 0 0 4rpx rgba($brand-primary, 0.1);
}
.search-icon {
font-size: 28rpx;
margin-right: 16rpx;
opacity: 0.5;
}
.search-input {
flex: 1;
font-size: 28rpx;
color: $text-main;
}
.placeholder-style {
color: $text-tertiary;
}
/* 筛选行 */
.filter-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
.price-range {
flex: 1;
display: flex;
align-items: center;
background: $bg-secondary;
border-radius: $radius-md;
padding: 10rpx 20rpx;
}
.price-label {
font-size: 24rpx;
color: $text-sub;
margin-right: 16rpx;
}
.price-input {
flex: 1;
font-size: 26rpx;
text-align: center;
color: $text-main;
}
.price-ph {
color: $text-tertiary;
font-size: 24rpx;
}
.price-sep {
color: $text-tertiary;
margin: 0 10rpx;
}
.filter-btn {
background: $gradient-brand;
color: $text-inverse;
font-size: 26rpx;
font-weight: 600;
border-radius: $radius-md;
padding: 0 32rpx;
height: 64rpx;
line-height: 64rpx;
border: none;
box-shadow: $shadow-sm;
}
.btn-hover {
opacity: 0.9;
transform: scale(0.98);
}
/* 商品 Grid 容器 */
.products-container {
padding: 24rpx;
}
.products-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
}
/* 商品卡片 */
.product-item {
animation: fadeInUp 0.5s ease-out backwards;
}
@for $i from 1 through 10 {
.product-item:nth-child(#{$i}) {
animation-delay: #{$i * 0.05}s;
}
}
.product-card {
background: $bg-card;
border-radius: $radius-lg;
overflow: hidden;
box-shadow: $shadow-card;
display: flex;
flex-direction: column;
transition: all 0.3s ease;
}
.product-item:active .product-card {
transform: scale(0.98);
box-shadow: $shadow-sm;
}
.thumb-wrap {
position: relative;
width: 100%;
padding-top: 100%; /* 1:1 Aspect Ratio */
background: $bg-secondary;
}
.product-thumb {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.stock-tag {
position: absolute;
bottom: 0;
right: 0;
background: rgba($accent-red, 0.9);
color: #fff;
font-size: 20rpx;
padding: 4rpx 12rpx;
border-top-left-radius: 12rpx;
backdrop-filter: blur(4px);
font-weight: 700;
box-shadow: 0 -2rpx 8rpx rgba(0,0,0,0.1);
}
.product-info {
padding: 20rpx;
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.product-title {
font-size: 28rpx;
color: $text-main;
line-height: 1.4;
height: 2.8em; /* 2 lines */
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
margin-bottom: 16rpx;
font-weight: 600;
}
.product-bottom {
display: flex;
align-items: flex-end;
justify-content: space-between;
flex-wrap: wrap;
gap: 8rpx;
}
.price-row {
display: flex; align-items: baseline;
}
.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;
padding: 8rpx 20rpx;
border-radius: 30rpx;
margin-left: auto;
}
/* Loading & Empty */
.loading-wrap {
padding: 100rpx 0;
display: flex;
justify-content: center;
}
.spinner {
width: 50rpx;
height: 50rpx;
border: 4rpx solid rgba($brand-primary, 0.2);
border-top-color: $brand-primary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 100rpx;
}
.empty-img {
width: 240rpx;
margin-bottom: 24rpx;
opacity: 0.6;
}
.empty-text {
color: $text-tertiary;
font-size: 28rpx;
}
.check-text {
font-size: 26rpx;
color: $text-sub;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>