feat(payment): standalone result page, method order & QR logos

- Payment result page is now a standalone page without business layout
- Support EasyPay return_url params (out_trade_no, trade_status)
- Payment methods sorted: EasyPay → Alipay → WeChat → Stripe
- Alipay/WeChat use official brand SVG icons
- QR code displays Alipay/WeChat logo in center with scan prompt
This commit is contained in:
erio
2026-04-07 17:50:19 +08:00
parent f9e581bb82
commit 8f16b01943
6 changed files with 210 additions and 39 deletions
@@ -5,7 +5,7 @@
</label>
<div class="grid grid-cols-2 gap-3 sm:flex">
<button
v-for="method in methods"
v-for="method in sortedMethods"
:key="method.type"
type="button"
:disabled="!method.available"
@@ -20,11 +20,30 @@
@click="method.available && emit('select', method.type)"
>
<span class="flex items-center gap-2">
<span :class="['flex h-8 w-8 items-center justify-center rounded-md text-white', iconBgClass(method.type)]">
<Icon v-if="isStripe(method.type)" name="creditCard" size="sm" />
<span v-else class="text-sm font-bold">
{{ iconLabel(method.type) }}
</span>
<!-- Alipay official logo -->
<span v-if="method.type.includes('alipay')" class="flex h-8 w-8 items-center justify-center">
<svg viewBox="0 0 1024 1024" class="h-8 w-8">
<path d="M230.4 0h563.2A230.4 230.4 0 0 1 1024 230.4v563.2A230.4 230.4 0 0 1 793.6 1024H230.4A230.4 230.4 0 0 1 0 793.6V230.4A230.4 230.4 0 0 1 230.4 0z" fill="#1677FF"/>
<path d="M716.8 604.16c-60.42-24.58-128.26-51.2-128.26-51.2s35.84-91.65 49.66-156.67c13.82-65.02 5.12-102.4-40.96-117.76-46.08-15.36-81.92 7.17-97.28 56.32-15.36 49.15-15.36 122.88 5.12 194.56-30.72 56.32-71.68 122.88-112.64 179.2-40.96 56.32-71.68 92.16-71.68 92.16s-97.28 35.84-143.36 76.8c-46.08 40.96-56.32 76.8-40.96 102.4 15.36 25.6 56.32 30.72 112.64-5.12 56.32-35.84 107.52-107.52 148.48-168.96 0 0 81.92-30.72 128-43.01 46.08-12.29 81.92-20.48 81.92-20.48s71.68 66.56 148.48 92.16c76.8 25.6 133.12 15.36 138.24-20.48 5.12-35.84-30.72-61.44-46.08-71.68-41.98-27.65-81.92-46.08-131.32-66.05zM293.63 855.04c-35.84 25.6-66.56 15.36-56.32-10.24 10.24-25.6 46.08-61.44 92.16-86.02 0 0 10.24-5.12 30.72-15.36-30.72 51.2-66.56 112.64-66.56 111.62zm225.28-491.52c-5.12-56.32 10.24-86.02 35.84-86.02s35.84 25.6 25.6 76.8c-10.24 51.2-30.72 107.52-30.72 107.52s-25.6-42.0-30.72-98.3zm-71.68 296.96c25.6-46.08 51.2-97.28 71.68-143.36 0 0 61.44 25.6 97.28 35.84-30.72 12.8-102.4 71.68-168.96 107.52z" fill="#FFFFFF"/>
</svg>
</span>
<!-- WeChat Pay official logo -->
<span v-else-if="method.type.includes('wxpay')" class="flex h-8 w-8 items-center justify-center">
<svg viewBox="0 0 1024 1024" class="h-8 w-8">
<path d="M230.4 0h563.2A230.4 230.4 0 0 1 1024 230.4v563.2A230.4 230.4 0 0 1 793.6 1024H230.4A230.4 230.4 0 0 1 0 793.6V230.4A230.4 230.4 0 0 1 230.4 0z" fill="#07C160"/>
<path d="M408.06 429.06a25.6 25.6 0 1 0 0-51.2 25.6 25.6 0 0 0 0 51.2zm-152.06 0a25.6 25.6 0 1 0 0-51.2 25.6 25.6 0 0 0 0 51.2z" fill="#FFFFFF"/>
<path d="M332 271c-119.3 0-216 79.7-216 178 0 55.1 28.9 104.9 74.2 138.7l-18.5 55.7 64.4-33.2c26.6 8.9 55.6 13.9 86 13.9 7.3 0 14.5-0.3 21.6-0.9-9-27.6-14.2-57-14.2-87.5C329.5 408.9 422.2 332.7 535 332.7c7.3 0 14.5 0.4 21.6 1-23.6-36.5-67.8-62.7-118.6-62.7H332z" fill="#FFFFFF"/>
<path d="M710.4 593.9a20.5 20.5 0 1 0 0-41 20.5 20.5 0 0 0 0 41zm-121.6 0a20.5 20.5 0 1 0 0-41 20.5 20.5 0 0 0 0 41z" fill="#FFFFFF"/>
<path d="M649.6 365.7c-99.1 0-179.5 69.3-179.5 154.8s80.4 154.8 179.5 154.8c21.3 0 41.8-3.2 60.8-9.2l53.5 27.5-15.4-46.2c37.6-28.1 61.1-69.4 61.1-115.0-0.1-85.4-80.5-154.7-160-166.7z" fill="#FFFFFF"/>
</svg>
</span>
<!-- Stripe icon -->
<span v-else-if="isStripe(method.type)" :class="['flex h-8 w-8 items-center justify-center rounded-md text-white', iconBgClass(method.type)]">
<Icon name="creditCard" size="sm" />
</span>
<!-- EasyPay icon -->
<span v-else :class="['flex h-8 w-8 items-center justify-center rounded-md text-white', iconBgClass(method.type)]">
<span class="text-sm font-bold">{{ iconLabel(method.type) }}</span>
</span>
<span class="flex flex-col items-start leading-none">
<span class="text-base font-semibold">{{ t(`payment.methods.${method.type}`) }}</span>
@@ -42,6 +61,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
@@ -51,7 +71,7 @@ export interface PaymentMethodOption {
available: boolean
}
defineProps<{
const props = defineProps<{
methods: PaymentMethodOption[]
selected: string
}>()
@@ -62,28 +82,35 @@ const emit = defineEmits<{
const { t } = useI18n()
/** Fixed display order for payment methods */
const METHOD_ORDER = ['easypay', 'alipay', 'wxpay', 'stripe']
const sortedMethods = computed(() => {
return [...props.methods].sort((a, b) => {
const ai = METHOD_ORDER.indexOf(a.type)
const bi = METHOD_ORDER.indexOf(b.type)
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi)
})
})
function isStripe(type: string) {
return type === 'stripe'
}
function iconBgClass(type: string): string {
if (type === 'easypay') return 'bg-[#FF6B35]'
if (type.includes('alipay')) return 'bg-[#00AEEF]'
if (type.includes('wxpay')) return 'bg-[#07C160]'
if (type === 'stripe') return 'bg-[#635bff]'
return 'bg-gray-500'
}
function iconLabel(type: string): string {
if (type === 'easypay') return '易'
if (type.includes('alipay')) return '\u652f'
if (type.includes('wxpay')) return '\u5fae'
return type[0]?.toUpperCase() || '?'
}
function methodSelectedClass(type: string): string {
if (type === 'easypay') return 'border-[#FF6B35] bg-orange-50 text-gray-900 shadow-sm dark:bg-orange-950 dark:text-gray-100'
if (type.includes('alipay')) return 'border-[#00AEEF] bg-blue-50 text-gray-900 shadow-sm dark:bg-blue-950 dark:text-gray-100'
if (type.includes('alipay')) return 'border-[#1677FF] bg-blue-50 text-gray-900 shadow-sm dark:bg-blue-950 dark:text-gray-100'
if (type.includes('wxpay')) return 'border-[#07C160] bg-green-50 text-gray-900 shadow-sm dark:bg-green-950 dark:text-gray-100'
if (type === 'stripe') return 'border-[#635bff] bg-indigo-50 text-gray-900 shadow-sm dark:bg-indigo-950 dark:text-gray-100'
return 'border-primary-500 bg-primary-50 text-gray-900 shadow-sm dark:bg-primary-950 dark:text-gray-100'
+4
View File
@@ -5095,6 +5095,10 @@ export default {
},
qr: {
scanToPay: 'Scan to Pay',
scanAlipay: 'Alipay QR Payment',
scanWxpay: 'WeChat QR Payment',
scanAlipayHint: 'Open Alipay on your phone and scan the QR code to pay',
scanWxpayHint: 'Open WeChat on your phone and scan the QR code to pay',
payInNewWindow: 'Complete Payment in New Window',
payInNewWindowHint: 'The payment page has opened in a new window. Please complete the payment there and return to this page.',
openPayWindow: 'Reopen Payment Page',
+4
View File
@@ -5292,6 +5292,10 @@ export default {
},
qr: {
scanToPay: '请扫码支付',
scanAlipay: '支付宝扫码支付',
scanWxpay: '微信扫码支付',
scanAlipayHint: '请使用手机打开支付宝,扫描二维码完成支付',
scanWxpayHint: '请使用手机打开微信,扫描二维码完成支付',
payInNewWindow: '请在新窗口中完成支付',
payInNewWindowHint: '支付页面已在新窗口打开,请在新窗口中完成支付后返回此页面',
openPayWindow: '重新打开支付页面',
+70 -3
View File
@@ -2,11 +2,15 @@
<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 ? t('payment.qr.scanToPay') : t('payment.qr.payInNewWindow') }}
{{ 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>
@@ -52,6 +56,7 @@ 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
@@ -62,10 +67,71 @@ const countdownDisplay = computed(() => {
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 ''
})
// Alipay logo SVG as data URI (blue brand mark)
const ALIPAY_LOGO = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#1677FF"/><text x="32" y="44" font-family="Arial,sans-serif" font-size="32" font-weight="bold" fill="white" text-anchor="middle">支</text></svg>')}`
// WeChat Pay logo SVG as data URI (green brand mark)
const WXPAY_LOGO = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#07C160"/><path d="M20 22c-8 0-14.5 5.4-14.5 12.1 0 3.8 2 7.2 5.1 9.5l-1.3 3.8 4.4-2.3c1.8.6 3.8 1 5.9 1 .5 0 1 0 1.5-.1-.6-1.9-1-3.9-1-6C20.1 33 27 27 35.5 27c.5 0 1 0 1.5.1C35.4 23.4 28.2 22 20 22zm-5 8a2 2 0 1 1 0-4 2 2 0 0 1 0 4zm10 0a2 2 0 1 1 0-4 2 2 0 0 1 0 4z" fill="white"/><path d="M50.5 40c0-5.8-5.5-10.5-12.2-10.5S26 34.2 26 40s5.5 10.5 12.3 10.5c1.5 0 2.8-.2 4.1-.6l3.6 2-1-3.2c2.6-2 4.1-4.8 4.1-7.9zm-16.2-1.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm8 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z" fill="white"/></svg>')}`
function getLogoForType(): string | null {
if (isAlipay.value) return ALIPAY_LOGO
if (isWxpay.value) return WXPAY_LOGO
return null
}
async function renderQR() {
await nextTick()
if (qrCanvas.value && qrUrl.value) {
await QRCode.toCanvas(qrCanvas.value, qrUrl.value, { width: 256, margin: 2 })
if (!qrCanvas.value || !qrUrl.value) return
// Use high error correction to support logo overlay
const logoSrc = getLogoForType()
await QRCode.toCanvas(qrCanvas.value, qrUrl.value, {
width: 256,
margin: 2,
errorCorrectionLevel: logoSrc ? 'H' : 'M',
})
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)
}
}
@@ -122,6 +188,7 @@ onMounted(() => {
orderId.value = Number(route.query.order_id) || 0
qrUrl.value = (route.query.qr as string) || ''
payUrl.value = (route.query.pay_url as string) || ''
paymentType.value = (route.query.payment_type as string) || ''
// Calculate countdown from expiresAt
const expiresAtStr = route.query.expires_at as string
+82 -21
View File
@@ -1,28 +1,33 @@
<template>
<AppLayout>
<div class="mx-auto flex max-w-md flex-col items-center space-y-6 py-16">
<div class="flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-dark-900">
<div class="w-full max-w-md space-y-6">
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-20">
<div class="h-8 w-8 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"></div>
</div>
<template v-else>
<div v-if="isSuccess" class="text-center">
<div class="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
<svg class="h-10 w-10 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<!-- Status Icon -->
<div class="text-center">
<div v-if="isSuccess"
class="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
<svg class="h-10 w-10 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"
stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 class="mt-4 text-2xl font-bold text-gray-900 dark:text-white">{{ t('payment.result.success') }}</h2>
</div>
<div v-else class="text-center">
<div class="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
<div v-else
class="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
<svg class="h-10 w-10 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h2 class="mt-4 text-2xl font-bold text-gray-900 dark:text-white">{{ t('payment.result.failed') }}</h2>
<h2 class="mt-4 text-2xl font-bold text-gray-900 dark:text-white">
{{ isSuccess ? t('payment.result.success') : t('payment.result.failed') }}
</h2>
</div>
<div v-if="order" class="w-full rounded-xl bg-gray-50 p-4 dark:bg-dark-800">
<div class="space-y-2 text-sm">
<!-- Order Info -->
<div v-if="order" class="rounded-xl bg-white p-5 shadow-sm dark:bg-dark-800">
<div class="space-y-3 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">#{{ order.id }}</span>
@@ -41,20 +46,37 @@
</div>
</div>
</div>
<div class="flex gap-3 pt-4">
<button class="btn btn-secondary" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
<button class="btn btn-primary" @click="router.push('/orders')">{{ t('payment.result.viewOrders') }}</button>
<!-- EasyPay return info (when no order loaded) -->
<div v-else-if="returnInfo" class="rounded-xl bg-white p-5 shadow-sm dark:bg-dark-800">
<div class="space-y-3 text-sm">
<div v-if="returnInfo.outTradeNo" 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">{{ returnInfo.outTradeNo }}</span>
</div>
<div v-if="returnInfo.money" 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">&#165;{{ returnInfo.money }}</span>
</div>
<div v-if="returnInfo.type" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ t('payment.methods.' + returnInfo.type, returnInfo.type) }}</span>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex gap-3">
<button class="btn btn-secondary flex-1" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
<button class="btn btn-primary flex-1" @click="router.push('/orders')">{{ t('payment.result.viewOrders') }}</button>
</div>
</template>
</div>
</AppLayout>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import AppLayout from '@/components/layout/AppLayout.vue'
import OrderStatusBadge from '@/components/payment/OrderStatusBadge.vue'
import { usePaymentStore } from '@/stores/payment'
import type { PaymentOrder } from '@/types/payment'
@@ -67,16 +89,55 @@ const paymentStore = usePaymentStore()
const order = ref<PaymentOrder | null>(null)
const loading = ref(true)
interface ReturnInfo {
outTradeNo: string
money: string
type: string
tradeStatus: string
}
const returnInfo = ref<ReturnInfo | null>(null)
const isSuccess = computed(() => {
// Internal flow: explicit status=success
if (route.query.status === 'success') return true
if (!order.value) return false
return order.value.status === 'COMPLETED' || order.value.status === 'PAID'
// EasyPay return flow: trade_status=TRADE_SUCCESS
if (route.query.trade_status === 'TRADE_SUCCESS') return true
// Order-based check
if (order.value) {
return order.value.status === 'COMPLETED' || order.value.status === 'PAID'
}
return false
})
/** Extract numeric order ID from out_trade_no like "sub2_46" → 46 */
function parseOutTradeNo(outTradeNo: string): number {
const match = outTradeNo.match(/_(\d+)$/)
return match ? Number(match[1]) : 0
}
onMounted(async () => {
const orderId = Number(route.query.order_id)
// Try order_id first (internal navigation from QRCode/Stripe pages)
let orderId = Number(route.query.order_id) || 0
// Fallback: EasyPay return URL with out_trade_no
if (!orderId && route.query.out_trade_no) {
const outTradeNo = route.query.out_trade_no as string
orderId = parseOutTradeNo(outTradeNo)
// Store return info for display when order lookup fails
returnInfo.value = {
outTradeNo,
money: (route.query.money as string) || '',
type: (route.query.type as string) || '',
tradeStatus: (route.query.trade_status as string) || '',
}
}
if (orderId) {
order.value = await paymentStore.pollOrderStatus(orderId)
try {
order.value = await paymentStore.pollOrderStatus(orderId)
} catch {
// Order lookup failed, will show returnInfo fallback
}
}
loading.value = false
})
+11 -3
View File
@@ -233,10 +233,10 @@ 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 || '' } })
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 } })
} else if (result.pay_url) {
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 || '' } })
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 {
errorMessage.value = t('payment.result.failed')
appStore.showError(errorMessage.value)
@@ -272,7 +272,15 @@ onMounted(async () => {
const limitsRes = await paymentAPI.getLimits()
methodLimits.value = limitsRes.data
} catch (e) { /* limits endpoint may not exist */ }
if (enabledMethods.value.length) selectedMethod.value = enabledMethods.value[0]
if (enabledMethods.value.length) {
const METHOD_ORDER = ['easypay', 'alipay', 'wxpay', 'stripe']
const sorted = [...enabledMethods.value].sort((a, b) => {
const ai = METHOD_ORDER.indexOf(a)
const bi = METHOD_ORDER.indexOf(b)
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi)
})
selectedMethod.value = sorted[0]
}
if (config.value?.balance_disabled) {
activeTab.value = 'subscription'
await loadPlans()