Files
gym-manage/gym-manage-uniapp/pages/memberInfo/purchaseCard.vue
T

2034 lines
54 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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="scroll-container theme-light">
<view class="purchase-card-page">
<view class="purchase-card-page__body">
<!-- 头部 -->
<view class="pc-intro">
<view class="pc-intro__icon">
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/crown0.png" mode="aspectFit" />
</view>
<text class="pc-intro__title">选择会员卡类型</text>
<text class="pc-intro__desc">购买后立即生效享受专属会员权益</text>
</view>
<!-- 骨架屏 -->
<ListSkeleton v-if="loadingCards" :count="3" layout="simple" />
<!-- 卡列表 -->
<view v-else-if="cardTypes.length" class="pc-list">
<view
v-for="card in cardTypes"
:key="card.id"
class="pc-card"
:class="{ 'pc-card--selected': isCardSelected(card.id) }"
>
<view class="pc-card__check" @tap="toggleCardSelection(card)">
<view
class="pc-card__check-inner"
:class="{ 'pc-card__check-inner--checked': isCardSelected(card.id) }"
></view>
</view>
<view class="pc-card__content">
<view class="pc-card__header">
<text class="pc-card__name">{{ card.cardName }}</text>
<view class="pc-card__tag" :class="'pc-card__tag--' + getTagClass(card.cardType)">
<text class="pc-card__tag-text">{{ getCardTypeName(card.cardType) }}</text>
</view>
</view>
<view class="pc-card__info" @tap="goToDetail(card)">
<view class="pc-card__info-row" v-if="card.validityDays">
<text class="pc-card__info-label">有效期</text>
<text class="pc-card__info-value">{{ card.validityDays }}</text>
</view>
<view class="pc-card__info-row" v-if="card.cardType === 'COUNT' && card.totalTimes">
<text class="pc-card__info-label">总次数</text>
<text class="pc-card__info-value">{{ card.totalTimes }}</text>
</view>
<view class="pc-card__info-row" v-if="card.cardType === 'BALANCE' && card.amount">
<text class="pc-card__info-label">储值金额</text>
<text class="pc-card__info-value">{{ card.amount }}</text>
</view>
<view class="pc-card__info-row" v-if="card.description">
<text class="pc-card__info-label">说明</text>
<text class="pc-card__info-value">{{ card.description }}</text>
</view>
</view>
<view class="pc-card__footer">
<view class="pc-card__footer-left" @tap="increaseCount(card)">
<text class="pc-card__price">¥{{ card.price }}</text>
<text class="pc-card__original-price" v-if="card.originalPrice && card.originalPrice !== card.price">¥{{ card.originalPrice }}</text>
</view>
<view class="pc-card__footer-right">
<view class="pc-card__detail-btn" @tap="goToDetail(card)">
<text class="pc-card__detail-text">详情</text>
</view>
<view class="pc-card__quantity" v-if="isCardSelected(card.id)">
<view class="pc-card__qty-btn" @tap="decreaseCount(card)">
<text>-</text>
</view>
<text class="pc-card__qty-num">{{ getCartItem(card.id)?.count || 0 }}</text>
<view class="pc-card__qty-btn pc-card__qty-btn--add" @tap="increaseCount(card)">
<text>+</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 底部 -->
<view class="pc-bottom">
<view class="pc-bottom__total" @tap="showCartModal = true">
<text class="pc-bottom__total-label">合计</text>
<text class="pc-bottom__total-price">¥{{ totalPrice }}</text>
<text class="pc-bottom__total-count">已选{{ totalCount }}</text>
</view>
<view
class="pc-bottom__btn"
:class="{ 'pc-bottom__btn--disabled': cart.length === 0 || loading }"
@tap="handlePurchase"
>
<text class="pc-bottom__btn-text">{{ loading ? '处理中...' : `立即购买 ${totalCount}` }}</text>
</view>
</view>
</view>
</view>
<!-- 购买成功弹窗 -->
<view class="success-modal-overlay" v-if="showSuccessModalVisible" @tap="closeSuccessModal">
<view class="success-modal-dialog" @tap.stop>
<view class="success-modal__icon">🎉</view>
<text class="success-modal__title">购买成功</text>
<text class="success-modal__desc">恭喜您会员卡已成功开通</text>
<view class="success-modal__buttons">
<view class="success-modal__btn success-modal__btn--secondary" @tap="handleBack">
<text>返回上一页</text>
</view>
<view class="success-modal__btn success-modal__btn--outline" @tap="handleRePurchase">
<text>再次购买</text>
</view>
<view class="success-modal__btn success-modal__btn--primary" @tap="handleGoToCard">
<text>去我的会员卡</text>
</view>
</view>
</view>
</view>
<!-- 购物车弹窗 -->
<view class="cart-modal-overlay" v-if="showCartModal" @tap="showCartModal = false">
<view class="cart-modal-dialog" @tap.stop>
<view class="cart-modal__header">
<text class="cart-modal__title">已选商品</text>
<view class="cart-modal__close" @tap="showCartModal = false">×</view>
</view>
<view class="cart-modal__body">
<view class="cart-modal__list" v-if="cart.length > 0">
<view class="cart-modal__item" v-for="item in cart" :key="item.card.id">
<view class="cart-modal__item-info">
<text class="cart-modal__item-name">{{ item.card.cardName }}</text>
<text class="cart-modal__item-desc">{{ getCardTypeName(item.card.cardType) }}</text>
</view>
<view class="cart-modal__item-right">
<view class="cart-modal__item-qty">
<view class="cart-modal__qty-btn" @tap="decreaseCount(item.card)">
<text>-</text>
</view>
<text class="cart-modal__qty-num">{{ item.count }}</text>
<view class="cart-modal__qty-btn cart-modal__qty-btn--add" @tap="increaseCount(item.card)">
<text>+</text>
</view>
</view>
<text class="cart-modal__item-price">¥{{ (item.card.price * item.count).toFixed(2) }}</text>
</view>
</view>
</view>
<view class="cart-modal__empty" v-else>
<text>暂无已选商品</text>
</view>
</view>
<view class="cart-modal__footer">
<view class="cart-modal__total">
<text class="cart-modal__total-label">合计</text>
<text class="cart-modal__total-price">¥{{ totalPrice }}</text>
</view>
<view class="cart-modal__btn" @tap="handlePurchase">
<text>立即购买</text>
</view>
</view>
</view>
</view>
<!-- 二维码支付对话框 (Web环境) -->
<view class="qr-payment-overlay" v-if="showQrPayment" @tap="closeQrPayment">
<view class="qr-payment-dialog" @tap.stop>
<view class="qr-payment-dialog__header">
<text class="qr-payment-dialog__title">{{ qrPaymentStatus === 'pending' ? '扫码支付' : (qrPaymentStatus === 'success' ? '支付成功' : '支付失败') }}</text>
<view class="qr-payment-dialog__close" @tap="closeQrPayment" v-if="qrPaymentStatus !== 'pending'">×</view>
</view>
<view class="qr-payment-dialog__body">
<!-- 待支付状态显示二维码 -->
<template v-if="qrPaymentStatus === 'pending'">
<view class="qr-payment-dialog__order">
<text class="qr-payment-dialog__label">订单信息</text>
<text class="qr-payment-dialog__value">{{ cart[0]?.card?.cardName }} × {{ cart[0]?.count || 1 }}</text>
</view>
<view class="qr-payment-dialog__amount">
<text class="qr-payment-dialog__label">支付金额</text>
<text class="qr-payment-dialog__money">¥{{ totalPrice }}</text>
</view>
<view class="qr-payment-dialog__qrcode">
<image
v-if="qrCodeUrl"
:src="'https://api.qrserver.com/v1/create-qr-code?size=200x200&data=' + encodeURIComponent(qrCodeUrl)"
class="qr-payment-dialog__qrcode-img"
mode="aspectFit"
/>
<view v-else class="qr-payment-dialog__qrcode-loading">加载中...</view>
</view>
<view class="qr-payment-dialog__tips">
<text class="qr-payment-dialog__tip">请使用支付宝扫码支付</text>
<text class="qr-payment-dialog__tip qr-payment-dialog__tip--warn" v-if="isWebEnv"> 沙箱环境测试</text>
</view>
</template>
<!-- 支付成功状态 -->
<template v-else-if="qrPaymentStatus === 'success'">
<view class="qr-payment-dialog__result">
<text class="qr-payment-dialog__icon"></text>
<text class="qr-payment-dialog__result-text">支付成功</text>
</view>
</template>
<!-- 支付失败状态 -->
<template v-else>
<view class="qr-payment-dialog__result">
<text class="qr-payment-dialog__icon"></text>
<text class="qr-payment-dialog__result-text">支付失败</text>
</view>
</template>
</view>
<view class="qr-payment-dialog__footer">
<template v-if="qrPaymentStatus === 'pending'">
<view class="qr-payment-dialog__btn qr-payment-dialog__btn--secondary" @tap="closeQrPayment">
<text>取消支付</text>
</view>
<view class="qr-payment-dialog__btn qr-payment-dialog__btn--primary" @tap="handleQrQueryResult">
<text>我已支付</text>
</view>
</template>
<template v-else>
<view class="qr-payment-dialog__btn qr-payment-dialog__btn--primary" @tap="closeQrPayment">
<text>确定</text>
</view>
</template>
</view>
</view>
</view>
<!-- 订单倒计时提醒弹窗 -->
<view class="countdown-modal-overlay" v-if="showCountdownModal" @tap="closeCountdownModal">
<view class="countdown-modal" @tap.stop>
<view class="countdown-modal__header">
<text class="countdown-modal__title">订单待支付</text>
</view>
<view class="countdown-modal__body">
<view class="countdown-modal__info">
<text class="countdown-modal__label">购买内容</text>
<text class="countdown-modal__value">{{ cart.map(item => `${item.card.cardName}×${item.count}`).join('、') || '会员卡' }}</text>
</view>
<view class="countdown-modal__info">
<text class="countdown-modal__label">购买金额</text>
<text class="countdown-modal__value countdown-modal__value--price">¥{{ totalPrice }}</text>
</view>
<view class="countdown-modal__timer-wrap">
<text class="countdown-modal__tip">订单超时倒计时</text>
<view class="countdown-modal__timer">
<text class="countdown-modal__time-num">{{ countdownMinutes }}</text>
<text class="countdown-modal__time-unit"></text>
<text class="countdown-modal__time-num">{{ countdownSeconds }}</text>
<text class="countdown-modal__time-unit"></text>
<text class="countdown-modal__time-num countdown-modal__time-num--ms">{{ countdownMs }}</text>
<text class="countdown-modal__time-unit">毫秒</text>
</view>
</view>
</view>
<view class="countdown-modal__footer">
<view class="countdown-modal__btn countdown-modal__btn--cancel" @tap="closeCountdownModal">
<text>取消</text>
</view>
<view class="countdown-modal__btn countdown-modal__btn--confirm" @tap="goToPayFromCountdown">
<text>去支付</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import ListSkeleton from '@/components/Skeleton/ListSkeleton.vue'
import { purchaseMemberCard, createPayment, createQrCodePayment, getPaymentStatus, queryHuifuTradeByOrderId, getActiveMemberCards } from '@/api/main.js'
import { getMemberId } from '@/utils/request.js'
// #ifdef H5
const isWebEnv = typeof navigator !== 'undefined' && !typeof plus !== 'undefined'
// #endif
// #ifndef H5
const isWebEnv = false
// #endif
const cardTypes = ref([])
const loadingCards = ref(false)
const loading = ref(false)
const cart = ref([])
const maxPurchaseCount = 10
const showQrPayment = ref(false)
const qrPaymentStatus = ref('pending')
const qrCodeUrl = ref('')
const currentOrderId = ref(null)
const currentHfSeqId = ref(null)
const currentMerchantOrderId = ref(null)
const currentPayUrl = ref('')
const paymentResolved = ref(false)
const displayStatus = ref('')
const showSuccessModalVisible = ref(false)
const showCountdownModal = ref(false)
const showCartModal = ref(false)
let countdownTimer = null
const countdownTotal = ref(15 * 60 * 1000)
const countdownRemaining = ref(15 * 60 * 1000)
let wsTask = null
let wsConnected = false
let wsResolve = null
let wsReject = null
let qrPaymentResolve = null
let qrPaymentReject = null
const WS_BASE_URL = 'ws://192.168.5.15:8084/ws/payment'
const TRADE_TYPE = 'ALIPAY'
const totalPrice = computed(() => {
const total = cart.value.reduce((sum, item) => sum + (item.card.price * item.count), 0)
return total.toFixed(2)
})
const totalCount = computed(() => {
return cart.value.reduce((sum, item) => sum + item.count, 0)
})
function getCartItem(cardId) {
const item = cart.value.find(item => item.card.id === cardId)
return item || null
}
function isCardSelected(cardId) {
return cart.value.some(item => item.card.id === cardId)
}
function toggleCardSelection(card) {
const index = cart.value.findIndex(item => item.card.id === card.id)
if (index > -1) {
cart.value.splice(index, 1)
} else {
cart.value.push({ card, count: 1 })
}
}
function increaseCount(card) {
const cartItem = getCartItem(card.id)
if (cartItem) {
if (cartItem.count < maxPurchaseCount) {
cartItem.count++
} else {
uni.showToast({ title: `最多购买${maxPurchaseCount}`, icon: 'none' })
}
} else {
cart.value.push({ card, count: 1 })
}
}
function decreaseCount(card) {
const cartItem = getCartItem(card.id)
if (cartItem) {
if (cartItem.count > 1) {
cartItem.count--
} else {
uni.showToast({ title: '最少购买1张', icon: 'none' })
}
}
}
function getCardTypeName(type) {
const map = {
DURATION: '时长卡',
TIME_CARD: '时长卡',
COUNT: '次卡',
COUNT_CARD: '次卡',
BALANCE: '储值卡',
STORED_VALUE_CARD: '储值卡'
}
return map[type] || type
}
function normalizeCardType(type) {
const map = {
TIME_CARD: 'DURATION',
COUNT_CARD: 'COUNT',
STORED_VALUE_CARD: 'BALANCE'
}
return map[type] || type
}
async function loadCardTypes(preselectCardId = null) {
loadingCards.value = true
try {
const res = await getActiveMemberCards()
let dataList = null
if (Array.isArray(res)) {
dataList = res
} else if (res && res.value && Array.isArray(res.value)) {
dataList = res.value
} else if (res && res.code === 200 && Array.isArray(res.data)) {
dataList = res.data
} else if (res && Array.isArray(res.data)) {
dataList = res.data
}
console.log(dataList)
if (dataList && dataList.length > 0) {
cardTypes.value = dataList
.filter(item => {
const cardType = normalizeCardType(item.memberCardType)
console.log(cardType)
return cardType !== 'BALANCE'
})
.map(item => {
let description = item.description || ''
if (item.extraConfig) {
try {
const config = JSON.parse(item.extraConfig)
const descParts = []
if (config.description) descParts.push(config.description)
if (config.desc) descParts.push(config.desc)
if (config.maxUsePerDay) descParts.push(`每日限用${config.maxUsePerDay}`)
if (descParts.length > 0) {
description = descParts.join('')
}
} catch (e) {
description = item.extraConfig
}
}
console.log(item.id, item.memberCardId)
return {
id: item.id, // 使用memberCardId作为业务ID
cardName: item.memberCardName,
cardType: normalizeCardType(item.memberCardType),
validityDays: item.memberCardValidityDays,
price: item.memberCardPrice,
totalTimes: item.memberCardTotalTimes,
amount: item.memberCardAmount,
status: item.memberCardStatus,
description: description
}
})
if (preselectCardId) {
const targetCard = cardTypes.value.find(c => String(c.id) === String(preselectCardId))
if (targetCard) {
increaseCount(targetCard)
}
}
} else {
console.log('会员卡列表为空:', res)
uni.showToast({ title: '暂无可购买的会员卡', icon: 'none' })
}
} catch (e) {
console.error('加载会员卡列表失败:', e)
uni.showToast({ title: '加载会员卡列表失败', icon: 'none' })
} finally {
loadingCards.value = false
}
}
function getTagClass(type) {
const map = { DURATION: 'time', COUNT: 'count', BALANCE: 'value' }
return map[type] || 'default'
}
function mapTransStat(transStat) {
const map = {
S: 'SUCCESS',
F: 'FAIL',
P: 'PENDING',
C: 'CLOSED',
INIT: 'PENDING',
PARTIAL_SUCCESS: 'SUCCESS',
WAITING: 'PENDING',
PROCESSING: 'PENDING'
}
return map[transStat] || 'PENDING'
}
function getStatusText(status) {
const map = {
PENDING: '待支付',
SUCCESS: '支付成功',
FAIL: '支付失败',
CLOSED: '已关闭',
'': '未下单'
}
return map[status] || status
}
function getStatusClass(status) {
const map = {
PENDING: 'pending',
SUCCESS: 'success',
FAIL: 'fail',
CLOSED: 'closed',
'': 'none'
}
return map[status] || 'none'
}
function connectWebSocket(orderId) {
return new Promise((resolve, reject) => {
wsResolve = resolve
wsReject = reject
wsConnected = false
const wsUrl = `${WS_BASE_URL}/${orderId}`
console.log('[WebSocket] 连接中...', wsUrl)
wsTask = uni.connectSocket({
url: wsUrl,
success: function() {
console.log('[WebSocket] 连接请求已发送')
},
fail: function(err) {
console.error('[WebSocket] 连接失败:', err)
wsReject = null
wsTask = null
reject(new Error('WebSocket连接失败'))
}
})
uni.onSocketOpen(function() {
console.log('[WebSocket] 连接成功')
wsConnected = true
})
uni.onSocketMessage(function(res) {
try {
const data = JSON.parse(res.data)
if (data.type === 'ping') {
return
}
console.log('[WebSocket] 收到消息:', data)
if (data.status === 'SUCCESS' || data.status === 'FAIL' || data.status === 'CLOSED') {
displayStatus.value = data.status
closeWebSocket()
if (wsResolve) {
wsResolve(data.status)
wsResolve = null
wsReject = null
}
}
} catch (e) {
console.warn('[WebSocket] 消息解析失败:', e)
}
})
uni.onSocketClose(function() {
console.log('[WebSocket] 连接关闭')
wsConnected = false
if (wsReject) {
wsReject(new Error('WebSocket连接关闭'))
wsResolve = null
wsReject = null
}
})
uni.onSocketError(function(err) {
console.error('[WebSocket] 连接错误:', err)
wsConnected = false
if (wsReject) {
wsReject(new Error('WebSocket连接错误'))
wsResolve = null
wsReject = null
}
})
})
}
function closeWebSocket() {
if (wsTask) {
try {
uni.closeSocket()
} catch (e) {}
wsTask = null
}
wsConnected = false
wsResolve = null
wsReject = null
}
function manualQueryStatus() {
if (!currentOrderId.value) {
uni.showToast({ title: '暂无订单', icon: 'none' })
return
}
if (loading.value) return
console.log('[Debug] 手动查询支付状态, orderId:', currentOrderId.value)
uni.showLoading({ title: '查询中...' })
getPaymentStatus(currentOrderId.value)
.then(res => {
uni.hideLoading()
if (res.code === 200 && res.data) {
const status = res.data.status || res.data.payStatus
displayStatus.value = status
console.log('[Debug] 查询结果:', status)
} else {
uni.showToast({ title: '查询失败', icon: 'none' })
}
})
.catch(() => {
uni.hideLoading()
uni.showToast({ title: '查询失败', icon: 'none' })
})
}
function goToDetail(card) {
uni.navigateTo({
url: `/pages/memberInfo/cardDetail?cardId=${card.id}`
})
}
function invokeAlipayApp(payUrl) {
return new Promise((resolve, reject) => {
if (!payUrl) {
reject(new Error('支付链接为空'))
return
}
console.log('[Alipay] 准备唤起支付宝:', payUrl)
if (typeof plus === 'undefined') {
uni.showModal({ title: '提示', content: '请在 App 内完成支付', showCancel: false })
reject(new Error('非 App 环境'))
return
}
// 优先使用支付宝scheme协议唤起App
const encodedUrl = encodeURIComponent(payUrl)
const alipayScheme = `alipays://platformapi/startapp?appId=20000067&url=${encodedUrl}`
// 先尝试用支付宝App打开
plus.runtime.openURL(alipayScheme, function(err) {
console.log('[Alipay] 唤起支付宝App失败,尝试H5支付:', err)
// 唤起失败,使用H5支付
plus.runtime.openURL(payUrl, function(h5Err) {
console.error('[Alipay] H5支付也失败:', h5Err)
uni.showModal({
title: '提示',
content: '无法唤起支付宝,请确认已安装支付宝 App',
showCancel: false
})
reject(new Error('唤起支付宝失败'))
})
})
resolve()
})
}
function handleAppResume() {
if (paymentResolved.value || !currentOrderId.value) {
return
}
console.log('[Payment] APP从后台返回,查询汇付官方支付结果')
console.log('[Payment] merchantOrderId:', currentMerchantOrderId.value, 'hfSeqId:', currentHfSeqId.value)
uni.showLoading({ title: '查询支付结果...' })
const queryOrderId = currentMerchantOrderId.value || currentOrderId.value
queryHuifuTradeByOrderId(queryOrderId, currentHfSeqId.value)
.then(res => {
uni.hideLoading()
if (res.code === 200 && res.data) {
const tradeInfo = res.data.trade_info
let status = res.data.status || res.data.payStatus
if (tradeInfo) {
const transStat = tradeInfo.trans_stat
console.log('[Payment] 汇付原始状态:', transStat)
status = mapTransStat(transStat)
}
displayStatus.value = status
console.log('[Payment] 支付状态:', status, '状态描述:', getStatusText(status))
if (status === 'SUCCESS') {
paymentResolved.value = true
uni.showToast({ title: '支付成功', icon: 'success', duration: 2000 })
setTimeout(() => {
handlePaymentSuccess()
}, 1500)
} else if (status === 'FAIL') {
paymentResolved.value = true
uni.showToast({ title: '支付失败', icon: 'error' })
loading.value = false
} else if (status === 'CLOSED') {
paymentResolved.value = true
uni.showToast({ title: '订单已关闭', icon: 'none' })
loading.value = false
} else {
// 未支付,弹出倒计时提醒
openCountdownModal()
}
} else {
// 查询失败,弹出倒计时
openCountdownModal()
}
})
.catch(err => {
uni.hideLoading()
console.error('[Payment] 查询支付状态失败:', err)
// 查询失败,弹出倒计时
openCountdownModal()
})
}
function handlePaymentSuccess() {
if (cart.value.length === 0) return
uni.showLoading({ title: '开通会员卡...' })
// 逐个购买购物车中的会员卡
let successCount = 0
let failedCount = 0
for (const item of cart.value) {
purchaseMemberCard({
memberCardId: item.card.id
})
.then(memberRes => {
if (memberRes.code === 200 || memberRes.id || memberRes.memberCardRecordId) {
successCount += item.count
} else {
failedCount += item.count
}
})
.catch(() => {
failedCount += item.count
})
}
uni.hideLoading()
if (showQrPayment.value) {
showQrPayment.value = false
}
if (successCount > 0) {
showSuccessModal()
} else {
uni.showToast({ title: '开通失败', icon: 'none' })
loading.value = false
}
}
const countdownMinutes = computed(() => {
const mins = Math.floor(countdownRemaining.value / 60000)
return String(mins).padStart(2, '0')
})
const countdownSeconds = computed(() => {
const secs = Math.floor((countdownRemaining.value % 60000) / 1000)
return String(secs).padStart(2, '0')
})
const countdownMs = computed(() => {
const ms = Math.floor((countdownRemaining.value % 1000) / 10)
return String(ms).padStart(2, '0')
})
function startCountdown() {
if (countdownTimer) clearInterval(countdownTimer)
countdownRemaining.value = countdownTotal.value
countdownTimer = setInterval(() => {
countdownRemaining.value -= 10
if (countdownRemaining.value <= 0) {
countdownRemaining.value = 0
stopCountdown()
}
}, 10)
}
function stopCountdown() {
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
}
function openCountdownModal() {
startCountdown()
showCountdownModal.value = true
}
function closeCountdownModal() {
showCountdownModal.value = false
stopCountdown()
}
function goToPayFromCountdown() {
closeCountdownModal()
if (typeof plus !== 'undefined') {
const payUrl = currentPayUrl.value || ''
if (payUrl) {
plus.runtime.openURL(payUrl)
}
}
}
function showSuccessModal() {
showSuccessModalVisible.value = true
}
function closeSuccessModal() {
showSuccessModalVisible.value = false
}
function handleBack() {
showSuccessModalVisible.value = false
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack({ delta: 1 })
} else {
// 没有页面栈时,返回会员中心
uni.redirectTo({ url: '/pages/memberInfo/memberCard' })
}
}
function handleRePurchase() {
showSuccessModalVisible.value = false
displayStatus.value = ''
paymentResolved.value = false
currentOrderId.value = null
}
function handleGoToCard() {
showSuccessModalVisible.value = false
uni.redirectTo({ url: '/pages/memberInfo/memberCard' })
}
function openQrPayment(qrCode, orderId) {
qrCodeUrl.value = qrCode
currentOrderId.value = orderId
qrPaymentStatus.value = 'pending'
paymentResolved.value = false
displayStatus.value = 'PENDING'
showQrPayment.value = true
}
function closeQrPayment() {
showQrPayment.value = false
if (qrPaymentStatus.value === 'pending' && qrPaymentReject) {
qrPaymentReject(new Error('用户取消支付'))
}
qrPaymentResolve = null
qrPaymentReject = null
qrCodeUrl.value = ''
}
function handleQrQueryResult() {
if (paymentResolved.value || !currentOrderId.value) return
uni.showLoading({ title: '查询支付结果...' })
getPaymentStatus(currentOrderId.value)
.then(res => {
uni.hideLoading()
if (res.code === 200 && res.data) {
const status = res.data.status || res.data.payStatus
displayStatus.value = status
console.log('[QrPayment] 支付状态:', status)
if (status === 'SUCCESS') {
paymentResolved.value = true
qrPaymentStatus.value = 'success'
if (qrPaymentResolve) {
qrPaymentResolve(true)
}
} else if (status === 'FAIL' || status === 'CLOSED') {
paymentResolved.value = true
qrPaymentStatus.value = 'fail'
if (qrPaymentReject) {
qrPaymentReject(new Error(status === 'FAIL' ? '支付失败' : '订单已过期'))
}
} else {
// 未支付,弹出倒计时提醒
showQrPayment.value = false
openCountdownModal()
}
} else {
// 未查到支付记录,弹出倒计时
showQrPayment.value = false
openCountdownModal()
}
})
.catch(() => {
uni.hideLoading()
showQrPayment.value = false
openCountdownModal()
})
}
async function handlePurchase() {
if (cart.value.length === 0) {
uni.showToast({ title: '请选择会员卡', icon: 'none' })
return
}
const cartData = encodeURIComponent(JSON.stringify(cart.value))
uni.navigateTo({
url: `/pages/memberInfo/confirmPayment?cartData=${cartData}`
})
}
// 每次页面显示时重置支付状态
onShow(() => {
paymentResolved.value = false
currentOrderId.value = null
currentHfSeqId.value = null
currentMerchantOrderId.value = null
currentPayUrl.value = ''
displayStatus.value = ''
loading.value = false
})
onMounted(() => {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const options = currentPage.options || {}
const cardId = options.cardId
loadCardTypes(cardId)
// 注册APP从后台返回事件
if (typeof plus !== 'undefined') {
plus.globalEvent.addEventListener('resume', handleAppResume)
}
})
onUnmounted(() => {
paymentResolved.value = true
closeWebSocket()
if (typeof plus !== 'undefined') {
plus.globalEvent.removeEventListener('resume', handleAppResume)
}
})
</script>
<style lang="scss">
@import '@/common/style/base.css';
@import '@/common/style/memberInfo/pages/page-reset.css';
@import '@/common/style/memberInfo/pages/sub-page-base.css';
@import '@/common/style/memberInfo/member-info-component-reset.css';
@import '@/common/style/memberInfo/member-info-sub-nav.css';
@import '@/common/style/memberInfo/member-info-tap.css';
.purchase-card-page {
min-height: 100vh;
background: #F5F7FA;
}
.purchase-card-page__body {
padding-bottom: 100px;
}
.pc-intro {
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 24px;
background: linear-gradient(135deg, #1A365D 0%, #2C5282 100%);
}
.pc-intro__icon {
width: 64px;
height: 64px;
margin-bottom: 16px;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
image {
width: 40px;
height: 40px;
}
}
.pc-intro__title {
font-size: 20px;
font-weight: 600;
color: #FFFFFF;
margin-bottom: 8px;
}
.pc-intro__desc {
font-size: 14px;
color: rgba(255, 255, 255, 0.7);
}
.pc-list {
padding: 16px;
}
.pc-payment {
padding: 16px;
margin: 0 16px;
background: #FFFFFF;
border-radius: 16px;
}
.pc-payment__title {
font-size: 16px;
font-weight: 600;
color: #1A202C;
margin-bottom: 16px;
}
.pc-payment__methods {
display: flex;
gap: 16px;
}
.pc-payment__method {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 16px;
border: 2px solid #1677FF;
border-radius: 12px;
background: #F0F7FF;
position: relative;
&--selected {
border-color: #1677FF;
background: #F0F7FF;
}
}
.pc-payment__icon {
width: 40px;
height: 40px;
margin-bottom: 8px;
}
.pc-payment__name {
font-size: 14px;
color: #2D3748;
}
.pc-payment__check {
position: absolute;
top: 8px;
right: 8px;
width: 20px;
height: 20px;
background: #1677FF;
border-radius: 50%;
&::after {
content: '';
display: block;
width: 6px;
height: 6px;
background: #FFFFFF;
border-radius: 50%;
margin: 4px auto;
}
}
.pc-card {
display: flex;
align-items: flex-start;
background: #FFFFFF;
border-radius: 16px;
padding: 20px;
margin-bottom: 16px;
border: 2px solid transparent;
transition: all 0.2s;
&--selected {
border-color: #2C5282;
background: #F7FAFC;
}
}
.pc-card__check {
width: 24px;
height: 24px;
margin-right: 16px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.pc-card__check-inner {
width: 20px;
height: 20px;
border: 2px solid #CBD5E0;
border-radius: 4px;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
&--checked {
border-color: #2C5282;
background: #2C5282;
}
}
.pc-card__check-inner--checked::before {
content: '✓';
color: #FFFFFF;
font-size: 14px;
font-weight: bold;
}
.pc-card__content {
flex: 1;
}
.pc-card__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.pc-card__name {
font-size: 18px;
font-weight: 600;
color: #1A202C;
}
.pc-card__tag {
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
&--time {
background: #EBF8FF;
.pc-card__tag-text { color: #2B6CB0; }
}
&--count {
background: #FEF3E2;
.pc-card__tag-text { color: #D69E2E; }
}
&--value {
background: #F0FFF4;
.pc-card__tag-text { color: #38A169; }
}
&--default {
background: #E2E8F0;
.pc-card__tag-text { color: #718096; }
}
}
.pc-card__tag-text {
font-size: 12px;
font-weight: 500;
}
.pc-card__info {
display: flex;
flex-wrap: wrap;
gap: 8px 24px;
margin-bottom: 12px;
}
.pc-card__info-row {
display: flex;
align-items: center;
gap: 8px;
}
.pc-card__info-label {
font-size: 13px;
color: #718096;
}
.pc-card__info-value {
font-size: 13px;
font-weight: 600;
color: #2D3748;
}
.pc-card__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.pc-card__footer-left {
display: flex;
align-items: baseline;
gap: 8px;
}
.pc-card__footer-right {
display: flex;
align-items: center;
gap: 12px;
}
.pc-card__detail-btn {
padding: 6px 16px;
border-radius: 20px;
background: #EDF2F7;
transition: all 0.2s;
}
.pc-card__detail-text {
font-size: 12px;
color: #4A5568;
font-weight: 500;
}
.pc-card__quantity {
display: flex;
align-items: center;
gap: 8px;
}
.pc-card__qty-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #EDF2F7;
font-size: 18px;
color: #4A5568;
transition: all 0.2s;
&:active {
transform: scale(0.9);
}
&--add {
background: #1677FF;
color: #FFFFFF;
}
}
.pc-card__qty-num {
font-size: 16px;
font-weight: 600;
color: #1A202C;
min-width: 24px;
text-align: center;
}
.pc-card__price {
font-size: 24px;
font-weight: 700;
color: #E53E3E;
}
.pc-card__original-price {
font-size: 14px;
color: #A0AEC0;
text-decoration: line-through;
}
.pc-debug {
margin: 16px;
background: #FFFBEB;
border: 1px solid #FCD34D;
border-radius: 12px;
overflow: hidden;
&__header {
padding: 12px 16px;
background: #FEF3C7;
border-bottom: 1px solid #FCD34D;
}
&__title {
font-size: 14px;
font-weight: 600;
color: #92400E;
}
&__body {
padding: 12px 16px;
}
&__row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
border-bottom: 1px dashed #FCD34D;
&:last-child {
border-bottom: none;
}
}
&__label {
font-size: 13px;
color: #92400E;
font-weight: 500;
}
&__value {
font-size: 13px;
color: #78350F;
font-weight: 600;
max-width: 200px;
text-align: right;
word-break: break-all;
&--order {
font-size: 11px;
font-family: monospace;
}
&--pending {
color: #D97706;
}
&--success {
color: #059669;
}
&--fail {
color: #DC2626;
}
&--closed {
color: #6B7280;
}
&--none {
color: #9CA3AF;
}
}
&__actions {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed #FCD34D;
}
&__btn {
background: #F59E0B;
color: #FFFFFF;
text-align: center;
padding: 10px 0;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
}
}
.pc-cart-summary {
display: none;
}
.pc-bottom__total {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
padding-right: 16px;
}
.pc-bottom__total-label {
font-size: 13px;
color: #718096;
}
.pc-bottom__total-price {
font-size: 28px;
font-weight: 700;
color: #E53E3E;
}
.pc-bottom__total-count {
font-size: 12px;
color: #A0AEC0;
margin-top: 4px;
}
.pc-bottom {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
padding-bottom: calc(16px + env(safe-area-inset-bottom));
background: #FFFFFF;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.05);
z-index: 100;
}
.pc-bottom__total-label {
font-size: 14px;
color: #718096;
}
.pc-bottom__total-price {
font-size: 28px;
font-weight: 700;
color: #E53E3E;
}
.pc-bottom__btn {
background: #1677FF;
border-radius: 12px;
padding: 16px 48px;
transition: all 0.2s;
&--disabled {
background: #A0AEC0;
}
}
.pc-bottom__btn-text {
font-size: 16px;
font-weight: 600;
color: #FFFFFF;
}
.cart-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
z-index: 1000;
}
.cart-modal-dialog {
width: 100%;
background: #FFFFFF;
border-radius: 20px 20px 0 0;
max-height: 70vh;
display: flex;
flex-direction: column;
}
.cart-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid #E2E8F0;
}
.cart-modal__title {
font-size: 18px;
font-weight: 600;
color: #1A202C;
}
.cart-modal__close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: #718096;
}
.cart-modal__body {
flex: 1;
overflow-y: auto;
padding: 16px 24px;
}
.cart-modal__list {
display: flex;
flex-direction: column;
gap: 16px;
}
.cart-modal__item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
background: #F7FAFC;
border-radius: 12px;
}
.cart-modal__item-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.cart-modal__item-name {
font-size: 16px;
font-weight: 600;
color: #2D3748;
}
.cart-modal__item-desc {
font-size: 13px;
color: #718096;
}
.cart-modal__item-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.cart-modal__item-qty {
display: flex;
align-items: center;
gap: 12px;
}
.cart-modal__qty-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: #FFFFFF;
border: 1px solid #E2E8F0;
border-radius: 8px;
font-size: 18px;
color: #4A5568;
&--add {
background: #2C5282;
border-color: #2C5282;
color: #FFFFFF;
}
}
.cart-modal__qty-num {
font-size: 16px;
font-weight: 600;
color: #2D3748;
min-width: 32px;
text-align: center;
}
.cart-modal__item-price {
font-size: 16px;
font-weight: 600;
color: #E53E3E;
}
.cart-modal__empty {
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
color: #A0AEC0;
font-size: 14px;
}
.cart-modal__footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
padding-bottom: calc(20px + env(safe-area-inset-bottom));
border-top: 1px solid #E2E8F0;
background: #FFFFFF;
}
.cart-modal__total {
display: flex;
align-items: baseline;
gap: 8px;
}
.cart-modal__total-label {
font-size: 14px;
color: #718096;
}
.cart-modal__total-price {
font-size: 24px;
font-weight: 700;
color: #E53E3E;
}
.cart-modal__btn {
background: #2C5282;
border-radius: 12px;
padding: 16px 48px;
font-size: 16px;
font-weight: 600;
color: #FFFFFF;
}
// ==================== 模拟支付对话框 ====================
.mock-payment-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.qr-payment-dialog {
width: 320px;
background: #FFFFFF;
border-radius: 16px;
overflow: hidden;
}
.qr-payment-dialog__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid #E2E8F0;
}
.qr-payment-dialog__title {
font-size: 18px;
font-weight: 600;
color: #1A202C;
}
.qr-payment-dialog__close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: #A0AEC0;
}
.qr-payment-dialog__body {
padding: 24px;
}
.qr-payment-dialog__order,
.qr-payment-dialog__amount {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.qr-payment-dialog__label {
font-size: 14px;
color: #718096;
}
.qr-payment-dialog__value {
font-size: 14px;
color: #2D3748;
}
.qr-payment-dialog__money {
font-size: 24px;
font-weight: 700;
color: #E53E3E;
}
.qr-payment-dialog__qrcode {
display: flex;
justify-content: center;
align-items: center;
padding: 20px 0;
background: #F7FAFC;
border-radius: 8px;
margin: 16px 0;
}
.qr-payment-dialog__qrcode-img {
width: 200px;
height: 200px;
}
.qr-payment-dialog__qrcode-loading {
font-size: 14px;
color: #A0AEC0;
padding: 80px 0;
}
.qr-payment-dialog__tips {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.qr-payment-dialog__tip {
font-size: 12px;
color: #718096;
&--warn {
color: #ED8936;
}
}
.qr-payment-dialog__result {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 0;
}
.qr-payment-dialog__icon {
font-size: 48px;
margin-bottom: 16px;
}
.qr-payment-dialog__result-text {
font-size: 16px;
font-weight: 600;
color: #2D3748;
}
.qr-payment-dialog__footer {
display: flex;
gap: 12px;
padding: 0 24px 24px;
}
.qr-payment-dialog__btn {
flex: 1;
padding: 14px 0;
border-radius: 8px;
text-align: center;
font-size: 16px;
font-weight: 500;
&--primary {
background: #1677FF;
color: #FFFFFF;
}
&--secondary {
background: #E2E8F0;
color: #4A5568;
}
}
.pc-payment-status {
display: flex;
align-items: center;
gap: 16px;
padding: 20px 24px;
margin: 16px;
border-radius: 16px;
background: #FFFFFF;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
animation: fadeIn 0.3s ease;
}
.pc-payment-status__icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
flex-shrink: 0;
&--pending {
background: #EBF8FF;
}
&--success {
background: #F0FFF4;
}
&--fail {
background: #FFF5F5;
}
&--closed {
background: #FEF3E2;
}
&--none {
background: #F7FAFC;
}
}
.pc-payment-status__content {
display: flex;
flex-direction: column;
gap: 4px;
}
.pc-payment-status__title {
font-size: 16px;
font-weight: 600;
color: #1A202C;
}
.pc-payment-status__desc {
font-size: 13px;
color: #718096;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.success-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
animation: fadeIn 0.3s ease;
}
.success-modal-dialog {
width: 85%;
max-width: 360px;
background: #FFFFFF;
border-radius: 20px;
padding: 40px 32px;
display: flex;
flex-direction: column;
align-items: center;
animation: modalPop 0.3s ease;
}
@keyframes modalPop {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
.success-modal__icon {
font-size: 64px;
margin-bottom: 16px;
}
.success-modal__title {
font-size: 24px;
font-weight: 700;
color: #1A202C;
margin-bottom: 8px;
}
.success-modal__desc {
font-size: 15px;
color: #718096;
margin-bottom: 32px;
text-align: center;
}
.success-modal__buttons {
width: 100%;
display: flex;
flex-direction: column;
gap: 12px;
}
.success-modal__btn {
padding: 16px 0;
border-radius: 12px;
text-align: center;
font-size: 16px;
font-weight: 600;
transition: all 0.2s;
&--primary {
background: #1677FF;
color: #FFFFFF;
}
&--secondary {
background: #F7FAFC;
color: #4A5568;
}
&--outline {
background: #FFFFFF;
color: #1677FF;
border: 2px solid #1677FF;
}
}
.countdown-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
animation: fadeIn 0.3s ease;
}
.countdown-modal {
width: 85%;
max-width: 600rpx;
background: #FFFFFF;
border-radius: 24rpx;
overflow: hidden;
animation: modalPop 0.3s ease;
}
.countdown-modal__header {
padding: 32rpx;
text-align: center;
background: linear-gradient(135deg, #FF8C42 0%, #FF6B2C 100%);
}
.countdown-modal__title {
font-size: 32rpx;
font-weight: 600;
color: #FFFFFF;
}
.countdown-modal__body {
padding: 40rpx 32rpx;
}
.countdown-modal__info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
&:last-of-type {
margin-bottom: 32rpx;
}
}
.countdown-modal__label {
font-size: 28rpx;
color: #718096;
}
.countdown-modal__value {
font-size: 28rpx;
font-weight: 600;
color: #2D3748;
&--price {
color: #FF6B2C;
font-size: 36rpx;
}
}
.countdown-modal__timer-wrap {
background: #F7FAFC;
border-radius: 16rpx;
padding: 32rpx;
text-align: center;
}
.countdown-modal__tip {
font-size: 24rpx;
color: #718096;
margin-bottom: 16rpx;
display: block;
}
.countdown-modal__timer {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
}
.countdown-modal__time-num {
font-size: 40rpx;
font-weight: 700;
color: #FF6B2C;
font-family: 'Courier New', monospace;
min-width: 48rpx;
&--ms {
font-size: 32rpx;
min-width: 40rpx;
}
}
.countdown-modal__time-unit {
font-size: 24rpx;
color: #718096;
margin-right: 4rpx;
}
.countdown-modal__footer {
display: flex;
gap: 20rpx;
padding: 0 32rpx 32rpx;
}
.countdown-modal__btn {
flex: 1;
padding: 24rpx 0;
border-radius: 12rpx;
text-align: center;
font-size: 28rpx;
font-weight: 600;
transition: all 0.2s;
&--cancel {
background: #F7FAFC;
color: #4A5568;
}
&--confirm {
background: linear-gradient(135deg, #FF8C42 0%, #FF6B2C 100%);
color: #FFFFFF;
}
}
</style>