基于Vue 3 的智能支付二维码弹窗组件设计与实现
最近做一个云服务支付系统的功能需求。集成支付宝支付功能,生成支付二维码并处理支付结果。
一、应用场景
在支付类系统中,我们经常需要实现这样的功能:当用户发起支付请求后,展示支付二维码,并持续轮询服务器查询支付状态。本文介绍的组件完美解决了以下需求:
- 动态二维码展示:支持自定义样式和参数
- 状态轮询机制:自动查询支付结果
- 智能超时处理:60秒自动销毁二维码
- 事件驱动机制:支付成功/超时事件通知
- 用户体验优化:实时倒计时显示
二、核心功能设计
1. 安装二维码生成库
推荐使用 qrcode
库来生成二维码和 Element-Plus 的弹窗 请先安装 qrcode-vue3 插件
npm install qrcode --save
2. 创建二维码组件
创建一个可复用的二维码组件 Qrcode.vue
:
<template><div class="qrcode-container"><canvas ref="qrcodeCanvas"></canvas></div>
</template><script setup>
import { ref, onMounted, watch } from 'vue'
import QRCode from 'qrcode'const props = defineProps({url: {type: String,required: true},size: {type: Number,default: 200}
})const qrcodeCanvas = ref(null)const generateQR = async () => {try {await QRCode.toCanvas(qrcodeCanvas.value, props.url, {width: props.size,margin: 1,color: {dark: '#000000',light: '#ffffff'}})} catch (err) {console.error('生成二维码失败:', err)}
}onMounted(generateQR)watch(() => props.url, () => {generateQR()
})
</script><style scoped>
.qrcode-container {display: flex;justify-content: center;align-items: center;padding: 10px;background: white;border: 1px solid #eee;border-radius: 4px;
}
</style>
3. 创建支付对话框组件
创建 PaymentDialog.vue
组件来展示二维码和处理支付流程:
二维码生成性能优化
问题:频繁生成二维码可能导致性能问题
解决方案:使用防抖技术限制生成频率,缓存已生成的二维码
支付状态轮询
问题:频繁轮询增加服务器压力
解决方案:合理设置轮询间隔(如10秒一次),支付接近超时时增加频率
支付超时处理:10分钟倒计时自动取消
轮询限制:最大60次轮询尝试,避免无限请求
<template><el-dialog v-model="visible" title="支付宝支付" width="400px" :close-on-click-modal="false" :close-on-press-escape="false":before-close="handleClose"><div class="payment-content"><div class="qrcode-wrapper"><Qrcode v-if="qrCodeUrl" :url="qrCodeUrl" :size="220" /><div v-else class="qrcode-placeholder"><el-icon :size="40"><Picture /></el-icon><p>正在生成支付二维码...</p></div></div><div class="payment-info"><div class="payment-method"><img src="@/assets/alipay-logo.png" alt="支付宝" class="alipay-logo"><span>支付宝扫码支付</span></div><div class="countdown">支付剩余时间: {{ formattedTime }}</div></div><div class="payment-amount">支付金额: <span class="amount">¥{{ amount.toFixed(2) }}</span></div></div><template #footer><el-button @click="handleClose">取消支付</el-button><el-button type="primary" @click="refreshQRCode" :loading="refreshing">刷新二维码</el-button></template></el-dialog>
</template><script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Picture } from '@element-plus/icons-vue'
import Qrcode from './Qrcode.vue'const props = defineProps({modelValue: {type: Boolean,default: false},qrCodeUrl: {type: String,default: ''},amount: {type: Number,required: true},orderId: {type: String,required: true}
})const emits = defineEmits(['update:modelValue','payment-success','payment-failed','refresh'
])const visible = computed({get() {return props.modelValue},set(value) {emits('update:modelValue', value)}
})const refreshing = ref(false)
let timer = null
const TIMEOUT_DURATION = 600 // 10分钟 = 600秒
const totalSeconds = ref(TIMEOUT_DURATION)//格式化倒计时
const formattedTime = computed(() => {const minutes = Math.floor(totalSeconds.value / 60)const seconds = totalSeconds.value % 60return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
})// 开始倒计时
const startCountdown = () => {totalSeconds.value = TIMEOUT_DURATIONclearInterval(timer)timer = setInterval(() => {if (totalSeconds.value <= 0) {handlePaymentTimeout()return}totalSeconds.value--// 最后1分钟每分钟检查一次支付状态if (totalSeconds.value <= 60 && totalSeconds.value % 10 === 0) {checkPaymentStatus()}// 正常情况每30秒检查一次if (totalSeconds.value > 60 && totalSeconds.value % 30 === 0) {checkPaymentStatus()}}, 1000)
}
// 处理支付超时
const handlePaymentTimeout = async () => {clearInterval(timer)try {// 调用后端接口取消订单await axios.post('/api/payment/cancel', { orderId: props.orderId })ElMessageBox.confirm('支付已超时,订单自动取消。是否重新创建支付?', '支付超时', {confirmButtonText: '重新支付',cancelButtonText: '取消',type: 'warning'}).then(() => {emits('refresh') // 触发父组件重新创建支付}).catch(() => {handleClose()})emits('payment-failed', '支付超时')} catch (error) {ElMessage.error('取消订单失败')handleClose()}
}
// 检查支付状态
const checkPaymentStatus = async () => {if (checkingStatus.value) returncheckingStatus.value = truetry {const response = await axios.get(`/api/payment/status/${props.orderId}`)if (response.data.status === 'SUCCESS') {handlePaymentSuccess()} else if (response.data.status === 'CLOSED') {handlePaymentFailed('订单已关闭')}} finally {checkingStatus.value = false}
}
// 刷新二维码
const refreshQRCode = async () => {refreshing.value = truetry {emits('refresh')totalSeconds.value = 600 // 重置倒计时ElMessage.success('二维码已刷新')} finally {refreshing.value = false}
}// 处理支付成功
const handlePaymentSuccess = () => {clearInterval(timer)emits('payment-success')visible.value = falseElMessage.success('支付成功')
}// 处理支付失败
const handlePaymentFailed = (message) => {clearInterval(timer)emits('payment-failed', message)visible.value = falseElMessage.warning(message || '支付失败')
}// 关闭对话框
const handleClose = () => {clearInterval(timer)visible.value = falseemits('payment-failed', '用户取消支付')
}// 组件挂载时开始倒计时
onMounted(() => {if (visible.value) {startCountdown()}
})// 监听visible变化
watch(visible, (newVal) => {if (newVal) {startCountdown()} else {clearInterval(timer)}
})// 组件卸载时清除定时器
onUnmounted(() => {clearInterval(timer)
})
</script><style scoped lang="less">
.payment-content {display: flex;flex-direction: column;align-items: center;padding: 0 20px;.qrcode-wrapper {margin: 15px 0;padding: 10px;background: #fff;border: 1px solid #eee;border-radius: 4px;.qrcode-placeholder {width: 220px;height: 220px;display: flex;flex-direction: column;justify-content: center;align-items: center;color: #999;}}.payment-info {margin: 15px 0;text-align: center;.payment-method {display: flex;align-items: center;justify-content: center;margin-bottom: 10px;}.alipay-logo {width: 24px;height: 24px;margin-right: 8px;}.countdown {font-size: 14px;color: #f56c6c;font-weight: bold;}}.payment-amount {margin: 15px 0;font-size: 16px;.amount {font-size: 20px;color: #f56c6c;font-weight: bold;}}
}
</style>
4. 在父组件中使用支付对话框
<template><div><el-button type="primary" @click="showPaymentDialog">支付宝支付</el-button><PaymentDialogv-model="dialogVisible":qr-code-url="qrCodeUrl":amount="paymentAmount":order-id="orderId"@payment-success="handlePaymentSuccess"@payment-failed="handlePaymentFailed"@refresh="refreshQRCode"/></div>
</template><script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import PaymentDialog from '@/components/PaymentDialog.vue'
import axios from 'axios'const dialogVisible = ref(false)
const qrCodeUrl = ref('')
const paymentAmount = ref(0)
const orderId = ref('')// 显示支付对话框
const showPaymentDialog = async () => {try {const response = await axios.post('/api/payment/create', {amount: 100, // 支付金额subject: '商品购买', // 订单标题body: '商品描述' // 订单描述})qrCodeUrl.value = response.data.qrCodeUrlpaymentAmount.value = response.data.amountorderId.value = response.data.orderIddialogVisible.value = true} catch (error) {ElMessage.error('创建支付订单失败')console.error(error)}
}// 刷新二维码
const refreshQRCode = async () => {try {const response = await axios.post('/api/payment/refresh', {orderId: orderId.value})qrCodeUrl.value = response.data.qrCodeUrl} catch (error) {ElMessage.error('刷新二维码失败')}
}// 处理支付成功
const handlePaymentSuccess = () => {ElMessage.success('支付成功')// 更新订单状态等后续操作
}// 处理支付失败
const handlePaymentFailed = (message) => {ElMessage.warning(message || '支付失败')
}
</script>
以上方案可根据实际业务需求调整轮询频率、超时时间等参数。