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.
This commit is contained in:
erio
2026-04-20 20:06:53 +08:00
parent 79192cf65b
commit 40d4e167cd
16 changed files with 177 additions and 47 deletions

View File

@@ -2850,7 +2850,7 @@ import ProxySelector from '@/components/common/ProxySelector.vue'
import ImageUpload from '@/components/common/ImageUpload.vue'
import BackupSettings from '@/views/admin/BackupView.vue'
import { useClipboard } from '@/composables/useClipboard'
import { extractApiErrorMessage } from '@/utils/apiError'
import { extractApiErrorMessage, extractI18nErrorMessage } from '@/utils/apiError'
import { useAppStore } from '@/stores'
import { useAdminSettingsStore } from '@/stores/adminSettings'
import {
@@ -4085,14 +4085,10 @@ const cancelRateLimitModeOptions = computed(() => [
{ value: 'fixed', label: t('admin.settings.payment.cancelRateLimitWindowModeFixed') },
])
const paymentErrorMap = computed(() => ({
PENDING_ORDERS: t('payment.errors.PENDING_ORDERS'),
}))
async function loadProviders() {
providersLoading.value = true
try { const res = await adminAPI.payment.getProviders(); providers.value = res.data || [] }
catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) }
finally { providersLoading.value = false }
}
@@ -4122,7 +4118,7 @@ async function handleSaveProvider(payload: Partial<ProviderInstance>) {
// Auto-save settings so provider changes take effect immediately
await saveSettings()
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error'), paymentErrorMap.value))
appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
} finally {
providerSaving.value = false
}
@@ -4148,7 +4144,7 @@ async function handleToggleField(provider: ProviderInstance, field: 'enabled' |
} else {
provider.allow_user_refund = newValue
}
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'), paymentErrorMap.value)) }
} catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) }
}
async function handleToggleType(provider: ProviderInstance, type: string) {
@@ -4158,7 +4154,7 @@ async function handleToggleType(provider: ProviderInstance, type: string) {
try {
await adminAPI.payment.updateProvider(provider.id, { supported_types: updated } as any)
provider.supported_types = updated
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'), paymentErrorMap.value)) }
} catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) }
}
function confirmDeleteProvider(provider: ProviderInstance) {
@@ -4177,7 +4173,7 @@ async function handleReorderProviders(updates: { id: number; sort_order: number
if (p) p.sort_order = u.sort_order
}
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
loadProviders()
}
}
@@ -4189,7 +4185,7 @@ async function handleDeleteProvider() {
appStore.showSuccess(t('common.deleted'))
showDeleteProviderDialog.value = false
loadProviders()
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'), paymentErrorMap.value)) }
} catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) }
}
onMounted(() => {