bindbox-mini/pages/shop/index.vue

827 lines
23 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-section">
<view class="filter-row">
<text class="filter-label">价格区间</text>
<view class="price-inputs">
<input
class="price-input"
type="number"
v-model="priceMin"
placeholder="最低"
placeholder-class="input-placeholder"
/>
<text class="price-separator">-</text>
<input
class="price-input"
type="number"
v-model="priceMax"
placeholder="最高"
placeholder-class="input-placeholder"
/>
</view>
<view class="filter-btn" @tap="applyPriceFilter">筛选</view>
<view class="filter-btn reset" @tap="resetPriceFilter" v-if="priceMin || priceMax">重置</view>
</view>
<view class="quick-prices">
<view
v-for="range in priceRanges"
:key="range.key"
class="quick-price-tag"
:class="{ active: isRangeActive(range) }"
@tap="selectQuickPrice(range)"
>
{{ range.label }}
</view>
</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-container">
<view class="loading-animation">
<view class="circle circle-1"></view>
<view class="circle circle-2"></view>
<view class="circle circle-3"></view>
<view class="circle circle-4"></view>
</view>
<text class="loading-text">{{ loadingText }}</text>
</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"
:class="{ disabled: p.stock === 0 }"
@tap.stop="onRedeemTap(p)"
>
{{ p.stock === 0 ? '已售罄' : '兑换' }}
</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>{{ loadingText }}</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, onUnmounted } from 'vue'
import { getStoreItems, redeemProductByPoints, redeemCouponByPoints, redeemItemCardByPoints } from '../../api/appUser'
import { checkPhoneBound, checkPhoneBoundSync } from '../../utils/checkPhone.js'
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 loadingTexts = [
'请稍等,正在努力加载中。。。',
'精彩马上就来,请稍候片刻~',
'正在搜寻更多好物,别急哦~',
'加载中,好东西值得等待~',
'正在为您准备惊喜,马上就好~',
'小憩一下,精彩内容即将呈现~',
'努力加载中,比心❤️~',
'正在赶来,马上就到~',
'稍安勿躁,美好值得等待~',
'加载进度99%... 开个玩笑😄'
]
const loadingText = ref(loadingTexts[0])
let loadingTextInterval = null
// 开始切换加载文字
function startLoadingTextRotation() {
if (loadingTextInterval) return
let index = 0
loadingText.value = loadingTexts[index]
loadingTextInterval = setInterval(() => {
index = (index + 1) % loadingTexts.length
loadingText.value = loadingTexts[index]
}, 2000) // 每2秒切换一次
}
// 停止切换加载文字
function stopLoadingTextRotation() {
if (loadingTextInterval) {
clearInterval(loadingTextInterval)
loadingTextInterval = null
}
}
// 监听 loading 状态,自动启停文字轮播
watch(loading, (newVal) => {
if (newVal) {
startLoadingTextRotation()
} else {
stopLoadingTextRotation()
}
})
// 价格筛选相关
const priceMin = ref('')
const priceMax = ref('')
const priceRanges = [
{ key: 'all', label: '全部', min: null, max: null },
{ key: '0-100', label: '0-100', min: 0, max: 100 },
{ key: '100-500', label: '100-500', min: 100, max: 500 },
{ key: '500-1000', label: '500-1K', min: 500, max: 1000 },
{ key: '1000-5000', label: '1K-5K', min: 1000, max: 5000 },
{ key: '5000+', label: '5K+', min: 5000, max: null }
]
const activePriceRange = ref('all')
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 ? (i.points_required / 100).toFixed(1) : (i.price ? (i.price / 100).toFixed(1) : (i.discount_value ? (i.discount_value / 100).toFixed(1) : '0.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
items.value = []
allItems.value = []
page.value = 1
hasMore.value = true
loadItems()
}
async function loadItems(append = false) {
if (loading.value) return
loading.value = true
try {
const res = await getStoreItems(currentTab.value, page.value, pageSize)
const list = res.list || res || []
const total = res.total || 0
const newItems = normalizeItems(list, currentTab.value)
if (append) {
allItems.value = [...allItems.value, ...newItems]
} else {
allItems.value = newItems
}
// 根据返回的total计算总页数
const totalPages = Math.ceil(total / pageSize)
// 如果当前页小于总页数,继续加载下一页
if (page.value < totalPages) {
page.value++
// 递归加载下一页
loading.value = false // 临时释放loading状态允许递归调用
await loadItems(true)
} else {
// 所有数据加载完成
hasMore.value = false
}
applyFilters()
} catch (e) {
console.error(e)
hasMore.value = false
} finally {
loading.value = false
}
}
function applyFilters() {
const k = String(keyword.value || '').trim().toLowerCase()
const minPrice = priceMin.value !== '' ? parseFloat(priceMin.value) : null
const maxPrice = priceMax.value !== '' ? parseFloat(priceMax.value) : null
items.value = allItems.value.filter(item => {
// 关键词筛选
const title = String(item.title || '').toLowerCase()
const matchKeyword = !k || title.includes(k)
// 价格筛选
const itemPrice = item.points || 0
let matchPrice = true
if (minPrice !== null) {
matchPrice = matchPrice && itemPrice >= minPrice
}
if (maxPrice !== null) {
matchPrice = matchPrice && itemPrice <= maxPrice
}
return matchKeyword && matchPrice
})
}
function applyPriceFilter() {
activePriceRange.value = 'custom'
applyFilters()
}
function resetPriceFilter() {
priceMin.value = ''
priceMax.value = ''
activePriceRange.value = 'all'
applyFilters()
}
function selectQuickPrice(range) {
activePriceRange.value = range.key
if (range.min !== null) {
priceMin.value = range.min.toString()
} else {
priceMin.value = ''
}
if (range.max !== null) {
priceMax.value = range.max.toString()
} else {
priceMax.value = ''
}
applyFilters()
}
function isRangeActive(range) {
return activePriceRange.value === range.key
}
function onSearchConfirm() { applyFilters() }
function onProductTap(p) {
if (p.kind === 'product') {
uni.navigateTo({ url: `/pages-shop/shop/detail?id=${p.id}` })
}
}
async function onRedeemTap(item) {
// 检查商品库存
if (item.kind === 'product' && item.stock === 0) {
uni.showModal({
title: '商品已售罄',
content: '该商品库存不足请联系客服处理',
showCancel: false
})
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: `是否消耗 ${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(() => {
// 检查手机号绑定状态(快速检查本地缓存)
if (!checkPhoneBoundSync()) return
const token = uni.getStorageSync('token')
if (token) {
page.value = 1
hasMore.value = true
allItems.value = []
loadItems()
}
})
onReachBottom(() => {
// 所有数据已在页面加载时一次性请求完成,无需触底加载
})
watch(keyword, () => applyFilters())
// 组件卸载时清理定时器
onUnmounted(() => {
stopLoadingTextRotation()
})
</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: 340rpx; }
.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; }
/* 价格筛选 */
.filter-section {
margin-bottom: 16rpx;
padding: 16rpx;
background: rgba(255, 255, 255, 0.6);
border-radius: $radius-md;
}
.filter-row {
display: flex;
align-items: center;
margin-bottom: 12rpx;
}
.filter-label {
font-size: 24rpx;
color: $text-secondary;
white-space: nowrap;
margin-right: 12rpx;
}
.price-inputs {
flex: 1;
display: flex;
align-items: center;
gap: 8rpx;
}
.price-input {
flex: 1;
height: 56rpx;
background: rgba(0, 0, 0, 0.04);
border-radius: $radius-sm;
padding: 0 16rpx;
font-size: 24rpx;
color: $text-main;
text-align: center;
}
.input-placeholder { color: $text-tertiary; }
.price-separator {
font-size: 24rpx;
color: $text-tertiary;
padding: 0 4rpx;
}
.filter-btn {
padding: 12rpx 20rpx;
background: $gradient-brand;
color: #fff;
font-size: 24rpx;
border-radius: $radius-round;
margin-left: 12rpx;
white-space: nowrap;
&.reset {
background: rgba(0, 0, 0, 0.06);
color: $text-secondary;
}
}
.quick-prices {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.quick-price-tag {
padding: 8rpx 20rpx;
background: rgba(0, 0, 0, 0.04);
border-radius: $radius-round;
font-size: 22rpx;
color: $text-secondary;
transition: all 0.2s;
&.active {
background: $gradient-brand;
color: #fff;
font-weight: 600;
}
}
/* 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;
&.disabled {
background: #ccc;
color: #999;
}
}
/* 优惠券 (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-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 150rpx;
}
.loading-animation {
position: relative;
width: 160rpx;
height: 160rpx;
margin-bottom: 40rpx;
}
.circle {
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
border: 4rpx solid transparent;
}
.circle-1 {
border-top-color: #FF6B6B;
border-right-color: #FF6B6B;
animation: rotate 1.5s cubic-bezier(0.68, -0.55, 0.265, 1.55) infinite;
}
.circle-2 {
width: 80%;
height: 80%;
top: 10%;
left: 10%;
border-top-color: #4ECDC4;
border-left-color: #4ECDC4;
animation: rotate 2s cubic-bezier(0.68, -0.55, 0.265, 1.55) infinite reverse;
}
.circle-3 {
width: 60%;
height: 60%;
top: 20%;
left: 20%;
border-bottom-color: #FFE66D;
border-left-color: #FFE66D;
animation: rotate 2.5s cubic-bezier(0.68, -0.55, 0.265, 1.55) infinite;
}
.circle-4 {
width: 40%;
height: 40%;
top: 30%;
left: 30%;
border-top-color: #95E1D3;
border-bottom-color: #95E1D3;
animation: rotate 3s cubic-bezier(0.68, -0.55, 0.265, 1.55) infinite reverse;
}
@keyframes rotate {
0% {
transform: rotate(0deg) scale(1);
opacity: 1;
}
50% {
transform: rotate(180deg) scale(1.1);
opacity: 0.8;
}
100% {
transform: rotate(360deg) scale(1);
opacity: 1;
}
}
.loading-text {
font-size: 28rpx;
color: $text-secondary;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
}
.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>