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

@@ -78,7 +78,7 @@ import Icon from '@/components/icons/Icon.vue'
import { usePaymentStore } from '@/stores/payment' import { usePaymentStore } from '@/stores/payment'
import { useAppStore } from '@/stores' import { useAppStore } from '@/stores'
import { paymentAPI } from '@/api/payment' import { paymentAPI } from '@/api/payment'
import { extractApiErrorMessage } from '@/utils/apiError' import { extractI18nErrorMessage } from '@/utils/apiError'
import { getPaymentPopupFeatures } from '@/components/payment/providerConfig' import { getPaymentPopupFeatures } from '@/components/payment/providerConfig'
import type { PaymentOrder } from '@/types/payment' import type { PaymentOrder } from '@/types/payment'
import QRCode from 'qrcode' import QRCode from 'qrcode'
@@ -222,7 +222,7 @@ async function handleCancel() {
cleanup() cleanup()
emit('close') emit('close')
} catch (err: unknown) { } catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error'))) appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
} finally { } finally {
cancelling.value = false cancelling.value = false
} }

View File

@@ -124,7 +124,7 @@ import { useI18n } from 'vue-i18n'
import { usePaymentStore } from '@/stores/payment' import { usePaymentStore } from '@/stores/payment'
import { useAppStore } from '@/stores' import { useAppStore } from '@/stores'
import { paymentAPI } from '@/api/payment' import { paymentAPI } from '@/api/payment'
import { extractApiErrorMessage } from '@/utils/apiError' import { extractI18nErrorMessage } from '@/utils/apiError'
import { getPaymentPopupFeatures } from '@/components/payment/providerConfig' import { getPaymentPopupFeatures } from '@/components/payment/providerConfig'
import type { PaymentOrder } from '@/types/payment' import type { PaymentOrder } from '@/types/payment'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
@@ -242,7 +242,7 @@ async function handleCancel() {
cleanup() cleanup()
outcome.value = 'cancelled' outcome.value = 'cancelled'
} catch (err: unknown) { } catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error'))) appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
} finally { } finally {
cancelling.value = false cancelling.value = false
} }

View File

@@ -67,7 +67,7 @@
import { ref, onMounted, nextTick } from 'vue' import { ref, onMounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { extractApiErrorMessage } from '@/utils/apiError' import { extractI18nErrorMessage } from '@/utils/apiError'
import { paymentAPI } from '@/api/payment' import { paymentAPI } from '@/api/payment'
import { useAppStore } from '@/stores' import { useAppStore } from '@/stores'
import { getPaymentPopupFeatures } from '@/components/payment/providerConfig' import { getPaymentPopupFeatures } from '@/components/payment/providerConfig'
@@ -132,7 +132,7 @@ onMounted(async () => {
selectedType.value = event.value.type selectedType.value = event.value.type
}) })
} catch (err: unknown) { } catch (err: unknown) {
initError.value = extractApiErrorMessage(err, t('payment.stripeLoadFailed')) initError.value = extractI18nErrorMessage(err, t, 'payment.errors', t('payment.stripeLoadFailed'))
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -186,7 +186,7 @@ async function handlePay() {
emit('success') emit('success')
} }
} catch (err: unknown) { } catch (err: unknown) {
error.value = extractApiErrorMessage(err, t('payment.result.failed')) error.value = extractI18nErrorMessage(err, t, 'payment.errors', t('payment.result.failed'))
} finally { } finally {
submitting.value = false submitting.value = false
} }
@@ -199,7 +199,7 @@ async function handleCancel() {
await paymentAPI.cancelOrder(props.orderId) await paymentAPI.cancelOrder(props.orderId)
emit('back') emit('back')
} catch (err: unknown) { } catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error'))) appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
} finally { } finally {
cancelling.value = false cancelling.value = false
} }

View File

@@ -99,9 +99,9 @@ export const PROVIDER_CONFIG_FIELDS: Record<string, ConfigFieldDef[]> = {
{ key: 'mchId', label: '', sensitive: false }, { key: 'mchId', label: '', sensitive: false },
{ key: 'privateKey', label: '', sensitive: true }, { key: 'privateKey', label: '', sensitive: true },
{ key: 'apiV3Key', label: '', sensitive: true }, { key: 'apiV3Key', label: '', sensitive: true },
{ key: 'certSerial', label: '', sensitive: false },
{ key: 'publicKey', label: '', sensitive: true }, { key: 'publicKey', label: '', sensitive: true },
{ key: 'publicKeyId', label: '', sensitive: false, optional: true }, { key: 'publicKeyId', label: '', sensitive: false },
{ key: 'certSerial', label: '', sensitive: false, optional: true },
], ],
stripe: [ stripe: [
{ key: 'secretKey', label: '', sensitive: true }, { key: 'secretKey', label: '', sensitive: true },

View File

@@ -5432,7 +5432,33 @@ export default {
errors: { errors: {
tooManyPending: 'Too many pending orders (max {max}). Please complete or cancel existing orders first.', tooManyPending: 'Too many pending orders (max {max}). Please complete or cancel existing orders first.',
cancelRateLimited: 'Too many cancellations. Please try again later.', cancelRateLimited: 'Too many cancellations. Please try again later.',
// Structured error codes (reason strings from backend ApplicationError)
PAYMENT_DISABLED: 'Payment system is disabled.',
USER_INACTIVE: 'Your account is disabled.',
BALANCE_PAYMENT_DISABLED: 'Balance recharge has been disabled.',
INVALID_AMOUNT: 'Invalid amount.',
INVALID_INPUT: 'Invalid request.',
PLAN_NOT_AVAILABLE: 'Plan not found or no longer available.',
GROUP_NOT_FOUND: 'Subscription group is no longer available.',
GROUP_TYPE_MISMATCH: 'Group is not a subscription type.',
TOO_MANY_PENDING: 'Too many pending orders (max {max}). Please complete or cancel existing orders first.',
DAILY_LIMIT_EXCEEDED: 'Daily recharge limit reached. Remaining: {remaining}.',
PAYMENT_GATEWAY_ERROR: 'Payment method is unavailable.',
NO_AVAILABLE_INSTANCE: 'No payment channel available right now.',
PAYMENT_PROVIDER_MISCONFIGURED: 'Payment provider misconfigured. Please contact an administrator.',
WXPAY_CONFIG_MISSING_KEY: 'WeChat Pay config missing required key: {key}.',
WXPAY_CONFIG_INVALID_KEY_LENGTH: 'WeChat Pay {key} length is invalid (expected {expected} bytes, got {actual}).',
WXPAY_CONFIG_INVALID_KEY: 'WeChat Pay {key} is malformed. Make sure you copied the full PEM content.',
PENDING_ORDERS: 'This provider has pending orders. Please wait for them to complete before making changes.', PENDING_ORDERS: 'This provider has pending orders. Please wait for them to complete before making changes.',
CANCEL_RATE_LIMITED: 'Too many cancellations. Please try again later.',
NOT_FOUND: 'Order not found.',
FORBIDDEN: 'No permission for this order.',
CONFLICT: 'Order status has changed. Please refresh.',
INVALID_ORDER_TYPE: 'Only balance orders can request a refund.',
INVALID_STATUS: 'The current order status does not allow this operation.',
BALANCE_NOT_ENOUGH: 'Refund amount exceeds balance.',
REFUND_AMOUNT_EXCEEDED: 'Refund amount exceeds the recharge amount.',
REFUND_FAILED: 'Refund failed.',
}, },
stripePay: 'Pay Now', stripePay: 'Pay Now',
stripeSuccessProcessing: 'Payment successful, processing your order...', stripeSuccessProcessing: 'Payment successful, processing your order...',

View File

@@ -5620,7 +5620,33 @@ export default {
errors: { errors: {
tooManyPending: '待支付订单过多(最多 {max} 个),请先完成或取消现有订单', tooManyPending: '待支付订单过多(最多 {max} 个),请先完成或取消现有订单',
cancelRateLimited: '取消订单过于频繁,请稍后再试', cancelRateLimited: '取消订单过于频繁,请稍后再试',
// Structured error codes (reason strings from backend ApplicationError)
PAYMENT_DISABLED: '支付系统已关闭',
USER_INACTIVE: '账号已被禁用',
BALANCE_PAYMENT_DISABLED: '余额充值功能已关闭',
INVALID_AMOUNT: '金额无效',
INVALID_INPUT: '参数有误',
PLAN_NOT_AVAILABLE: '套餐不存在或已下架',
GROUP_NOT_FOUND: '订阅分组不可用',
GROUP_TYPE_MISMATCH: '分组类型不是订阅类型',
TOO_MANY_PENDING: '待支付订单过多(最多 {max} 个),请先完成或取消现有订单',
DAILY_LIMIT_EXCEEDED: '今日充值已达上限,剩余额度 {remaining}',
PAYMENT_GATEWAY_ERROR: '支付方式不可用',
NO_AVAILABLE_INSTANCE: '暂无可用的支付通道',
PAYMENT_PROVIDER_MISCONFIGURED: '支付通道配置错误,请联系管理员',
WXPAY_CONFIG_MISSING_KEY: '微信支付配置缺少必填项:{key}',
WXPAY_CONFIG_INVALID_KEY_LENGTH: '微信支付 {key} 长度错误,应为 {expected} 字节(实际 {actual}',
WXPAY_CONFIG_INVALID_KEY: '微信支付 {key} 格式错误,请确认复制了完整的 PEM 内容',
PENDING_ORDERS: '该服务商有未完成的订单,请等待订单完成后再操作', PENDING_ORDERS: '该服务商有未完成的订单,请等待订单完成后再操作',
CANCEL_RATE_LIMITED: '取消订单过于频繁,请稍后再试',
NOT_FOUND: '订单不存在',
FORBIDDEN: '无权限操作此订单',
CONFLICT: '订单状态已变更,请刷新',
INVALID_ORDER_TYPE: '仅余额订单可申请退款',
INVALID_STATUS: '当前订单状态不允许此操作',
BALANCE_NOT_ENOUGH: '退款金额超过余额',
REFUND_AMOUNT_EXCEEDED: '退款金额超过充值金额',
REFUND_FAILED: '退款失败',
}, },
stripePay: '立即支付', stripePay: '立即支付',
stripeSuccessProcessing: '支付成功,正在处理订单...', stripeSuccessProcessing: '支付成功,正在处理订单...',

View File

@@ -23,14 +23,96 @@ interface ApiErrorLike {
/** /**
* Extract the error code from an API error object. * Extract the error code from an API error object.
*
* Prefers the string `reason` (e.g. "PAYMENT_PROVIDER_MISCONFIGURED") over the
* numeric HTTP `code`, because reason is granular enough to drive i18n lookup
* while HTTP code is not.
*/ */
export function extractApiErrorCode(err: unknown): string | undefined { export function extractApiErrorCode(err: unknown): string | undefined {
if (!err || typeof err !== 'object') return undefined if (!err || typeof err !== 'object') return undefined
const e = err as ApiErrorLike const e = err as ApiErrorLike
const code = e.code ?? e.reason ?? e.response?.data?.code const code = e.reason ?? e.code ?? e.response?.data?.code
return code != null ? String(code) : undefined return code != null ? String(code) : undefined
} }
/**
* Extract metadata (interpolation params) from an API error object.
* Backend errors carry `metadata` with template variables that fill i18n placeholders.
*/
export function extractApiErrorMetadata(err: unknown): Record<string, unknown> | undefined {
if (!err || typeof err !== 'object') return undefined
const e = err as ApiErrorLike
return e.metadata
}
type TranslateFn = (key: string, params?: Record<string, unknown>) => string
type TranslateWithExistsFn = TranslateFn & { te?: (key: string) => boolean }
/**
* Translate a value via i18n if a matching key exists, otherwise return the original.
* Example: "certSerial" → t('admin.settings.payment.field_certSerial') → "证书序列号".
*/
function tryTranslate(t: TranslateFn, key: string, fallback: string): string {
const translated = t(key)
if (translated === key) return fallback
const te = (t as TranslateWithExistsFn).te
if (te && !te(key)) return fallback
return translated
}
/**
* Replace raw config field names in metadata (e.g. "certSerial") with their
* localized UI labels (e.g. "证书序列号"), using the provider-config field i18n namespace.
* Handles both single `key` and `/`-joined `keys` patterns used by wxpay errors.
*/
function localizeMetadata(metadata: Record<string, unknown>, t: TranslateFn): Record<string, unknown> {
const out: Record<string, unknown> = { ...metadata }
if (typeof out.key === 'string') {
out.key = tryTranslate(t, `admin.settings.payment.field_${out.key}`, out.key)
}
if (typeof out.keys === 'string') {
out.keys = out.keys
.split('/')
.map(k => tryTranslate(t, `admin.settings.payment.field_${k}`, k))
.join(' / ')
}
return out
}
/**
* Extract a localized error message from an API error by looking up
* `<namespace>.<REASON>` in i18n and substituting metadata as placeholders.
*
* Config-field names in metadata (`key` / `keys`) are automatically translated
* to their UI labels before substitution, so error messages read like
* "缺少必填项:证书序列号" instead of "缺少必填项certSerial".
*
* @param err - The caught error
* @param t - Vue i18n translate function
* @param namespace- i18n key prefix, e.g. "payment.errors"
* @param fallback - Fallback key or plain string if no localized mapping exists
*/
export function extractI18nErrorMessage(
err: unknown,
t: TranslateFn,
namespace: string,
fallback: string,
): string {
const code = extractApiErrorCode(err)
if (code) {
const key = `${namespace}.${code}`
const rawMetadata = extractApiErrorMetadata(err) ?? {}
const metadata = localizeMetadata(rawMetadata, t)
const translated = t(key, metadata)
// Vue i18n returns the key itself when missing; detect that and fall back.
if (translated !== key) return translated
// If the framework exposes `te`, use it to double-check.
const te = (t as TranslateWithExistsFn).te
if (te && te(key)) return translated
}
return extractApiErrorMessage(err, fallback)
}
/** /**
* Extract a displayable error message from an API error. * Extract a displayable error message from an API error.
* *

View File

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

View File

@@ -116,7 +116,7 @@ import { ref, reactive, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { adminPaymentAPI } from '@/api/admin/payment' import { adminPaymentAPI } from '@/api/admin/payment'
import { extractApiErrorMessage } from '@/utils/apiError' import { extractI18nErrorMessage } from '@/utils/apiError'
import { formatOrderDateTime } from '@/components/payment/orderUtils' import { formatOrderDateTime } from '@/components/payment/orderUtils'
import type { PaymentOrder } from '@/types/payment' import type { PaymentOrder } from '@/types/payment'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
@@ -167,7 +167,7 @@ async function loadOrders() {
orders.value = res.data.items || [] orders.value = res.data.items || []
orderPagination.total = res.data.total || 0 orderPagination.total = res.data.total || 0
} catch (err: unknown) { } catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error'))) appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
} finally { ordersLoading.value = false } } finally { ordersLoading.value = false }
} }
@@ -214,12 +214,12 @@ async function showOrderDetail(order: PaymentOrder) {
async function handleCancelOrder(order: PaymentOrder) { async function handleCancelOrder(order: PaymentOrder) {
try { await adminPaymentAPI.cancelOrder(order.id); appStore.showSuccess(t('payment.admin.orderCancelled')); loadOrders() } try { await adminPaymentAPI.cancelOrder(order.id); appStore.showSuccess(t('payment.admin.orderCancelled')); loadOrders() }
catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) } catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) }
} }
async function handleRetryOrder(order: PaymentOrder) { async function handleRetryOrder(order: PaymentOrder) {
try { await adminPaymentAPI.retryRecharge(order.id); appStore.showSuccess(t('payment.admin.retrySuccess')); loadOrders() } try { await adminPaymentAPI.retryRecharge(order.id); appStore.showSuccess(t('payment.admin.retrySuccess')); loadOrders() }
catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) } catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) }
} }
function openRefundDialog(order: PaymentOrder) { selectedOrder.value = order; showRefundDialog.value = true } function openRefundDialog(order: PaymentOrder) { selectedOrder.value = order; showRefundDialog.value = true }
@@ -230,7 +230,7 @@ async function handleRefund(data: { amount: number; reason: string; deduct_balan
try { try {
await adminPaymentAPI.refundOrder(selectedOrder.value.id, { amount: data.amount, reason: data.reason, deduct_balance: data.deduct_balance, force: data.force }) await adminPaymentAPI.refundOrder(selectedOrder.value.id, { amount: data.amount, reason: data.reason, deduct_balance: data.deduct_balance, force: data.force })
appStore.showSuccess(t('payment.admin.refundSuccess')); showRefundDialog.value = false; loadOrders() appStore.showSuccess(t('payment.admin.refundSuccess')); showRefundDialog.value = false; loadOrders()
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) } } catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) }
finally { refundSubmitting.value = false } finally { refundSubmitting.value = false }
} }

View File

@@ -72,7 +72,7 @@ import { ref, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { adminPaymentAPI } from '@/api/admin/payment' import { adminPaymentAPI } from '@/api/admin/payment'
import { extractApiErrorMessage } from '@/utils/apiError' import { extractI18nErrorMessage } from '@/utils/apiError'
import type { DashboardStats } from '@/types/payment' import type { DashboardStats } from '@/types/payment'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue' import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
@@ -110,7 +110,7 @@ async function loadDashboard() {
const res = await adminPaymentAPI.getDashboard(days.value) const res = await adminPaymentAPI.getDashboard(days.value)
stats.value = res.data stats.value = res.data
} catch (err: unknown) { } catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error'))) appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
} finally { } finally {
loading.value = false loading.value = false
} }

View File

@@ -78,7 +78,7 @@ import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { adminPaymentAPI } from '@/api/admin/payment' import { adminPaymentAPI } from '@/api/admin/payment'
import { extractApiErrorMessage } from '@/utils/apiError' import { extractI18nErrorMessage } from '@/utils/apiError'
import adminAPI from '@/api/admin' import adminAPI from '@/api/admin'
import type { SubscriptionPlan } from '@/types/payment' import type { SubscriptionPlan } from '@/types/payment'
import type { AdminGroup } from '@/types' import type { AdminGroup } from '@/types'
@@ -150,7 +150,7 @@ async function loadPlans() {
: (p.features || []), : (p.features || []),
})) }))
} }
catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) } catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) }
finally { plansLoading.value = false } finally { plansLoading.value = false }
} }
@@ -166,7 +166,7 @@ async function toggleForSale(plan: SubscriptionPlan) {
await adminPaymentAPI.updatePlan(plan.id, { for_sale: !plan.for_sale }) await adminPaymentAPI.updatePlan(plan.id, { for_sale: !plan.for_sale })
plan.for_sale = !plan.for_sale plan.for_sale = !plan.for_sale
} catch (err: unknown) { } catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error'))) appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
} }
} }
@@ -174,7 +174,7 @@ function confirmDeletePlan(plan: SubscriptionPlan) { deletingPlanId.value = plan
async function handleDeletePlan() { async function handleDeletePlan() {
if (!deletingPlanId.value) return if (!deletingPlanId.value) return
try { await adminPaymentAPI.deletePlan(deletingPlanId.value); appStore.showSuccess(t('common.deleted')); showDeletePlanDialog.value = false; loadPlans() } try { await adminPaymentAPI.deletePlan(deletingPlanId.value); appStore.showSuccess(t('common.deleted')); showDeletePlanDialog.value = false; loadPlans() }
catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) } catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) }
} }
// ==================== Lifecycle ==================== // ==================== Lifecycle ====================

View File

@@ -39,7 +39,7 @@ import { useRoute, useRouter } from 'vue-router'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import { usePaymentStore } from '@/stores/payment' import { usePaymentStore } from '@/stores/payment'
import { paymentAPI } from '@/api/payment' import { paymentAPI } from '@/api/payment'
import { extractApiErrorMessage } from '@/utils/apiError' import { extractI18nErrorMessage } from '@/utils/apiError'
import { useAppStore } from '@/stores' import { useAppStore } from '@/stores'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import alipayIcon from '@/assets/icons/alipay.svg' import alipayIcon from '@/assets/icons/alipay.svg'
@@ -167,7 +167,7 @@ async function handleCancel() {
cleanup() cleanup()
router.push('/purchase') router.push('/purchase')
} catch (err: unknown) { } catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error'))) appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
} finally { } finally {
cancelling.value = false cancelling.value = false
} }

View File

@@ -271,7 +271,7 @@ import { usePaymentStore } from '@/stores/payment'
import { useSubscriptionStore } from '@/stores/subscriptions' import { useSubscriptionStore } from '@/stores/subscriptions'
import { useAppStore } from '@/stores' import { useAppStore } from '@/stores'
import { paymentAPI } from '@/api/payment' import { paymentAPI } from '@/api/payment'
import { extractApiErrorMessage } from '@/utils/apiError' import { extractI18nErrorMessage } from '@/utils/apiError'
import { isMobileDevice } from '@/utils/device' import { isMobileDevice } from '@/utils/device'
import type { SubscriptionPlan, CheckoutInfoResponse, OrderType } from '@/types/payment' import type { SubscriptionPlan, CheckoutInfoResponse, OrderType } from '@/types/payment'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
@@ -610,7 +610,7 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
} else if (apiErr.reason === 'CANCEL_RATE_LIMITED') { } else if (apiErr.reason === 'CANCEL_RATE_LIMITED') {
errorMessage.value = t('payment.errors.cancelRateLimited') errorMessage.value = t('payment.errors.cancelRateLimited')
} else { } else {
errorMessage.value = extractApiErrorMessage(err, t('payment.result.failed')) errorMessage.value = extractI18nErrorMessage(err, t, 'payment.errors', t('payment.result.failed'))
} }
appStore.showError(errorMessage.value) appStore.showError(errorMessage.value)
} finally { } finally {
@@ -648,7 +648,7 @@ onMounted(async () => {
} }
} }
} }
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) } } catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) }
finally { loading.value = false } finally { loading.value = false }
// Fetch active subscriptions (uses cache, non-blocking) // Fetch active subscriptions (uses cache, non-blocking)
subscriptionStore.fetchActiveSubscriptions().catch(() => {}) subscriptionStore.fetchActiveSubscriptions().catch(() => {})

View File

@@ -99,7 +99,7 @@ import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { usePaymentStore } from '@/stores/payment' import { usePaymentStore } from '@/stores/payment'
import { paymentAPI } from '@/api/payment' import { paymentAPI } from '@/api/payment'
import { extractApiErrorMessage } from '@/utils/apiError' import { extractI18nErrorMessage } from '@/utils/apiError'
import { isMobileDevice } from '@/utils/device' import { isMobileDevice } from '@/utils/device'
import type { PaymentOrder } from '@/types/payment' import type { PaymentOrder } from '@/types/payment'
import type { Stripe, StripeElements } from '@stripe/stripe-js' import type { Stripe, StripeElements } from '@stripe/stripe-js'
@@ -167,7 +167,7 @@ onMounted(async () => {
mountPaymentElement(stripe, clientSecret) mountPaymentElement(stripe, clientSecret)
} }
} catch (err: unknown) { } catch (err: unknown) {
initError.value = extractApiErrorMessage(err, t('payment.stripeLoadFailed')) initError.value = extractI18nErrorMessage(err, t, 'payment.errors', t('payment.stripeLoadFailed'))
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -248,7 +248,7 @@ async function handleGenericPay() {
scheduleClose() scheduleClose()
} }
} catch (err: unknown) { } catch (err: unknown) {
stripeError.value = extractApiErrorMessage(err, t('payment.result.failed')) stripeError.value = extractI18nErrorMessage(err, t, 'payment.errors', t('payment.result.failed'))
} finally { } finally {
stripeSubmitting.value = false stripeSubmitting.value = false
} }

View File

@@ -56,7 +56,7 @@
import { computed, ref, onMounted, onUnmounted } from 'vue' import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { extractApiErrorMessage } from '@/utils/apiError' import { extractI18nErrorMessage } from '@/utils/apiError'
import { isMobileDevice } from '@/utils/device' import { isMobileDevice } from '@/utils/device'
interface StripeWithWechatPay { interface StripeWithWechatPay {
@@ -143,7 +143,7 @@ async function initStripe(clientSecret: string, publishableKey: string) {
} }
} }
} catch (err: unknown) { } catch (err: unknown) {
error.value = extractApiErrorMessage(err, t('payment.stripeLoadFailed')) error.value = extractI18nErrorMessage(err, t, 'payment.errors', t('payment.stripeLoadFailed'))
} }
} }

View File

@@ -86,7 +86,7 @@ import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores' import { useAppStore } from '@/stores'
import { paymentAPI } from '@/api/payment' import { paymentAPI } from '@/api/payment'
import { extractApiErrorMessage } from '@/utils/apiError' import { extractI18nErrorMessage } from '@/utils/apiError'
import type { PaymentOrder } from '@/types/payment' import type { PaymentOrder } from '@/types/payment'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import Pagination from '@/components/common/Pagination.vue' import Pagination from '@/components/common/Pagination.vue'
@@ -128,7 +128,7 @@ async function fetchOrders() {
orders.value = res.data.items || [] orders.value = res.data.items || []
pagination.total = res.data.total || 0 pagination.total = res.data.total || 0
} catch (err: unknown) { } catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error'))) appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -148,7 +148,7 @@ async function confirmCancel() {
cancelTargetId.value = null cancelTargetId.value = null
await fetchOrders() await fetchOrders()
} catch (err: unknown) { } catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error'))) appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
} finally { } finally {
actionLoading.value = false actionLoading.value = false
} }
@@ -166,7 +166,7 @@ async function confirmRefund() {
refundReason.value = '' refundReason.value = ''
await fetchOrders() await fetchOrders()
} catch (err: unknown) { } catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error'))) appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
} finally { } finally {
actionLoading.value = false actionLoading.value = false
} }