365 lines
8.4 KiB
Vue
365 lines
8.4 KiB
Vue
<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="address-section" v-if="paymentInfo.payment_url">
|
||
<div class="address-label">支付链接</div>
|
||
<div class="address-box">
|
||
<span class="address-text">{{ paymentInfo.payment_url }}</span>
|
||
<el-button type="primary" size="small" @click="copyText(paymentInfo.payment_url)">复制</el-button>
|
||
</div>
|
||
<el-button type="success" size="small" class="open-link-btn" @click="openPaymentUrl">打开支付页面</el-button>
|
||
</div>
|
||
|
||
<div class="qr-section" v-if="paymentInfo.payment_url">
|
||
<div class="qr-label">扫码支付</div>
|
||
<div class="qr-wrapper">
|
||
<canvas ref="qrCanvas"></canvas>
|
||
</div>
|
||
<div class="qr-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 } 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)
|
||
let pollTimer: any = null
|
||
let countdownTimer: any = null
|
||
|
||
async function createPayment() {
|
||
try {
|
||
const orderId = Number(route.params.id)
|
||
const res: any = await orderApi.createPayment(orderId)
|
||
paymentInfo.value = res.data
|
||
totalTime.value = res.data.expiration_time || 600
|
||
remaining.value = totalTime.value
|
||
|
||
const orderRes: any = await orderApi.getById(orderId)
|
||
orderAmount.value = orderRes.data?.total_amount || 0
|
||
|
||
if (qrCanvas.value && res.data.payment_url) {
|
||
await QRCode.toCanvas(qrCanvas.value, res.data.payment_url, {
|
||
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)
|
||
}
|
||
}, 1000)
|
||
}
|
||
|
||
function startPolling() {
|
||
const orderId = Number(route.params.id)
|
||
pollTimer = setInterval(async () => {
|
||
try {
|
||
const res: any = await orderApi.checkPaymentStatus(orderId)
|
||
if (res.data?.status === 'pending_confirm' || res.data?.status === 'completed') {
|
||
clearInterval(pollTimer)
|
||
clearInterval(countdownTimer)
|
||
ElMessage.success('支付成功!')
|
||
setTimeout(() => router.push(`/orders/${orderId}`), 1500)
|
||
}
|
||
} catch {}
|
||
}, 5000)
|
||
}
|
||
|
||
function copyText(text: string) {
|
||
navigator.clipboard.writeText(text)
|
||
ElMessage.success('已复制')
|
||
}
|
||
|
||
function openPaymentUrl() {
|
||
if (paymentInfo.value?.payment_url) {
|
||
window.open(paymentInfo.value.payment_url, '_blank')
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
.address-section {
|
||
width: 100%;
|
||
margin-bottom: 24px;
|
||
|
||
.address-label {
|
||
font-size: 13px;
|
||
color: rgba(255, 255, 255, 0.5);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.address-box {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
background: rgba(255, 255, 255, 0.06);
|
||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||
border-radius: 8px;
|
||
padding: 10px 12px;
|
||
|
||
.address-text {
|
||
flex: 1;
|
||
font-size: 13px;
|
||
color: #fff;
|
||
word-break: break-all;
|
||
font-family: monospace;
|
||
}
|
||
}
|
||
}
|
||
|
||
.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;
|
||
}
|
||
}
|
||
|
||
.open-link-btn {
|
||
margin-top: 8px;
|
||
width: 100%;
|
||
}
|
||
|
||
.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>
|