736 lines
16 KiB
Plaintext
736 lines
16 KiB
Plaintext
<template>
|
||
<scroll-view class="exchange-page" scroll-y>
|
||
<view class="header">
|
||
<view class="points-info">
|
||
<text class="points-label">可用积分</text>
|
||
<text class="points-value">{{ totalPoints }}</text>
|
||
</view>
|
||
<view class="header-actions">
|
||
<text class="records-link" @click="goToRecords">兑换记录</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="tabs">
|
||
<view
|
||
class="tab-item"
|
||
:class="activeTab === 'all' ? 'active' : ''"
|
||
@click="switchTab('all')"
|
||
>
|
||
<text class="tab-text">全部</text>
|
||
</view>
|
||
<view
|
||
class="tab-item"
|
||
:class="activeTab === 'coupon' ? 'active' : ''"
|
||
@click="switchTab('coupon')"
|
||
>
|
||
<text class="tab-text">优惠券</text>
|
||
</view>
|
||
<view
|
||
class="tab-item"
|
||
:class="activeTab === 'physical' ? 'active' : ''"
|
||
@click="switchTab('physical')"
|
||
>
|
||
<text class="tab-text">实物</text>
|
||
</view>
|
||
<view
|
||
class="tab-item"
|
||
:class="activeTab === 'virtual' ? 'active' : ''"
|
||
@click="switchTab('virtual')"
|
||
>
|
||
<text class="tab-text">虚拟</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="product-list" v-if="!loading">
|
||
<view
|
||
class="product-card"
|
||
v-for="product in filteredProducts"
|
||
:key="product.id"
|
||
@click="showExchangePopup(product)"
|
||
>
|
||
<image
|
||
class="product-image"
|
||
:src="product.image_url || defaultImage"
|
||
mode="aspectFill"
|
||
/>
|
||
<view class="product-info">
|
||
<text class="product-name">{{ product.name }}</text>
|
||
<text class="product-desc" v-if="product.description">{{ product.description }}</text>
|
||
<view class="product-bottom">
|
||
<view class="product-points">
|
||
<text class="points-num">{{ product.points_required }}</text>
|
||
<text class="points-unit">积分</text>
|
||
</view>
|
||
<text class="product-stock">库存{{ product.stock }}件</text>
|
||
<text class="product-original" v-if="product.original_price">¥{{ product.original_price }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="empty-state" v-if="!loading && filteredProducts.length === 0">
|
||
<text class="empty-text">暂无可兑换商品</text>
|
||
</view>
|
||
|
||
<view class="loading-state" v-if="loading">
|
||
<text class="loading-text">加载中...</text>
|
||
</view>
|
||
|
||
<view class="exchange-popup" v-if="showPopup" @click="closePopup">
|
||
<view class="popup-content" @click.stop>
|
||
<view class="popup-header">
|
||
<text class="popup-title">确认兑换</text>
|
||
<text class="popup-close" @click="closePopup">×</text>
|
||
</view>
|
||
|
||
<view class="popup-product" v-if="selectedProduct != null">
|
||
<image
|
||
class="popup-product-image"
|
||
:src="selectedProduct.image_url || defaultImage"
|
||
mode="aspectFill"
|
||
/>
|
||
<view class="popup-product-info">
|
||
<text class="popup-product-name">{{ selectedProduct.name }}</text>
|
||
<view class="popup-product-points">
|
||
<text class="popup-points-num">{{ selectedProduct.points_required }}</text>
|
||
<text class="popup-points-unit">积分</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="popup-quantity">
|
||
<text class="quantity-label">兑换数量</text>
|
||
<view class="quantity-control">
|
||
<text class="quantity-btn" @click="decreaseQuantity">-</text>
|
||
<text class="quantity-value">{{ exchangeQuantity }}</text>
|
||
<text class="quantity-btn" @click="increaseQuantity">+</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="popup-summary">
|
||
<view class="summary-row">
|
||
<text class="summary-label">消耗积分</text>
|
||
<text class="summary-value">{{ totalPointsCost }}</text>
|
||
</view>
|
||
<view class="summary-row">
|
||
<text class="summary-label">当前积分</text>
|
||
<text class="summary-value">{{ totalPoints }}</text>
|
||
</view>
|
||
<view class="summary-row" v-if="totalPoints < totalPointsCost">
|
||
<text class="summary-label insufficient">积分不足</text>
|
||
<text class="summary-value insufficient">差{{ totalPointsCost - totalPoints }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<button
|
||
class="popup-btn"
|
||
:class="{ disabled: totalPoints < totalPointsCost }"
|
||
:disabled="totalPoints < totalPointsCost || exchanging"
|
||
@click="confirmExchange"
|
||
>
|
||
{{ exchanging ? '兑换中...' : '确认兑换' }}
|
||
</button>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="success-popup" v-if="showSuccess" @click="closeSuccess">
|
||
<view class="success-content" @click.stop>
|
||
<view class="success-icon">✓</view>
|
||
<text class="success-title">兑换成功</text>
|
||
<text class="success-desc">消耗 {{ totalPointsCost }} 积分</text>
|
||
<button class="success-btn" @click="closeSuccess">确定</button>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
</template>
|
||
|
||
<script setup lang="uts">
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { supabaseService } from '@/utils/supabaseService.uts'
|
||
|
||
type PointProduct = {
|
||
id: string
|
||
name: string
|
||
description: string | null
|
||
image_url: string | null
|
||
product_type: string
|
||
points_required: number
|
||
original_price: number | null
|
||
stock: number
|
||
status: number
|
||
}
|
||
|
||
const totalPoints = ref<number>(0)
|
||
const products = ref<PointProduct[]>([])
|
||
const loading = ref<boolean>(true)
|
||
const activeTab = ref<string>('all')
|
||
const showPopup = ref<boolean>(false)
|
||
const showSuccess = ref<boolean>(false)
|
||
const selectedProduct = ref<PointProduct | null>(null)
|
||
const exchangeQuantity = ref<number>(1)
|
||
const exchanging = ref<boolean>(false)
|
||
|
||
const defaultImage: string = '/static/images/default-product.png'
|
||
|
||
const filteredProducts = computed((): PointProduct[] => {
|
||
if (activeTab.value === 'all') {
|
||
return products.value
|
||
}
|
||
const filtered: PointProduct[] = []
|
||
for (let i = 0; i < products.value.length; i++) {
|
||
if (products.value[i].product_type === activeTab.value) {
|
||
filtered.push(products.value[i])
|
||
}
|
||
}
|
||
return filtered
|
||
})
|
||
|
||
const totalPointsCost = computed((): number => {
|
||
if (selectedProduct.value == null) return 0
|
||
return selectedProduct.value.points_required * exchangeQuantity.value
|
||
})
|
||
|
||
const loadProducts = async (): Promise<void> => {
|
||
loading.value = true
|
||
try {
|
||
const points = await supabaseService.getUserPoints()
|
||
totalPoints.value = points
|
||
|
||
const productList = await supabaseService.getPointProducts()
|
||
const parsed: PointProduct[] = []
|
||
for (let i = 0; i < productList.length; i++) {
|
||
const item = productList[i]
|
||
const itemAny = item as any
|
||
|
||
let id = ''
|
||
let name = ''
|
||
let description: string | null = null
|
||
let image_url: string | null = null
|
||
let product_type = 'coupon'
|
||
let points_required = 0
|
||
let original_price: number | null = null
|
||
let stock = 0
|
||
let status = 1
|
||
|
||
// UTSJSONObject2 需要使用 _getValue 方法
|
||
if (typeof itemAny._getValue === 'function') {
|
||
id = (itemAny._getValue('id') as string) ?? ''
|
||
name = (itemAny._getValue('name') as string) ?? ''
|
||
description = itemAny._getValue('description') as string | null
|
||
image_url = itemAny._getValue('image_url') as string | null
|
||
product_type = (itemAny._getValue('product_type') as string) ?? 'coupon'
|
||
points_required = (itemAny._getValue('points_required') as number) ?? 0
|
||
original_price = itemAny._getValue('original_price') as number | null
|
||
stock = (itemAny._getValue('stock') as number) ?? 0
|
||
status = (itemAny._getValue('status') as number) ?? 1
|
||
} else {
|
||
id = itemAny['id'] ?? ''
|
||
name = itemAny['name'] ?? ''
|
||
description = itemAny['description'] ?? null
|
||
image_url = itemAny['image_url'] ?? null
|
||
product_type = itemAny['product_type'] ?? 'coupon'
|
||
points_required = itemAny['points_required'] ?? 0
|
||
original_price = itemAny['original_price'] ?? null
|
||
stock = itemAny['stock'] ?? 0
|
||
status = itemAny['status'] ?? 1
|
||
}
|
||
|
||
const product: PointProduct = {
|
||
id,
|
||
name,
|
||
description,
|
||
image_url,
|
||
product_type,
|
||
points_required,
|
||
original_price,
|
||
stock,
|
||
status
|
||
}
|
||
parsed.push(product)
|
||
}
|
||
products.value = parsed
|
||
} catch (e) {
|
||
console.error('加载商品失败:', e)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const switchTab = (tab: string): void => {
|
||
activeTab.value = tab
|
||
}
|
||
|
||
const showExchangePopup = (product: PointProduct): void => {
|
||
selectedProduct.value = product
|
||
exchangeQuantity.value = 1
|
||
showPopup.value = true
|
||
}
|
||
|
||
const closePopup = (): void => {
|
||
showPopup.value = false
|
||
selectedProduct.value = null
|
||
}
|
||
|
||
const increaseQuantity = (): void => {
|
||
if (selectedProduct.value != null && exchangeQuantity.value < selectedProduct.value.stock) {
|
||
exchangeQuantity.value++
|
||
}
|
||
}
|
||
|
||
const decreaseQuantity = (): void => {
|
||
if (exchangeQuantity.value > 1) {
|
||
exchangeQuantity.value--
|
||
}
|
||
}
|
||
|
||
const confirmExchange = async (): Promise<void> => {
|
||
if (selectedProduct.value == null) return
|
||
if (totalPoints.value < totalPointsCost.value) return
|
||
|
||
exchanging.value = true
|
||
|
||
try {
|
||
const result = await supabaseService.exchangeProduct(
|
||
selectedProduct.value.id,
|
||
exchangeQuantity.value,
|
||
null
|
||
)
|
||
|
||
if (result.getBoolean('success') === true) {
|
||
showPopup.value = false
|
||
totalPoints.value -= totalPointsCost.value
|
||
showSuccess.value = true
|
||
loadProducts()
|
||
} else {
|
||
const message = result.getString('message') ?? '兑换失败'
|
||
uni.showToast({ title: message, icon: 'none' })
|
||
}
|
||
} catch (e) {
|
||
console.error('兑换异常:', e)
|
||
uni.showToast({ title: '兑换异常', icon: 'none' })
|
||
} finally {
|
||
exchanging.value = false
|
||
}
|
||
}
|
||
|
||
const closeSuccess = (): void => {
|
||
showSuccess.value = false
|
||
}
|
||
|
||
const goToRecords = (): void => {
|
||
uni.navigateTo({
|
||
url: '/pages/mall/consumer/points/exchange-records'
|
||
})
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadProducts()
|
||
})
|
||
</script>
|
||
|
||
<style>
|
||
.exchange-page {
|
||
flex: 1;
|
||
height: 100%;
|
||
background-color: #f5f5f5;
|
||
}
|
||
|
||
.header {
|
||
background: linear-gradient(135deg, #ff6b35 0%, #ff8c42 100%);
|
||
padding: 16px;
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.points-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.points-label {
|
||
font-size: 12px;
|
||
color: rgba(255, 255, 255, 0.8);
|
||
}
|
||
|
||
.points-value {
|
||
font-size: 28px;
|
||
font-weight: bold;
|
||
color: white;
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
flex-direction: row;
|
||
}
|
||
|
||
.records-link {
|
||
font-size: 14px;
|
||
color: white;
|
||
padding: 6px 12px;
|
||
background-color: rgba(255, 255, 255, 0.2);
|
||
border-radius: 16px;
|
||
}
|
||
|
||
.tabs {
|
||
display: flex;
|
||
flex-direction: row;
|
||
background-color: white;
|
||
padding: 0 16px;
|
||
}
|
||
|
||
.tab-item {
|
||
flex: 1;
|
||
padding: 12px 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-bottom: 2px solid transparent;
|
||
}
|
||
|
||
.tab-item.active {
|
||
border-bottom-color: #ff6b35;
|
||
}
|
||
|
||
.tab-text {
|
||
font-size: 14px;
|
||
color: #666;
|
||
}
|
||
|
||
.tab-item.active .tab-text {
|
||
color: #ff6b35;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.product-list {
|
||
display: flex;
|
||
flex-direction: row;
|
||
flex-wrap: wrap;
|
||
padding: 8px;
|
||
}
|
||
|
||
.product-card {
|
||
width: calc(50% - 8px);
|
||
margin: 4px;
|
||
background-color: white;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.product-image {
|
||
width: 100%;
|
||
height: 150px;
|
||
}
|
||
|
||
.product-info {
|
||
padding: 8px;
|
||
}
|
||
|
||
.product-name {
|
||
font-size: 14px;
|
||
color: #333;
|
||
lines: 2;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.product-desc {
|
||
font-size: 12px;
|
||
color: #999;
|
||
margin-top: 4px;
|
||
lines: 1;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.product-bottom {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.product-points {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: baseline;
|
||
}
|
||
|
||
.points-num {
|
||
font-size: 18px;
|
||
font-weight: bold;
|
||
color: #ff6b35;
|
||
}
|
||
|
||
.points-unit {
|
||
font-size: 12px;
|
||
color: #ff6b35;
|
||
margin-left: 2px;
|
||
}
|
||
|
||
.product-stock {
|
||
font-size: 12px;
|
||
color: #ff6b35;
|
||
}
|
||
|
||
.product-original {
|
||
font-size: 12px;
|
||
color: #999;
|
||
text-decoration: line-through;
|
||
}
|
||
|
||
.empty-state {
|
||
padding: 60px 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.empty-text {
|
||
font-size: 14px;
|
||
color: #999;
|
||
}
|
||
|
||
.loading-state {
|
||
padding: 60px 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.loading-text {
|
||
font-size: 14px;
|
||
color: #999;
|
||
}
|
||
|
||
.exchange-popup {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background-color: rgba(0, 0, 0, 0.5);
|
||
display: flex;
|
||
align-items: flex-end;
|
||
justify-content: center;
|
||
z-index: 1000;
|
||
}
|
||
|
||
.popup-content {
|
||
background-color: white;
|
||
border-radius: 16px 16px 0 0;
|
||
width: 100%;
|
||
padding: 16px;
|
||
}
|
||
|
||
.popup-header {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.popup-title {
|
||
font-size: 18px;
|
||
font-weight: bold;
|
||
color: #333;
|
||
}
|
||
|
||
.popup-close {
|
||
font-size: 24px;
|
||
color: #999;
|
||
}
|
||
|
||
.popup-product {
|
||
display: flex;
|
||
flex-direction: row;
|
||
padding: 12px;
|
||
background-color: #f9f9f9;
|
||
border-radius: 8px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.popup-product-image {
|
||
width: 80px;
|
||
height: 80px;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.popup-product-info {
|
||
flex: 1;
|
||
margin-left: 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
}
|
||
|
||
.popup-product-name {
|
||
font-size: 14px;
|
||
color: #333;
|
||
lines: 2;
|
||
}
|
||
|
||
.popup-product-points {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: baseline;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.popup-points-num {
|
||
font-size: 20px;
|
||
font-weight: bold;
|
||
color: #ff6b35;
|
||
}
|
||
|
||
.popup-points-unit {
|
||
font-size: 12px;
|
||
color: #ff6b35;
|
||
margin-left: 2px;
|
||
}
|
||
|
||
.popup-quantity {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 12px 0;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
}
|
||
|
||
.quantity-label {
|
||
font-size: 14px;
|
||
color: #333;
|
||
}
|
||
|
||
.quantity-control {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
}
|
||
|
||
.quantity-btn {
|
||
width: 28px;
|
||
height: 28px;
|
||
background-color: #f5f5f5;
|
||
border-radius: 4px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 18px;
|
||
color: #666;
|
||
}
|
||
|
||
.quantity-value {
|
||
width: 40px;
|
||
text-align: center;
|
||
font-size: 16px;
|
||
color: #333;
|
||
}
|
||
|
||
.popup-summary {
|
||
padding: 12px 0;
|
||
}
|
||
|
||
.summary-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.summary-label {
|
||
font-size: 14px;
|
||
color: #666;
|
||
}
|
||
|
||
.summary-label.insufficient {
|
||
color: #ff6b35;
|
||
}
|
||
|
||
.summary-value {
|
||
font-size: 14px;
|
||
color: #333;
|
||
}
|
||
|
||
.summary-value.insufficient {
|
||
color: #ff6b35;
|
||
}
|
||
|
||
.popup-btn {
|
||
background: linear-gradient(135deg, #ff6b35 0%, #ff8c42 100%);
|
||
color: white;
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
border-radius: 24px;
|
||
height: 44px;
|
||
line-height: 44px;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.popup-btn.disabled {
|
||
background: #ccc;
|
||
}
|
||
|
||
.success-popup {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background-color: rgba(0, 0, 0, 0.5);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 1001;
|
||
}
|
||
|
||
.success-content {
|
||
background-color: white;
|
||
border-radius: 16px;
|
||
padding: 32px;
|
||
width: 280px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
|
||
.success-icon {
|
||
width: 60px;
|
||
height: 60px;
|
||
background-color: #52c41a;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 30px;
|
||
color: white;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.success-title {
|
||
font-size: 20px;
|
||
font-weight: bold;
|
||
color: #333;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.success-desc {
|
||
font-size: 14px;
|
||
color: #666;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.success-btn {
|
||
background-color: #ff6b35;
|
||
color: white;
|
||
font-size: 16px;
|
||
border-radius: 20px;
|
||
width: 100%;
|
||
height: 40px;
|
||
line-height: 40px;
|
||
}
|
||
</style>
|