feat: 实现站内USDT收银台支付流程

- 后端: payment.go 支付处理(创建订单/回调通知/状态查询/签名算法)
- 后端: 新增公开支付通道API(PublicList,不暴露config)
- 前端: Checkout.vue 收银台页面(倒计时/二维码/支付轮询)
- 前端: Cart.vue 对接动态支付通道,选择bepusdt跳转收银台
- 前端: API添加paymentChannelApi和orderApi支付方法
- 路由: 注册checkout路由和支付相关API
This commit is contained in:
2026-05-26 16:43:31 +08:00
parent 675b365b8b
commit 09f1ee4601
9 changed files with 1002 additions and 19 deletions
+329
View File
@@ -0,0 +1,329 @@
<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 }} <small>USDT</small></div>
<div class="amount-cny"> ¥{{ paymentInfo.amount }}</div>
</div>
<div class="address-section">
<div class="address-label">收款地址</div>
<div class="address-box">
<span class="address-text">{{ paymentInfo.token }}</span>
<el-button type="primary" size="small" @click="copyAddress">复制</el-button>
</div>
</div>
<div class="qr-section">
<div class="qr-label">扫码支付</div>
<div class="qr-wrapper">
<canvas ref="qrCanvas"></canvas>
</div>
</div>
<div class="tips">
<p>1. 请使用 {{ paymentInfo.trade_type?.toUpperCase() }} 网络转账</p>
<p>2. 请准确转账 <strong>{{ paymentInfo.actual_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>
<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 } 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 remaining = ref(0)
const totalTime = 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
await QRCode.toCanvas(qrCanvas.value, res.data.token, {
width: 180,
margin: 2,
color: { dark: '#000', light: '#fff' }
})
startCountdown()
startPolling()
} catch (err: any) {
ElMessage.error(err.response?.data?.error || '创建支付失败')
router.push('/orders')
}
}
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 copyAddress() {
if (paymentInfo.value?.token) {
navigator.clipboard.writeText(paymentInfo.value.token)
ElMessage.success('地址已复制')
}
}
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;
}
}
.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;
}
}
</style>