Files
sale/frontend/src/views/user/Checkout.vue
T
admin 7bbff3223b feat: bepusdt支付渠道增加钱包地址显示和复制功能
- 后端: 添加wallet_address字段到支付信息返回
- 前端: Checkout页面显示钱包地址,支持点击复制

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 19:24:00 +08:00

390 lines
9.1 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 class="checkout-page">
<div class="checkout-card" v-if="paymentInfo">
<div class="checkout-header">
<h2>USDT 支付</h2>
<span class="trade-type">{{ paymentInfo.trade_type?.toUpperCase() }}</span>
</div>
<div class="countdown" v-if="remaining > 0">
<svg viewBox="0 0 36 36" class="countdown-ring">
<circle cx="18" cy="18" r="16" fill="none" stroke="rgba(255,255,255,0.08)" stroke-width="2" />
<circle cx="18" cy="18" r="16" fill="none" stroke="#26a17b" stroke-width="2"
:stroke-dasharray="100.53" :stroke-dashoffset="100.53 * (1 - remaining / totalTime)"
stroke-linecap="round" transform="rotate(-90 18 18)" />
</svg>
<span>{{ Math.floor(remaining / 60) }}:{{ String(remaining % 60).padStart(2, '0') }}</span>
</div>
<div class="countdown expired" v-else>
<span>支付已超时</span>
</div>
<div class="amount-section">
<div class="amount-label">支付金额</div>
<div class="amount-value">{{ paymentInfo.actual_amount || paymentInfo.amount }} <small>USDT</small></div>
<div class="amount-cny">订单金额:¥{{ orderAmount }}</div>
</div>
<div class="qr-section">
<div class="qr-label">扫码支付</div>
<div class="qr-wrapper">
<canvas ref="qrCanvas"></canvas>
</div>
<div class="qr-tip">请使用手机浏览器扫码完成支付</div>
</div>
<div class="address-section" v-if="paymentInfo.wallet_address">
<div class="address-label">
<span>钱包地址</span>
<span class="network-tag">{{ paymentInfo.trade_type?.toUpperCase() }}</span>
</div>
<div class="address-box">
<span class="address-text">{{ paymentInfo.wallet_address }}</span>
<el-button type="primary" size="small" @click="copyAddress">
<el-icon><CopyDocument /></el-icon>
复制
</el-button>
</div>
<div class="address-tip">请勿向此地址转账其他资产否则将无法找回</div>
</div>
<div class="tips">
<p>1. 请使用 {{ paymentInfo.trade_type?.toUpperCase() }} 网络转账</p>
<p>2. 请准确转账 <strong>{{ paymentInfo.actual_amount || paymentInfo.amount }} USDT</strong></p>
<p>3. 转账后系统会自动确认无需手动操作</p>
</div>
<div class="status-polling">
<el-icon class="is-loading"><Loading /></el-icon>
<span>等待支付确认中...</span>
</div>
<div class="actions">
<el-button @click="$router.push('/orders')">返回订单</el-button>
</div>
</div>
<div class="checkout-card loading" v-else-if="paymentError">
<el-icon :size="40" color="#f56c6c"><CircleCloseFilled /></el-icon>
<p class="error-text">{{ paymentError }}</p>
<el-button @click="$router.push('/orders')">返回订单</el-button>
</div>
<div class="checkout-card loading" v-else>
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
<p>正在创建支付订单...</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Loading, CircleCloseFilled, CopyDocument } from '@element-plus/icons-vue'
import { orderApi } from '../../api'
import QRCode from 'qrcode'
const route = useRoute()
const router = useRouter()
const qrCanvas = ref<HTMLCanvasElement>()
const paymentInfo = ref<any>(null)
const paymentError = ref('')
const remaining = ref(0)
const totalTime = ref(0)
const orderAmount = ref(0)
const orderId = ref(0)
let pollTimer: any = null
let countdownTimer: any = null
async function createPayment() {
try {
orderId.value = Number(route.params.id)
const res: any = await orderApi.createPayment(orderId.value)
paymentInfo.value = res.data
totalTime.value = res.data.expiration_time || 600
remaining.value = totalTime.value
const orderRes: any = await orderApi.getById(orderId.value)
orderAmount.value = orderRes.data?.total_amount || 0
if (qrCanvas.value) {
const qrContent = res.data.payment_url || res.data.token || ''
if (qrContent) {
await QRCode.toCanvas(qrCanvas.value, qrContent, {
width: 180,
margin: 2,
color: { dark: '#000', light: '#fff' }
})
}
}
startCountdown()
startPolling()
} catch (err: any) {
paymentError.value = err.response?.data?.error || '创建支付失败'
}
}
function startCountdown() {
countdownTimer = setInterval(() => {
remaining.value--
if (remaining.value <= 0) {
clearInterval(countdownTimer)
clearInterval(pollTimer)
orderApi.cancel(orderId.value).catch(() => {})
paymentError.value = '支付已超时,订单已取消'
paymentInfo.value = null
}
}, 1000)
}
function copyAddress() {
if (paymentInfo.value?.wallet_address) {
navigator.clipboard.writeText(paymentInfo.value.wallet_address)
ElMessage.success('地址已复制到剪贴板')
}
}
function startPolling() {
pollTimer = setInterval(async () => {
try {
const res: any = await orderApi.checkPaymentStatus(orderId.value)
if (res.data?.status === 'pending_confirm' || res.data?.status === 'completed') {
clearInterval(pollTimer)
clearInterval(countdownTimer)
ElMessage.success('支付成功!')
setTimeout(() => router.push(`/orders/${orderId.value}`), 1500)
}
} catch {}
}, 5000)
}
onMounted(() => {
createPayment()
})
onUnmounted(() => {
clearInterval(pollTimer)
clearInterval(countdownTimer)
})
</script>
<style scoped lang="scss">
.checkout-page {
width: 100%;
min-height: 100%;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 40px 16px;
}
.checkout-card {
background: #2d2d44;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 16px;
padding: 32px;
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
align-items: center;
}
.checkout-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
h2 {
font-size: 20px;
font-weight: 600;
color: #fff;
margin: 0;
}
.trade-type {
background: rgba(38, 161, 123, 0.15);
color: #26a17b;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
}
.countdown {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 24px;
font-size: 18px;
font-weight: 600;
color: #26a17b;
.countdown-ring {
width: 24px;
height: 24px;
}
&.expired {
color: #f56c6c;
}
}
.amount-section {
text-align: center;
margin-bottom: 24px;
.amount-label {
font-size: 13px;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 4px;
}
.amount-value {
font-size: 36px;
font-weight: 700;
color: #26a17b;
small {
font-size: 16px;
font-weight: 500;
}
}
.amount-cny {
font-size: 14px;
color: rgba(255, 255, 255, 0.5);
margin-top: 4px;
}
}
.qr-section {
margin-bottom: 24px;
text-align: center;
.qr-label {
font-size: 13px;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 12px;
}
.qr-wrapper {
background: #fff;
border-radius: 12px;
padding: 12px;
display: inline-block;
}
.qr-tip {
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
margin-top: 8px;
}
}
.address-section {
width: 100%;
margin-bottom: 24px;
.address-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 12px;
.network-tag {
background: rgba(38, 161, 123, 0.15);
color: #26a17b;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
}
.address-box {
display: flex;
align-items: center;
gap: 12px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 12px 16px;
.address-text {
flex: 1;
font-size: 14px;
color: #fff;
word-break: break-all;
font-family: monospace;
}
.el-button {
flex-shrink: 0;
}
}
.address-tip {
font-size: 12px;
color: rgba(245, 158, 11, 0.8);
margin-top: 8px;
}
}
.tips {
width: 100%;
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 20px;
p {
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
margin: 4px 0;
line-height: 1.6;
strong {
color: #26a17b;
}
}
}
.status-polling {
display: flex;
align-items: center;
gap: 8px;
color: rgba(255, 255, 255, 0.5);
font-size: 13px;
margin-bottom: 20px;
}
.actions {
width: 100%;
display: flex;
justify-content: center;
}
.loading {
min-height: 300px;
justify-content: center;
p {
color: rgba(255, 255, 255, 0.5);
margin-top: 16px;
}
.error-text {
color: #f56c6c;
font-size: 15px;
margin: 16px 0;
}
}
</style>