Files
sale/frontend/src/views/user/Checkout.vue
T

365 lines
8.4 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="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>