Files
sub2api/frontend/src/components/payment/StripePaymentInline.vue
erio 40d4e167cd feat(payment): i18n payment error codes and label localization
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.
2026-04-20 20:23:16 +08:00

208 lines
7.9 KiB
Vue

<template>
<div class="space-y-4">
<div v-if="loading" class="flex items-center justify-center py-12">
<div class="h-8 w-8 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"></div>
</div>
<div v-else-if="initError" class="card p-6 text-center">
<p class="text-sm text-red-600 dark:text-red-400">{{ initError }}</p>
<button class="btn btn-secondary mt-4" @click="$emit('back')">{{ t('payment.result.backToRecharge') }}</button>
</div>
<!-- Success -->
<template v-else-if="success">
<div class="card p-6">
<div 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 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">#{{ orderId }}</span>
</div>
<div v-if="amount > 0" 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">{{ orderType === 'balance' ? '$' : '¥' }}{{ amount.toFixed(2) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">¥{{ payAmount.toFixed(2) }}</span>
</div>
</div>
</div>
<button class="btn btn-primary" @click="$emit('done')">{{ t('common.confirm') }}</button>
</div>
</div>
</template>
<template v-else>
<!-- Amount -->
<div class="card overflow-hidden">
<div class="bg-gradient-to-br from-[#635bff] to-[#4f46e5] px-6 py-5 text-center">
<p class="text-sm font-medium text-indigo-200">{{ t('payment.actualPay') }}</p>
<p class="mt-1 text-3xl font-bold text-white">¥{{ payAmount.toFixed(2) }}</p>
</div>
</div>
<!-- Stripe Payment Element -->
<div class="card p-6">
<div ref="stripeMount" class="min-h-[200px]"></div>
<p v-if="error" class="mt-4 text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<button class="btn btn-stripe mt-6 w-full py-3 text-base" :disabled="submitting || !ready" @click="handlePay">
<span v-if="submitting" class="flex items-center justify-center gap-2">
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
{{ t('common.processing') }}
</span>
<span v-else>{{ t('payment.stripePay') }}</span>
</button>
</div>
<!-- Cancel order -->
<button class="btn btn-secondary w-full" :disabled="cancelling" @click="handleCancel">
{{ cancelling ? t('common.processing') : t('payment.qr.cancelOrder') }}
</button>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { extractI18nErrorMessage } from '@/utils/apiError'
import { paymentAPI } from '@/api/payment'
import { useAppStore } from '@/stores'
import { getPaymentPopupFeatures } from '@/components/payment/providerConfig'
import type { Stripe, StripeElements } from '@stripe/stripe-js'
import Icon from '@/components/icons/Icon.vue'
// Stripe payment methods that open a popup (redirect or QR code)
const POPUP_METHODS = new Set(['alipay', 'wechat_pay'])
const props = defineProps<{
orderId: number
amount: number
clientSecret: string
orderType?: 'balance' | 'subscription'
publishableKey: string
payAmount: number
}>()
const emit = defineEmits<{ success: []; done: []; back: []; redirect: [orderId: number, payUrl: string] }>()
const { t } = useI18n()
const router = useRouter()
const appStore = useAppStore()
const stripeMount = ref<HTMLElement | null>(null)
const loading = ref(true)
const initError = ref('')
const error = ref('')
const submitting = ref(false)
const cancelling = ref(false)
const success = ref(false)
const ready = ref(false)
const selectedType = ref('')
let stripeInstance: Stripe | null = null
let elementsInstance: StripeElements | null = null
onMounted(async () => {
try {
const { loadStripe } = await import('@stripe/stripe-js')
const stripe = await loadStripe(props.publishableKey)
if (!stripe) { initError.value = t('payment.stripeLoadFailed'); return }
stripeInstance = stripe
loading.value = false
await nextTick()
if (!stripeMount.value) return
const isDark = document.documentElement.classList.contains('dark')
const elements = stripe.elements({
clientSecret: props.clientSecret,
appearance: { theme: isDark ? 'night' : 'stripe', variables: { borderRadius: '8px' } },
})
elementsInstance = elements
const paymentElement = elements.create('payment', {
layout: 'tabs',
paymentMethodOrder: ['alipay', 'wechat_pay', 'card', 'link'],
} as Record<string, unknown>)
paymentElement.mount(stripeMount.value)
paymentElement.on('ready', () => { ready.value = true })
paymentElement.on('change', (event: { value: { type: string } }) => {
selectedType.value = event.value.type
})
} catch (err: unknown) {
initError.value = extractI18nErrorMessage(err, t, 'payment.errors', t('payment.stripeLoadFailed'))
} finally {
loading.value = false
}
})
async function handlePay() {
if (!stripeInstance || !elementsInstance || submitting.value) return
// Alipay / WeChat Pay: open popup for redirect or QR display
if (POPUP_METHODS.has(selectedType.value)) {
const popupUrl = router.resolve({
path: '/payment/stripe-popup',
query: {
order_id: String(props.orderId),
method: selectedType.value,
amount: String(props.payAmount),
},
}).href
const popup = window.open(popupUrl, 'paymentPopup', getPaymentPopupFeatures())
const onReady = (event: MessageEvent) => {
if (event.source !== popup || event.data?.type !== 'STRIPE_POPUP_READY') return
window.removeEventListener('message', onReady)
popup?.postMessage({
type: 'STRIPE_POPUP_INIT',
clientSecret: props.clientSecret,
publishableKey: props.publishableKey,
}, window.location.origin)
}
window.addEventListener('message', onReady)
emit('redirect', props.orderId, popupUrl)
return
}
// Card / Link: confirm inline
submitting.value = true
error.value = ''
try {
const { error: stripeError } = await stripeInstance.confirmPayment({
elements: elementsInstance,
confirmParams: {
return_url: window.location.origin + '/payment/result?order_id=' + props.orderId + '&status=success',
},
redirect: 'if_required',
})
if (stripeError) {
error.value = stripeError.message || t('payment.result.failed')
} else {
success.value = true
emit('success')
}
} catch (err: unknown) {
error.value = extractI18nErrorMessage(err, t, 'payment.errors', t('payment.result.failed'))
} finally {
submitting.value = false
}
}
async function handleCancel() {
if (!props.orderId || cancelling.value) return
cancelling.value = true
try {
await paymentAPI.cancelOrder(props.orderId)
emit('back')
} catch (err: unknown) {
appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
} finally {
cancelling.value = false
}
}
</script>