feat(payment): inline QR code dialog for scan-to-pay mode

QR code payments now display in an inline dialog on the payment page
with countdown and status polling. Success is shown directly without
page navigation. Redirect mode (pay_url only) still navigates to the
polling page as before.
This commit is contained in:
erio
2026-04-08 13:29:02 +08:00
parent 7295fce5fd
commit 4049b2f695
3 changed files with 273 additions and 2 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.108.88
0.1.108.89
@@ -0,0 +1,249 @@
<template>
<BaseDialog :show="show" :title="dialogTitle" width="narrow" @close="handleClose">
<!-- QR Code + Polling State -->
<div v-if="!success" class="flex flex-col items-center space-y-4">
<!-- QR Code -->
<div v-if="qrUrl" class="rounded-2xl bg-white p-4 shadow-sm dark:bg-dark-800">
<canvas ref="qrCanvas" class="mx-auto"></canvas>
</div>
<p v-if="qrUrl && scanHint" class="text-center text-sm text-gray-500 dark:text-gray-400">
{{ scanHint }}
</p>
<!-- Countdown -->
<div v-if="expired" class="text-center">
<p class="text-lg font-medium text-red-500">{{ t('payment.qr.expired') }}</p>
</div>
<div v-else class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.expiresIn') }}</p>
<p class="mt-1 text-2xl font-bold tabular-nums text-gray-900 dark:text-white">{{ countdownDisplay }}</p>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">{{ t('payment.qr.waitingPayment') }}</p>
</div>
</div>
<!-- Success State -->
<div v-else class="flex flex-col items-center space-y-4 py-4">
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
<Icon name="check" size="lg" class="text-green-500" />
</div>
<p class="text-lg font-bold text-gray-900 dark:text-white">{{ t('payment.result.success') }}</p>
<div v-if="paidOrder" class="w-full rounded-xl bg-gray-50 p-4 dark:bg-dark-800">
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</span>
<span class="font-medium text-gray-900 dark:text-white">#{{ paidOrder.id }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">&yen;{{ paidOrder.pay_amount.toFixed(2) }}</span>
</div>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<button v-if="!success && !expired" class="btn btn-secondary" :disabled="cancelling" @click="handleCancel">
{{ cancelling ? t('common.processing') : t('payment.qr.cancelOrder') }}
</button>
<button v-if="success" class="btn btn-primary" @click="handleDone">
{{ t('common.confirm') }}
</button>
<button v-if="expired" class="btn btn-primary" @click="handleClose">
{{ t('payment.result.backToRecharge') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Icon from '@/components/icons/Icon.vue'
import { usePaymentStore } from '@/stores/payment'
import { useAppStore } from '@/stores'
import { paymentAPI } from '@/api/payment'
import { extractApiErrorMessage } from '@/utils/apiError'
import type { PaymentOrder } from '@/types/payment'
import QRCode from 'qrcode'
import alipayIcon from '@/assets/icons/alipay.svg'
import wxpayIcon from '@/assets/icons/wxpay.svg'
const props = defineProps<{
show: boolean
orderId: number
qrCode: string
expiresAt: string
paymentType: string
}>()
const emit = defineEmits<{
close: []
success: []
}>()
const { t } = useI18n()
const paymentStore = usePaymentStore()
const appStore = useAppStore()
const qrCanvas = ref<HTMLCanvasElement | null>(null)
const qrUrl = ref('')
const remainingSeconds = ref(0)
const expired = ref(false)
const cancelling = ref(false)
const success = ref(false)
const paidOrder = ref<PaymentOrder | null>(null)
let pollTimer: ReturnType<typeof setInterval> | null = null
let countdownTimer: ReturnType<typeof setInterval> | null = null
const isAlipay = computed(() => props.paymentType.includes('alipay'))
const isWxpay = computed(() => props.paymentType.includes('wxpay'))
const dialogTitle = computed(() => {
if (success.value) return t('payment.result.success')
if (isAlipay.value) return t('payment.qr.scanAlipay')
if (isWxpay.value) return t('payment.qr.scanWxpay')
return t('payment.qr.scanToPay')
})
const scanHint = computed(() => {
if (isAlipay.value) return t('payment.qr.scanAlipayHint')
if (isWxpay.value) return t('payment.qr.scanWxpayHint')
return ''
})
const countdownDisplay = computed(() => {
const m = Math.floor(remainingSeconds.value / 60)
const s = remainingSeconds.value % 60
return m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0')
})
function getLogoForType(): string | null {
if (isAlipay.value) return alipayIcon
if (isWxpay.value) return wxpayIcon
return null
}
async function renderQR() {
await nextTick()
if (!qrCanvas.value || !qrUrl.value) return
const logoSrc = getLogoForType()
await QRCode.toCanvas(qrCanvas.value, qrUrl.value, {
width: 220,
margin: 2,
errorCorrectionLevel: logoSrc ? 'H' : 'M',
})
if (!logoSrc) return
const canvas = qrCanvas.value
const ctx = canvas.getContext('2d')
if (!ctx) return
const img = new Image()
img.src = logoSrc
img.onload = () => {
const logoSize = 40
const x = (canvas.width - logoSize) / 2
const y = (canvas.height - logoSize) / 2
const pad = 4
ctx.fillStyle = '#FFFFFF'
ctx.beginPath()
const r = 5
ctx.moveTo(x - pad + r, y - pad)
ctx.arcTo(x + logoSize + pad, y - pad, x + logoSize + pad, y + logoSize + pad, r)
ctx.arcTo(x + logoSize + pad, y + logoSize + pad, x - pad, y + logoSize + pad, r)
ctx.arcTo(x - pad, y + logoSize + pad, x - pad, y - pad, r)
ctx.arcTo(x - pad, y - pad, x + logoSize + pad, y - pad, r)
ctx.fill()
ctx.drawImage(img, x, y, logoSize, logoSize)
}
}
async function pollStatus() {
if (!props.orderId) return
const order = await paymentStore.pollOrderStatus(props.orderId)
if (!order) return
if (order.status === 'COMPLETED' || order.status === 'PAID') {
cleanup()
paidOrder.value = order
success.value = true
emit('success')
} else if (order.status === 'EXPIRED' || order.status === 'CANCELLED' || order.status === 'FAILED') {
cleanup()
expired.value = true
}
}
function startCountdown(seconds: number) {
remainingSeconds.value = Math.max(0, seconds)
if (remainingSeconds.value <= 0) {
expired.value = true
return
}
countdownTimer = setInterval(() => {
remainingSeconds.value--
if (remainingSeconds.value <= 0) {
expired.value = true
cleanup()
}
}, 1000)
}
async function handleCancel() {
if (!props.orderId || cancelling.value) return
cancelling.value = true
try {
await paymentAPI.cancelOrder(props.orderId)
cleanup()
emit('close')
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
cancelling.value = false
}
}
function handleClose() {
cleanup()
emit('close')
}
function handleDone() {
cleanup()
emit('close')
}
function cleanup() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null }
}
function init() {
// Reset state
success.value = false
paidOrder.value = null
expired.value = false
cancelling.value = false
qrUrl.value = props.qrCode
let seconds = 30 * 60
if (props.expiresAt) {
const expiresAt = new Date(props.expiresAt)
seconds = Math.floor((expiresAt.getTime() - Date.now()) / 1000)
}
startCountdown(seconds)
pollTimer = setInterval(pollStatus, 3000)
renderQR()
}
// Watch for dialog open/close
watch(() => props.show, (isOpen) => {
if (isOpen) {
init()
} else {
cleanup()
}
})
watch(qrUrl, () => renderQR())
onUnmounted(() => cleanup())
</script>
+23 -1
View File
@@ -115,6 +115,16 @@
</div>
</template>
</BaseDialog>
<!-- Inline QR Payment Dialog -->
<PaymentQRDialog
:show="qrDialog.show"
:order-id="qrDialog.orderId"
:qr-code="qrDialog.qrCode"
:expires-at="qrDialog.expiresAt"
:payment-type="qrDialog.paymentType"
@close="qrDialog.show = false"
@success="authStore.refreshUser()"
/>
</AppLayout>
</template>
@@ -134,6 +144,7 @@ import AmountInput from '@/components/payment/AmountInput.vue'
import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue'
import { METHOD_ORDER } from '@/components/payment/providerConfig'
import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue'
import PaymentQRDialog from '@/components/payment/PaymentQRDialog.vue'
import Icon from '@/components/icons/Icon.vue'
import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue'
@@ -153,6 +164,9 @@ const amount = ref<number | null>(null)
const selectedMethod = ref('')
const selectedPlan = ref<SubscriptionPlan | null>(null)
// Inline QR payment dialog state
const qrDialog = ref({ show: false, orderId: 0, qrCode: '', expiresAt: '', paymentType: '' })
// All checkout data from single API call
const checkout = ref<CheckoutInfoResponse>({
methods: {}, global_min: 0, global_max: 0,
@@ -273,8 +287,16 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
if (result.client_secret) {
router.push({ path: '/payment/stripe', query: { order_id: String(result.order_id), client_secret: result.client_secret } })
} else if (result.qr_code) {
router.push({ path: '/payment/qrcode', query: { order_id: String(result.order_id), qr: result.qr_code || '', pay_url: result.pay_url || '', expires_at: result.expires_at || '', payment_type: selectedMethod.value } })
// QR mode: show inline dialog, no page navigation
qrDialog.value = {
show: true,
orderId: result.order_id,
qrCode: result.qr_code,
expiresAt: result.expires_at || '',
paymentType: selectedMethod.value,
}
} else if (result.pay_url) {
// Redirect mode: open new window + navigate to polling page
window.open(result.pay_url, '_blank')
router.push({ path: '/payment/qrcode', query: { order_id: String(result.order_id), pay_url: result.pay_url, expires_at: result.expires_at || '', payment_type: selectedMethod.value } })
} else {