整合api请求、添加购买会员卡页面、登陆页面

This commit is contained in:
future
2026-06-23 22:17:53 +08:00
parent 1c547a717e
commit 8d8c823616
70 changed files with 7666 additions and 2656 deletions
@@ -0,0 +1,694 @@
<template>
<view class="scroll-container theme-light">
<view class="purchase-card-page">
<MemberInfoSubNav title="购买会员卡" @back="goBack" />
<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>
<!-- 卡列表 -->
<view class="pc-list">
<view
v-for="card in cardTypes"
:key="card.id"
class="pc-card"
:class="{ 'pc-card--selected': selectedCardId === card.id }"
@tap="selectCard(card)"
>
<view class="pc-card__check">
<view
class="pc-card__check-inner"
:class="{ 'pc-card__check-inner--checked': selectedCardId === 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">
<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">
<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>
</view>
</view>
<!-- 支付方式 -->
<view class="pc-payment">
<text class="pc-payment__title">支付方式</text>
<view class="pc-payment__methods">
<view class="pc-payment__method pc-payment__method--selected">
<image src="https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/alipay.png" mode="aspectFit" class="pc-payment__icon" />
<text class="pc-payment__name">支付宝支付</text>
<view class="pc-payment__check"></view>
</view>
</view>
</view>
<!-- 底部 -->
<view class="pc-bottom">
<view class="pc-bottom__total">
<text class="pc-bottom__total-label">合计</text>
<text class="pc-bottom__total-price">¥{{ selectedCard?.price || 0 }}</text>
</view>
<view
class="pc-bottom__btn"
:class="{ 'pc-bottom__btn--disabled': !selectedCard || loading }"
@tap="handlePurchase"
>
<text class="pc-bottom__btn-text">{{ loading ? '处理中...' : '立即购买' }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { onBackPress } from '@dcloudio/uni-app'
import MemberInfoSubNav from '@/components/memberInfo/MemberInfoSubNav.vue'
import { purchaseMemberCard, createPayment, getPaymentStatus } from '@/api/main.js'
import { loadMemberStore } from '@/common/memberInfo/store.js'
const cardTypes = ref([
{ id: 1, cardName: '月卡', cardType: 'DURATION', validityDays: 30, price: 1, originalPrice: 399 },
{ id: 2, cardName: '季卡', cardType: 'DURATION', validityDays: 90, price: 1, originalPrice: 999 },
{ id: 3, cardName: '年卡', cardType: 'DURATION', validityDays: 365, price: 1, originalPrice: 2999 },
{ id: 4, cardName: '10次卡', cardType: 'COUNT', validityDays: 90, totalTimes: 10, price: 1 },
{ id: 5, cardName: '50次卡', cardType: 'COUNT', validityDays: 180, totalTimes: 50, price: 1 },
{ id: 6, cardName: '100次卡', cardType: 'COUNT', validityDays: 365, totalTimes: 100, price: 1 },
{ id: 7, cardName: '储值500', cardType: 'BALANCE', validityDays: 365, amount: 500, price: 1 },
{ id: 8, cardName: '储值1000', cardType: 'BALANCE', validityDays: 365, amount: 1000, price: 1 },
])
const selectedCardId = ref(null)
const loading = ref(false)
const selectedCard = ref(null)
const pollingTimer = ref(null)
const isPolling = ref(false)
// 支付方式
const TRADE_TYPE = 'ALIPAY'
// ==================== 工具函数 ====================
function getCardTypeName(type) {
const map = { DURATION: '时长卡', COUNT: '次卡', BALANCE: '储值卡' }
return map[type] || type
}
function getTagClass(type) {
const map = { DURATION: 'time', COUNT: 'count', BALANCE: 'value' }
return map[type] || 'default'
}
function selectCard(card) {
selectedCardId.value = card.id
selectedCard.value = card
}
function goBack() {
uni.redirectTo({
url: '/pages/memberInfo/memberCard'
})
}
onBackPress(() => {
uni.redirectTo({
url: '/pages/memberInfo/memberCard'
})
return true
})
// ==================== 唤起支付宝 App ====================
function invokeAlipayApp(payUrl) {
return new Promise((resolve, reject) => {
if (!payUrl) {
reject(new Error('支付链接为空'))
return
}
console.log('[Alipay] 准备唤起支付宝:', payUrl)
// 检查是否在 App 环境
if (typeof plus === 'undefined') {
uni.showModal({
title: '提示',
content: '请在 App 内完成支付',
showCancel: false
})
reject(new Error('非 App 环境'))
return
}
// 使用 plus.runtime.openURL 唤起支付宝
plus.runtime.openURL(payUrl, function(err) {
console.error('[Alipay] 唤起支付宝失败:', err)
uni.showModal({
title: '提示',
content: '无法唤起支付宝,请确认已安装支付宝 App',
showCancel: false
})
reject(new Error('唤起支付宝失败: ' + (err?.message || '未知错误')))
})
// 直接 resolve,支付结果由轮询处理
resolve()
})
}
// ==================== 轮询支付状态 ====================
function startPolling(orderId) {
return new Promise((resolve, reject) => {
if (isPolling.value) return
isPolling.value = true
let pollCount = 0
const maxRetry = 60 // 60秒
let isResolved = false
// 监听 App 回到前台
const handleResume = () => {
if (isResolved) return
console.log('[Poll] App 回到前台,立即查询')
doQuery()
}
if (typeof plus !== 'undefined') {
plus.globalEvent.addEventListener('resume', handleResume)
}
function doQuery() {
if (isResolved) return
pollCount++
getPaymentStatus(orderId)
.then(res => {
if (res.code === 200 && res.data) {
const status = res.data.status || res.data.payStatus
console.log(`[Poll] 第 ${pollCount} 次查询,状态:`, status)
if (status === 'SUCCESS') {
isResolved = true
cleanup()
resolve(true)
return
} else if (status === 'FAIL' || status === 'CLOSED') {
isResolved = true
cleanup()
reject(new Error('支付失败'))
return
}
}
// 继续轮询
if (pollCount < maxRetry && !isResolved) {
pollingTimer.value = setTimeout(doQuery, 1000)
} else if (!isResolved) {
isResolved = true
cleanup()
reject(new Error('支付超时,请确认是否已完成支付'))
}
})
.catch(err => {
console.error('[Poll] 查询异常:', err)
if (pollCount < maxRetry && !isResolved) {
pollingTimer.value = setTimeout(doQuery, 1000)
} else if (!isResolved) {
isResolved = true
cleanup()
reject(new Error('支付超时,请确认是否已完成支付'))
}
})
}
function cleanup() {
isPolling.value = false
if (pollingTimer.value) {
clearTimeout(pollingTimer.value)
pollingTimer.value = null
}
if (typeof plus !== 'undefined') {
plus.globalEvent.removeEventListener('resume', handleResume)
}
}
// 延迟 1.5 秒开始轮询
pollingTimer.value = setTimeout(doQuery, 1500)
})
}
// ==================== 核心购买逻辑 ====================
async function handlePurchase() {
if (!selectedCard.value) {
uni.showToast({ title: '请选择会员卡', icon: 'none' })
return
}
if (loading.value) return
loading.value = true
try {
const store = loadMemberStore()
const memberId = store.memberProfile?.id || store.profile?.id
// if (!memberId) {
// uni.showToast({ title: '请先登录', icon: 'none' })
// loading.value = false
// return
// }
// 金额转换:元 -> 分
const transAmt = String(Math.round(selectedCard.value.price * 100))
const cardName = selectedCard.value.cardName
// 1. 创建支付订单
uni.showLoading({ title: '创建订单中...' })
const payRes = await createPayment({
memberId: memberId,
orderType: 'MEMBER_CARD',
goodsDesc: `购买${cardName}`,
transAmt: transAmt,
tradeType: TRADE_TYPE,
remark: `会员卡类型ID:${selectedCard.value.id}`
})
if (payRes.code !== 200) {
uni.hideLoading()
uni.showToast({ title: payRes.message || '创建支付订单失败', icon: 'none' })
loading.value = false
return
}
const payData = payRes.data
const orderId = payData.orderId
// 2. 获取支付链接
const payUrl = payData.payUrl || payData.payInfo || payData.h5PayUrl
if (!payUrl) {
uni.hideLoading()
uni.showToast({ title: '获取支付链接失败', icon: 'none' })
loading.value = false
return
}
// 3. 唤起支付宝 App
uni.hideLoading()
uni.showLoading({ title: '正在唤起支付宝...' })
try {
await invokeAlipayApp(payUrl)
} catch (err) {
uni.hideLoading()
uni.showToast({ title: '唤起支付宝失败,请确认已安装支付宝', icon: 'none' })
loading.value = false
return
}
// 4. 轮询支付状态
uni.showLoading({ title: '等待支付结果...' })
await startPolling(orderId)
// 5. 支付成功,购买会员卡
uni.hideLoading()
const memberRes = await purchaseMemberCard({
memberId: memberId,
memberCardId: selectedCard.value.id,
sourceOrderId: orderId
})
if (memberRes.code === 200) {
uni.showToast({ title: '购买成功 🎉', icon: 'success' })
setTimeout(() => {
uni.redirectTo({
url: '/pages/memberInfo/memberCard'
})
}, 1500)
} else {
uni.showToast({ title: memberRes.message || '会员卡购买失败', icon: 'none' })
}
} catch (e) {
console.error('[purchaseCard] 购买失败:', e)
uni.hideLoading()
const errorMsg = e.message || '购买失败'
uni.showToast({ title: errorMsg, icon: 'none' })
} finally {
loading.value = false
}
}
// ==================== 生命周期 ====================
onMounted(() => {
if (cardTypes.value.length > 0) {
selectCard(cardTypes.value[0])
}
})
onUnmounted(() => {
// 清理轮询
if (pollingTimer.value) {
clearTimeout(pollingTimer.value)
pollingTimer.value = null
}
isPolling.value = false
if (typeof plus !== 'undefined') {
plus.globalEvent.removeEventListener('resume', () => {})
}
})
</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: 50%;
transition: all 0.2s;
&--checked {
border-color: #2C5282;
background: #2C5282;
&::after {
content: '';
display: block;
width: 6px;
height: 6px;
background: #FFFFFF;
border-radius: 50%;
margin: 4px auto;
}
}
}
.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: baseline;
gap: 8px;
}
.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-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 {
display: flex;
align-items: baseline;
gap: 8px;
}
.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;
}
</style>