485 lines
15 KiB
Vue
485 lines
15 KiB
Vue
<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>
|