Files
lilishop-uniapp/pages/cart/payment/payOrder.vue
pikachu1995@126.com cc71d20aa6 feat(paid-member): 添加付费会员功能模块
- 新增付费会员API接口层,支持会员查询、下单、记录及报价
- 新增付费会员页面,展示权益、推荐商品及购买流程
- 新增付费会员购买记录页面,支持订单状态查看与继续支付
- 新增 PaidMemberPrice 组件用于商品列表和详情页展示会员价
- 个人中心页添加付费会员入口,支持配置化展示标题与背景
- 商品详情页集成会员价展示,支持秒杀和拼团场景下隐藏
- 商品列表组件集成会员价展示,支持批量请求与防竞态
- 支付页适配付费会员支付流程,支持H5轮询与结果回调
- 订单确认页优惠文案更新为"活动优惠(含会员优惠)"
- pages.json 新增付费会员及购买记录页面路由配置
- README 更新升级计划,按时间倒序补充已完成功能明细
2026-11-05 22:37:12 +08:00

499 lines
14 KiB
Vue
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>
<div :style="themeStyle" class="wrapper">
<div class="box">
<div class="block block-1">
<image class="img" src="@/pages/cart/static/pay.png" />
<p class="ptips">收银台</p>
<view class="ptips ptips-countdown">
剩余支付时间:
<u-count-down
v-if="autoCancelTime > 0"
:time="autoCancelTime"
format="HH:mm:ss"
@finish="onPayTimeout"
>
<template #default="{ hours, minutes, seconds }">
<view class="countdown-box">
<text class="countdown-num">{{ padTime(hours) }}</text>
<text class="countdown-colon">:</text>
<text class="countdown-num">{{ padTime(minutes) }}</text>
<text class="countdown-colon">:</text>
<text class="countdown-num">{{ padTime(seconds) }}</text>
</view>
</template>
</u-count-down>
<view v-else class="countdown-box">
<text class="countdown-num">00</text>
<text class="countdown-colon">:</text>
<text class="countdown-num">00</text>
<text class="countdown-colon">:</text>
<text class="countdown-num">00</text>
</view>
</view>
<view class="ptips ptips-price">
支付金额
<text class="price-text">¥{{ unitPrice(cashierParams.price) }}</text>
</view>
</div>
</div>
<div class="__pay_form__">
</div>
<div class="block-4" v-if="cashierParams.price > 0">
<div class="payItem payItem-title">支付方式</div>
<div
class="payItem payItem-method"
v-for="(item, index) in payList"
:key="index"
@click="awaitPay(item, index)"
>
<view class="method-left">
<template v-if="item == 'ALIPAY'">
<u-icon class="method_icon" name="zhifubao-circle-fill" color="#008ffa" size="44"></u-icon>
<text class="method_name">支付宝</text>
</template>
<template v-if="item == 'WECHAT'">
<u-icon class="method_icon" name="weixin-circle-fill" color="#00c98b" size="44"></u-icon>
<text class="method_name">微信</text>
</template>
<template v-if="item == 'WALLET'">
<u-icon class="method_icon" name="red-packet-fill" color="#dd6161" size="44"></u-icon>
<text class="method_name">余额支付(当前余额:¥{{ unitPrice(walletValue) }})</text>
</template>
</view>
<u-icon size="28" color="#b1b1b1" name="arrow-right"></u-icon>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, getCurrentInstance, onMounted, computed } from 'vue'
import { onLoad, onBackPress, onUnload } from '@dcloudio/uni-app'
import { useStore } from '@/store'
import * as API_Trade from '@/api/trade'
import { payCallback } from '@/api/members'
import { unitPrice } from '@/utils/filters.js'
import { getThemeStyle } from '@/utils/theme'
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const { proxy } = getCurrentInstance()!
const routerVal = ref<Record<string, string>>({})
const cashierParams = ref<Record<string, any>>({ price: 0 })
const payList = ref<string[]>([])
const sn = ref('')
const orderType = ref('')
const paymentType = ref('')
const paymentClient = ref('')
const walletValue = ref(0)
const autoCancelTime = ref(0)
let h5PollTimer: ReturnType<typeof setInterval> | undefined
onUnload(() => { if (h5PollTimer) clearInterval(h5PollTimer) })
onLoad((val) => {
routerVal.value = val || {}
// #ifdef APP-PLUS
paymentType.value = 'APP'
paymentClient.value = 'APP'
// #endif
// #ifdef MP-WEIXIN
paymentType.value = 'WECHAT_MP'
paymentClient.value = 'MP'
// #endif
// #ifdef H5
paymentType.value = 'H5'
paymentClient.value = isWeiXin() ? 'JSAPI' : 'H5'
// #endif
})
onBackPress((e) => {
if (e.from == 'backbutton') {
if (routerVal.value.paid_member_sn) {
uni.redirectTo({ url: '/pages/mine/paidMemberRecords' })
} else if (routerVal.value.recharge_sn) {
uni.switchTab({ url: '/pages/tabbar/user/my' })
} else {
uni.navigateTo({ url: '/pages/order/myOrder?status=0' })
}
return true
}
return false
})
onMounted(() => {
cashierData()
})
function hideLoadingIfNeeded() {
if (store.state.isShowToast) uni.hideLoading()
}
function callback(paymentMethod: string) {
if (orderType.value === 'PAID_MEMBER') {
uni.redirectTo({ url: '/pages/mine/paidMember?payment_sn=' + encodeURIComponent(sn.value) })
return
}
const goSuccess = () => {
uni.redirectTo({
url:
'/pages/cart/payment/success?paymentMethod=' +
paymentMethod +
'&payPrice=' +
cashierParams.value.price +
'&orderType=' +
orderType.value +
'&orderPromotionType=' +
(cashierParams.value.orderPromotionType || ''),
})
}
goSuccess()
}
function cashierData() {
const parms: Record<string, string> = {}
if (routerVal.value.paid_member_sn) {
sn.value = routerVal.value.paid_member_sn
orderType.value = 'PAID_MEMBER'
} else if (routerVal.value.recharge_sn) {
sn.value = routerVal.value.recharge_sn
orderType.value = 'RECHARGE'
} else if (routerVal.value.trade_sn) {
sn.value = routerVal.value.trade_sn
orderType.value = 'TRADE'
} else {
sn.value = routerVal.value.order_sn
orderType.value = 'ORDER'
}
parms.sn = sn.value
parms.orderType = orderType.value
parms.clientType = paymentType.value
API_Trade.getCashierData(parms).then((res) => {
if (res.data.success) {
cashierParams.value = res.data.result
// #ifdef MP-WEIXIN
payList.value = res.data.result.support.filter((item: string) => item != 'ALIPAY')
// #endif
if (routerVal.value.recharge_sn) {
payList.value = res.data.result.support.filter((item: string) => item != 'WALLET')
} else {
payList.value = res.data.result.support
}
// #ifdef H5
const ua = window.navigator.userAgent.toLowerCase()
if (ua.match(/MicroMessenger/i) == 'micromessenger') {
payList.value = res.data.result.support.filter((item: string) => item != 'ALIPAY')
if (orderType.value == 'RECHARGE') {
payList.value = res.data.result.support.filter((item: string) => item == 'WECHAT')
}
}
// #endif
walletValue.value = res.data.result.walletValue
const cancelAt = Number(res.data.result.autoCancel)
autoCancelTime.value = cancelAt > 0 ? Math.max(cancelAt - Date.now(), 0) : 0
} else if (res.data.code == 32000) {
setTimeout(() => {
uni.redirectTo({ url: '/pages/order/myOrder?status=0' })
}, 500)
}
})
}
function awaitPay(payment: string) {
proxy.$u.throttle(() => {
pay(payment)
}, 2000)
}
function padTime(val: number) {
return String(val != null ? val : 0).padStart(2, '0')
}
function onPayTimeout() {
uni.showToast({ title: '支付超时,请重新下单', icon: 'none' })
setTimeout(() => {
uni.redirectTo({ url: '/pages/order/myOrder?status=0' })
}, 1500)
}
function goPayError(message = '支付失败,如果您已支付,请勿反复支付') {
if (orderType.value === 'PAID_MEMBER') {
uni.showToast({ title: '支付未完成,请在购买记录中查看结果', icon: 'none' })
uni.redirectTo({ url: '/pages/mine/paidMemberRecords' })
return
}
const query = [`orderSn=${encodeURIComponent(sn.value)}`, `message=${encodeURIComponent(message)}`]
uni.navigateTo({ url: `/pages/cart/payment/error?${query.join('&')}` })
}
async function pay(payment: string) {
const params = {
sn: sn.value,
orderType: orderType.value,
clientType: paymentType.value,
}
const paymentMethod = payment
const client = paymentClient.value
uni.showLoading({ title: '正在唤起支付...', mask: true })
// #ifdef APP-PLUS
await API_Trade.initiatePay(paymentMethod, client, params).then((signXml) => {
hideLoadingIfNeeded()
if (!signXml.data.success) {
uni.showToast({ title: signXml.data.message, duration: 2000 })
return
}
const payForm = signXml.data.result
const provider = paymentMethod === 'WECHAT' ? 'wxpay' : 'alipay'
if (paymentMethod === 'WALLET') {
uni.showToast({ icon: 'none', title: '支付成功!' })
callback(paymentMethod)
} else {
uni.requestPayment({
provider,
orderInfo: payForm || '',
success: () => {
uni.showToast({ icon: 'none', title: '支付成功!' })
callback(paymentMethod)
},
fail: () => {
goPayError()
},
})
}
})
// #endif
// #ifdef H5
await API_Trade.initiatePay(paymentMethod, client, params).then((res) => {
const response = res.data
if (paymentMethod !== 'ALIPAY' && !response.success) {
uni.showToast({ title: response.message, duration: 2000, icon: 'none' })
return
}
if (paymentMethod === 'ALIPAY') {
document.write(response)
} else if (paymentMethod === 'WECHAT') {
if (isWeiXin()) {
WeixinJSBridge.invoke('getBrandWCPayRequest', response.result, (payRes: any) => {
if (payRes.err_msg == 'get_brand_wcpay_request:ok') {
uni.showToast({ icon: 'none', title: '支付成功!' })
callback(paymentMethod)
} else {
goPayError()
}
})
hideLoadingIfNeeded()
} else {
const paymentUrl = typeof response.result === 'string' && response.result.startsWith('http')
? response.result : JSON.parse(response.result).h5_url
const returnUrl = orderType.value === 'PAID_MEMBER'
? window.location.href.split('#')[0] + '#/pages/mine/paidMember?payment_sn=' + encodeURIComponent(sn.value)
: window.location.href
window.location.href = paymentUrl + (paymentUrl.includes('?') ? '&' : '?') + 'redirect_url=' + encodeURIComponent(returnUrl)
const searchParams = { ...params, price: cashierParams.value }
let attempts = 0
if (h5PollTimer) clearInterval(h5PollTimer)
h5PollTimer = setInterval(() => {
if (++attempts > 100) { clearInterval(h5PollTimer); return }
payCallback(searchParams).then((cbRes) => {
if (cbRes.data.result) {
clearInterval(h5PollTimer)
if (orderType.value === 'PAID_MEMBER') callback(paymentMethod)
else uni.navigateTo({ url: '/pages/order/myOrder' })
}
}).catch(() => {})
}, 3000)
hideLoadingIfNeeded()
}
} else if (paymentMethod === 'WALLET') {
uni.showToast({ title: response.message, icon: 'none' })
if (response.success) callback(paymentMethod)
}
})
// #endif
// #ifdef MP-WEIXIN
await API_Trade.initiatePay(paymentMethod, client, params).then((res) => {
const response = res.data.result
if (!res.data.success) {
uni.showModal({ content: res.data.message, showCancel: false })
return
}
if (paymentMethod === 'WECHAT') {
uni.requestPayment({
provider: 'wxpay',
appid: response.appid,
timeStamp: response.timeStamp,
nonceStr: response.nonceStr,
package: response.package,
signType: response.signType,
paySign: response.paySign,
success: () => {
uni.showToast({ icon: 'none', title: '支付成功!' })
callback(paymentMethod)
},
fail: () => {
goPayError()
},
})
} else {
uni.showToast({ icon: 'none', title: '支付成功!' })
callback(paymentMethod)
}
})
// #endif
}
function isWeiXin() {
const ua = window.navigator.userAgent.toLowerCase()
return ua.match(/MicroMessenger/i) == 'micromessenger'
}
</script>
<style scoped lang="scss">
.method_icon {
flex-shrink: 0;
}
.method-left {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
}
.method_name {
font-size: 28rpx;
color: #333;
padding-left: 20rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.payItem {
padding: 0 30rpx;
border-top: 1px solid #f5f5f5;
color: #333;
}
.payItem-title {
font-size: 28rpx;
color: #999;
padding-top: 24rpx;
padding-bottom: 16rpx;
border-top: none;
}
.payItem-method {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 100rpx;
padding-top: 24rpx;
padding-bottom: 24rpx;
}
.ptips {
font-size: 32rpx;
margin: 20rpx 0;
color: #333;
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
}
.ptips-countdown {
gap: 8rpx;
}
.ptips-price {
.price-text {
font-size: 40rpx;
color: $main-color;
font-weight: 600;
margin-left: 10rpx;
}
}
.countdown-box {
display: inline-flex;
align-items: center;
}
.countdown-num {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44rpx;
height: 44rpx;
padding: 0 8rpx;
font-size: 28rpx;
color: #008ffa;
border: 1rpx solid #008ffa;
border-radius: 8rpx;
box-sizing: border-box;
}
.countdown-colon {
margin: 0 6rpx;
font-size: 28rpx;
color: #008ffa;
font-weight: 600;
}
.img {
width: 392rpx !important;
height: 296rpx !important;
}
.wrapper {
min-height: 100vh;
height: auto;
background: #f9f9f9;
}
.block-4 {
background: #fff;
color: $u-tips-color;
> p {
padding: 8rpx;
}
}
.box {
background: #fff;
padding: 40rpx 0;
// justify-content: center; //这个是X轴居中
// align-items: center; //这个是 Y轴居中
}
.block {
text-align: center;
display: block;
width: 100%;
image {
width: 200rpx;
height: 200rpx;
}
}
.block-1 {
margin-top: 80rpx;
}
.btns {
margin: 0 20rpx;
}
</style>