bindbox-mini/pages/shop/index.vue

485 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="tab-row">
<view
v-for="tab in tabs"
:key="tab.id"
class="tab-item"
:class="{ active: currentTab === tab.id }"
@tap="switchTab(tab.id)"
>
<text class="tab-text">{{ tab.name }}</text>
<view class="tab-line" v-if="currentTab === tab.id"></view>
</view>
</view>
</view>
<!-- 占位 -->
<view class="header-placeholder"></view>
<view v-if="loading && !items.length" class="loading-wrap"><view class="spinner"></view></view>
<view class="content-container" v-else>
<!-- 商品视图 (网格) -->
<view v-if="currentTab === 'product'" class="products-grid">
<view class="product-item" v-for="p in items" :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 > 0">仅剩{{p.stock}}</view>
<view class="stock-tag out" v-else-if="p.stock === 0">已罄</view>
</view>
<view class="product-info">
<text class="product-title">{{ p.title }}</text>
<view class="product-bottom">
<view class="price-row">
<text class="points-val">{{ p.points || 0 }}</text>
<text class="points-unit">积分</text>
</view>
<view class="redeem-btn" @tap.stop="onRedeemTap(p)">兑换</view>
</view>
</view>
</view>
</view>
</view>
<!-- 优惠券视图 (票据样式列表) -->
<view v-else-if="currentTab === 'coupon'" class="coupons-list">
<view class="coupon-card" v-for="c in items" :key="c.id">
<view class="coupon-left">
<view class="coupon-val-row">
<text class="coupon-symbol">¥</text>
<text class="coupon-val">{{ (c.discount_value || 0) / 100 }}</text>
</view>
<text class="coupon-limit" v-if="c.min_spend > 0">{{ (c.min_spend || 0) / 100 }}可用</text>
<text class="coupon-limit" v-else>无门槛</text>
</view>
<view class="coupon-divider">
<view class="notch top"></view>
<view class="dash"></view>
<view class="notch bottom"></view>
</view>
<view class="coupon-right">
<view class="coupon-name">{{ c.title || c.name }}</view>
<view class="coupon-bottom">
<view class="coupon-price">
<text class="p-val">{{ c.points || 0 }}</text>
<text class="p-unit">积分</text>
</view>
<view class="coupon-btn" @tap="onRedeemTap(c)">立即兑换</view>
</view>
</view>
</view>
</view>
<!-- 道具卡视图 (列表卡片) -->
<view v-else-if="currentTab === 'item_card'" class="item-cards-list">
<!-- TODO: 后续我们要打开的 -->
<view class="empty-state" style="padding-top: 50rpx;">
<image class="empty-img" src="/static/empty.png" mode="widthFix" />
<text class="empty-text">暂未开放</text>
</view>
</view>
<view v-if="!items.length && currentTab !== 'item_card'" class="empty-state">
<image class="empty-img" src="/static/empty.png" mode="widthFix" />
<text class="empty-text">暂无相关兑换项</text>
</view>
<!-- 加载更多 -->
<view v-if="loading && items.length > 0" class="loading-more">
<view class="spinner"></view>
<text>加载更多...</text>
</view>
<view v-else-if="!hasMore && items.length > 0" class="no-more">- 到底啦 -</view>
</view>
</view>
</template>
<script setup>
import { onShow, onReachBottom } from '@dcloudio/uni-app'
import { ref, watch } from 'vue'
import { getStoreItems, redeemProductByPoints, redeemCouponByPoints, redeemItemCardByPoints } from '../../api/appUser'
const loading = ref(false)
const keyword = ref('')
const currentTab = ref('product')
const items = ref([])
const allItems = ref([])
const page = ref(1)
const pageSize = 20
const hasMore = ref(true)
const tabs = [
{ id: 'product', name: '商品' },
{ id: 'coupon', name: '优惠券' },
{ id: 'item_card', name: '道具卡' }
]
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 normalizeItems(list, kind) {
if (!Array.isArray(list)) return []
return list.map((i, idx) => ({
id: i.id,
kind: i.kind || kind,
image: cleanUrl(i.main_image || i.image || ''),
title: i.name || i.title || '',
price: i.price || i.discount_value || 0,
points: i.points_required ? Math.floor(i.points_required / 100) : (i.price ? Math.floor(i.price / 100) : (i.discount_value ? Math.floor(i.discount_value / 100) : 0)),
stock: i.in_stock ? 99 : 0, // Simplified stock check if returned as bool
discount_value: i.discount_value || 0,
min_spend: i.min_spend || 0,
description: i.description || ''
})).filter(i => i.title)
}
function switchTab(id) {
if (currentTab.value === id) return
currentTab.value = id
loading.value = true
items.value = []
allItems.value = []
page.value = 1
hasMore.value = true
loadItems()
}
async function loadItems(append = false) {
if (loading.value && append) return
loading.value = true
try {
const res = await getStoreItems(currentTab.value, page.value, pageSize)
const list = res.list || res || []
const newItems = normalizeItems(list, currentTab.value)
if (append) {
allItems.value = [...allItems.value, ...newItems]
} else {
allItems.value = newItems
}
if (newItems.length < pageSize) {
hasMore.value = false
} else {
page.value++
}
applyFilters()
} catch (e) {
console.error(e)
hasMore.value = false
} finally {
loading.value = false
}
}
function applyFilters() {
const k = String(keyword.value || '').trim().toLowerCase()
items.value = allItems.value.filter(item => {
const title = String(item.title || '').toLowerCase()
return !k || title.includes(k)
})
}
function onSearchConfirm() { applyFilters() }
function onProductTap(p) {
if (p.kind === 'product') {
uni.navigateTo({ url: `/pages/shop/detail?id=${p.id}` })
}
}
async function onRedeemTap(item) {
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: `是否消耗 ${item.points} 积分兑换 ${item.title}`,
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '兑换中...' })
try {
const userId = uni.getStorageSync('user_id')
if (!userId) throw new Error('用户ID不存在')
if (item.kind === 'product') {
await redeemProductByPoints(userId, item.id, 1)
} else if (item.kind === 'coupon') {
await redeemCouponByPoints(userId, item.id)
} else if (item.kind === 'item_card') {
await redeemItemCardByPoints(userId, item.id, 1)
}
uni.hideLoading()
uni.showModal({
title: '兑换成功',
content: `您已成功兑换 ${item.title || item.name}`,
showCancel: false,
success: () => {
loadItems()
}
})
} catch (e) {
uni.hideLoading()
uni.showToast({ title: e.message || '兑换失败', icon: 'none' })
}
}
}
})
}
onShow(() => {
const token = uni.getStorageSync('token')
const phoneBound = !!uni.getStorageSync('phone_bound')
if (token && phoneBound) {
page.value = 1
hasMore.value = true
allItems.value = []
loadItems()
} else {
// Redirect logic if needed
}
})
onReachBottom(() => {
if (!loading.value && hasMore.value) {
loadItems(true)
}
})
watch(keyword, () => applyFilters())
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background-color: $bg-page;
padding-bottom: 40rpx;
position: relative;
overflow-x: hidden;
}
/* 顶部 Header */
.header-section {
position: fixed;
top: 0; left: 0; right: 0;
z-index: 100;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20rpx);
padding: 20rpx 24rpx 0;
box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.05);
}
.header-placeholder { height: 180rpx; }
.search-box { margin-bottom: 20rpx; }
.search-input-wrap {
display: flex; align-items: center;
background: rgba(0, 0, 0, 0.04);
border-radius: $radius-round;
padding: 16rpx 24rpx;
}
.search-icon { font-size: 28rpx; margin-right: 16rpx; opacity: 0.5; }
.search-input { flex: 1; font-size: 26rpx; color: $text-main; }
.placeholder-style { color: $text-tertiary; }
/* Tabs */
.tab-row {
display: flex;
justify-content: space-around;
padding-bottom: 4rpx;
}
.tab-item {
position: relative;
padding: 16rpx 20rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.tab-text {
font-size: 28rpx;
color: $text-secondary;
font-weight: 500;
transition: all 0.2s;
}
.tab-item.active .tab-text {
color: $brand-primary;
font-weight: 700;
font-size: 30rpx;
}
.tab-line {
position: absolute;
bottom: 0;
width: 40rpx;
height: 6rpx;
background: $gradient-brand;
border-radius: 4rpx;
}
.content-container { padding: 24rpx; }
/* 商品 Grid */
.products-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
}
.product-card {
background: #fff;
border-radius: $radius-lg;
overflow: hidden;
box-shadow: $shadow-sm;
}
.thumb-wrap {
position: relative; width: 100%; padding-top: 100%;
background: #f8f8f8;
}
.product-thumb { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
.stock-tag {
position: absolute; bottom: 0; right: 0;
background: rgba($brand-primary, 0.9);
color: #fff; font-size: 20rpx; padding: 4rpx 12rpx;
border-top-left-radius: 12rpx;
&.out { background: #999; }
}
.product-info { padding: 16rpx; }
.product-title {
font-size: 26rpx; color: $text-main; font-weight: 600;
line-height: 1.4; height: 2.8em; overflow: hidden;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
margin-bottom: 12rpx;
}
.product-bottom { display: flex; align-items: center; justify-content: space-between; }
.points-val { font-size: 32rpx; font-weight: 800; color: #FF9800; font-family: 'DIN-Bold'; }
.points-unit { font-size: 20rpx; color: #FF9800; margin-left: 2rpx; }
.redeem-btn {
background: $gradient-brand; color: #fff; font-size: 22rpx;
padding: 6rpx 18rpx; border-radius: 24rpx; font-weight: 600;
}
/* 优惠券 (Ticket Style) */
.coupons-list { display: flex; flex-direction: column; gap: 24rpx; }
.coupon-card {
display: flex;
background: #fff;
border-radius: $radius-lg;
height: 180rpx;
box-shadow: $shadow-sm;
overflow: hidden;
}
.coupon-left {
width: 200rpx;
background: linear-gradient(135deg, #FF6B6B, #FF3B30);
display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #fff;
}
.coupon-val-row { display: flex; align-items: baseline; }
.coupon-symbol { font-size: 24rpx; font-weight: 600; }
.coupon-val { font-size: 56rpx; font-weight: 800; font-family: 'DIN-Bold'; }
.coupon-limit { font-size: 20rpx; opacity: 0.9; }
.coupon-divider {
width: 2rpx; position: relative;
background: #eee;
.notch {
position: absolute; width: 24rpx; height: 24rpx; background: $bg-page;
border-radius: 50%; left: -12rpx;
&.top { top: -12rpx; }
&.bottom { bottom: -12rpx; }
}
}
.coupon-right {
flex: 1; padding: 24rpx;
display: flex; flex-direction: column; justify-content: space-between;
}
.coupon-name { font-size: 30rpx; font-weight: 700; color: $text-main; }
.coupon-bottom { display: flex; align-items: center; justify-content: space-between; }
.coupon-price .p-val { font-size: 36rpx; font-weight: 800; color: #FF9800; }
.coupon-price .p-unit { font-size: 22rpx; color: #FF9800; }
.coupon-btn {
background: #333; color: #fff; font-size: 24rpx;
padding: 10rpx 24rpx; border-radius: 30rpx; font-weight: 600;
}
/* 道具卡 (Card Style) */
.item-cards-list { display: flex; flex-direction: column; gap: 24rpx; }
.item-card {
display: flex; background: #fff; border-radius: $radius-lg; padding: 24rpx;
box-shadow: $shadow-sm; align-items: center;
}
.ic-icon-wrap {
width: 100rpx; height: 100rpx; background: #f0f0f0;
border-radius: $radius-md; display: flex; align-items: center; justify-content: center;
font-size: 48rpx; margin-right: 24rpx;
}
.ic-info { flex: 1; display: flex; flex-direction: column; }
.ic-name { font-size: 30rpx; font-weight: 700; color: $text-main; margin-bottom: 4rpx; }
.ic-desc { font-size: 22rpx; color: $text-tertiary; margin-bottom: 20rpx; }
.ic-bottom { display: flex; align-items: center; justify-content: space-between; }
.ic-price .p-val { font-size: 32rpx; font-weight: 800; color: #FF9800; }
.ic-btn {
background: $gradient-brand; color: #fff; font-size: 24rpx;
padding: 8rpx 24rpx; border-radius: 30rpx;
}
.empty-state {
display: flex; flex-direction: column; align-items: center; padding-top: 100rpx;
}
.empty-img { width: 240rpx; opacity: 0.6; }
.empty-text { color: $text-tertiary; font-size: 26rpx; margin-top: 20rpx; }
.loading-wrap { padding: 100rpx 0; display: flex; justify-content: center; }
.spinner {
width: 48rpx; height: 48rpx; border: 4rpx solid #eee; border-top-color: $brand-primary;
border-radius: 50%; animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* 加载更多 */
.loading-more {
display: flex;
align-items: center;
justify-content: center;
padding: 30rpx 0;
color: $text-tertiary;
font-size: 24rpx;
gap: 12rpx;
}
.no-more {
text-align: center;
padding: 40rpx 0;
color: $text-tertiary;
font-size: 24rpx;
opacity: 0.6;
}
</style>