Files
medical-mall/pages/mall/consumer/points/exchange.uvue
2026-03-06 17:30:50 +08:00

736 lines
16 KiB
Plaintext
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>
<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>