Pairs with the backend structured payment errors (reason + metadata). The
frontend now maps reason codes to localized messages with metadata as
interpolation variables, and automatically localizes raw config-field names
(e.g. "certSerial" → "证书序列号") using the existing UI-label i18n
namespace.
- frontend/src/utils/apiError.ts
- extractApiErrorCode now prefers the string `reason` over the numeric HTTP
`code`; reason is granular enough to drive i18n lookup, HTTP code is not.
- New extractApiErrorMetadata to pull interpolation params off the error.
- New extractI18nErrorMessage(err, t, namespace, fallback): looks up
`<namespace>.<REASON>` in i18n and substitutes metadata. Before
substitution, `metadata.key` and `metadata.keys` (slash-joined) are
re-translated through `admin.settings.payment.field_<key>` so users see
"缺少必填项:证书序列号" instead of "缺少必填项:certSerial".
- frontend/src/i18n/locales/{zh,en}.ts
- Add payment.errors entries for every structured reason code returned by
the backend (PAYMENT_DISABLED, INVALID_AMOUNT, TOO_MANY_PENDING,
DAILY_LIMIT_EXCEEDED, NO_AVAILABLE_INSTANCE, PAYMENT_PROVIDER_MISCONFIGURED,
WXPAY_CONFIG_MISSING_KEY / INVALID_KEY_LENGTH / INVALID_KEY, NOT_FOUND,
FORBIDDEN, CONFLICT, INVALID_ORDER_TYPE, INVALID_STATUS,
BALANCE_NOT_ENOUGH, REFUND_AMOUNT_EXCEEDED, REFUND_FAILED, and more),
with placeholders for template variables.
- 13 payment-related Vue files
- Migrate catch-block error reporting from extractApiErrorMessage to
extractI18nErrorMessage(err, t, 'payment.errors', fallback).
- Remove the ad-hoc paymentErrorMap computed in SettingsView.vue, which the
new helper supersedes (it reads i18n directly via t).
- frontend/src/components/payment/providerConfig.ts
- wxpay: publicKey and publicKeyId are now required (was optional), matching
the pubkey-only verifier direction; certSerial is already required.
This PR is drop-in safe: reason-preferring extractApiErrorCode is backward
compatible with callers that pass their own i18nMap, and error codes missing
from i18n fall back to the existing message-based path.
204 lines
6.8 KiB
Vue
204 lines
6.8 KiB
Vue
<template>
|
|
<AppLayout>
|
|
<div class="mx-auto flex max-w-md flex-col items-center space-y-6 py-8">
|
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
|
{{ qrUrl ? scanTitle : t('payment.qr.payInNewWindow') }}
|
|
</h2>
|
|
<div v-if="qrUrl" class="rounded-2xl bg-white p-6 shadow-lg dark:bg-dark-800">
|
|
<canvas ref="qrCanvas" class="mx-auto"></canvas>
|
|
</div>
|
|
<!-- Scan prompt for QR code -->
|
|
<p v-if="qrUrl && !expired && scanHint" class="text-center text-sm text-gray-500 dark:text-gray-400">
|
|
{{ scanHint }}
|
|
</p>
|
|
<div v-if="expired" class="text-center">
|
|
<p class="text-lg font-medium text-red-500">{{ t('payment.qr.expired') }}</p>
|
|
<button class="btn btn-primary mt-4" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
|
|
</div>
|
|
<div v-else class="text-center">
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ qrUrl ? t('payment.qr.expiresIn') : t('payment.qr.payInNewWindowHint') }}</p>
|
|
<p class="mt-1 text-2xl font-bold tabular-nums text-gray-900 dark:text-white">{{ countdownDisplay }}</p>
|
|
<p class="mt-2 text-sm text-gray-400 dark:text-gray-500">{{ t('payment.qr.waitingPayment') }}</p>
|
|
</div>
|
|
<a v-if="payUrl && !qrUrl && !expired" :href="payUrl" target="_blank" rel="noopener noreferrer"
|
|
class="btn btn-primary w-full py-3">
|
|
{{ t('payment.qr.openPayWindow') }}
|
|
</a>
|
|
<!-- Cancel button -->
|
|
<button v-if="!expired && orderId" class="btn btn-secondary w-full" :disabled="cancelling" @click="handleCancel">
|
|
{{ cancelling ? t('common.processing') : t('payment.qr.cancelOrder') }}
|
|
</button>
|
|
</div>
|
|
</AppLayout>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
|
import { usePaymentStore } from '@/stores/payment'
|
|
import { paymentAPI } from '@/api/payment'
|
|
import { extractI18nErrorMessage } from '@/utils/apiError'
|
|
import { useAppStore } from '@/stores'
|
|
import QRCode from 'qrcode'
|
|
import alipayIcon from '@/assets/icons/alipay.svg'
|
|
import wxpayIcon from '@/assets/icons/wxpay.svg'
|
|
|
|
const { t } = useI18n()
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const paymentStore = usePaymentStore()
|
|
const appStore = useAppStore()
|
|
|
|
const qrCanvas = ref<HTMLCanvasElement | null>(null)
|
|
const qrUrl = ref('')
|
|
const payUrl = ref('')
|
|
const orderId = ref(0)
|
|
const remainingSeconds = ref(0)
|
|
const expired = ref(false)
|
|
const cancelling = ref(false)
|
|
const paymentType = ref('')
|
|
|
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
|
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
|
|
|
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')
|
|
})
|
|
|
|
const isAlipay = computed(() => paymentType.value.includes('alipay'))
|
|
const isWxpay = computed(() => paymentType.value.includes('wxpay'))
|
|
|
|
const scanTitle = computed(() => {
|
|
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 ''
|
|
})
|
|
|
|
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
|
|
|
|
// Use medium error correction to support logo overlay while keeping QR code scannable
|
|
const logoSrc = getLogoForType()
|
|
await QRCode.toCanvas(qrCanvas.value, qrUrl.value, {
|
|
width: 256,
|
|
margin: 2,
|
|
errorCorrectionLevel: logoSrc ? 'M' : 'L',
|
|
})
|
|
|
|
if (!logoSrc) return
|
|
|
|
// Draw logo in center of QR code
|
|
const canvas = qrCanvas.value
|
|
const ctx = canvas.getContext('2d')
|
|
if (!ctx) return
|
|
|
|
const img = new Image()
|
|
img.src = logoSrc
|
|
img.onload = () => {
|
|
const logoSize = 48
|
|
const x = (canvas.width - logoSize) / 2
|
|
const y = (canvas.height - logoSize) / 2
|
|
// White background with rounded corners
|
|
const pad = 5
|
|
ctx.fillStyle = '#FFFFFF'
|
|
ctx.beginPath()
|
|
const r = 6
|
|
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()
|
|
// Draw logo
|
|
ctx.drawImage(img, x, y, logoSize, logoSize)
|
|
}
|
|
}
|
|
|
|
async function pollStatus() {
|
|
if (!orderId.value) return
|
|
const order = await paymentStore.pollOrderStatus(orderId.value)
|
|
if (!order) return
|
|
if (order.status === 'COMPLETED' || order.status === 'PAID') {
|
|
cleanup()
|
|
router.push({ path: '/payment/result', query: { order_id: String(orderId.value), status: '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 (!orderId.value || cancelling.value) return
|
|
cancelling.value = true
|
|
try {
|
|
await paymentAPI.cancelOrder(orderId.value)
|
|
cleanup()
|
|
router.push('/purchase')
|
|
} catch (err: unknown) {
|
|
appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
|
|
} finally {
|
|
cancelling.value = false
|
|
}
|
|
}
|
|
|
|
function cleanup() {
|
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
|
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null }
|
|
}
|
|
|
|
watch(qrUrl, () => renderQR())
|
|
|
|
onMounted(() => {
|
|
orderId.value = Number(route.query.order_id) || 0
|
|
qrUrl.value = String(route.query.qr || '')
|
|
payUrl.value = String(route.query.pay_url || '')
|
|
paymentType.value = String(route.query.payment_type || '')
|
|
|
|
// Calculate countdown from expiresAt
|
|
const expiresAtStr = String(route.query.expires_at || '')
|
|
let seconds = 30 * 60 // fallback: 30 minutes
|
|
if (expiresAtStr) {
|
|
const expiresAt = new Date(expiresAtStr)
|
|
const now = new Date()
|
|
seconds = Math.floor((expiresAt.getTime() - now.getTime()) / 1000)
|
|
}
|
|
startCountdown(seconds)
|
|
pollTimer = setInterval(pollStatus, 3000)
|
|
renderQR()
|
|
})
|
|
|
|
onUnmounted(() => cleanup())
|
|
</script>
|