mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-05-05 05:30:44 +08:00
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.
122 lines
5.5 KiB
Vue
122 lines
5.5 KiB
Vue
<template>
|
|
<AppLayout>
|
|
<div class="space-y-6">
|
|
<!-- Header with Day Switcher -->
|
|
<div class="flex items-center justify-end">
|
|
<div class="flex items-center gap-2">
|
|
<div class="flex rounded-lg border border-gray-200 dark:border-dark-600">
|
|
<button
|
|
v-for="d in DAYS_OPTIONS"
|
|
:key="d"
|
|
type="button"
|
|
class="px-3 py-1.5 text-xs font-medium transition-colors first:rounded-l-lg last:rounded-r-lg"
|
|
:class="days === d
|
|
? 'bg-primary-600 text-white'
|
|
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700'"
|
|
@click="days = d"
|
|
>
|
|
{{ d }}{{ t('payment.admin.daySuffix') }}
|
|
</button>
|
|
</div>
|
|
<button @click="loadDashboard" :disabled="loading" class="btn btn-secondary" :title="t('common.refresh')">
|
|
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Dashboard Content -->
|
|
<div v-if="loading" class="flex items-center justify-center py-12">
|
|
<LoadingSpinner />
|
|
</div>
|
|
<template v-else-if="stats">
|
|
<OrderStatsCards :stats="stats" />
|
|
<DailyRevenueChart :data="stats.daily_series || []" :loading="loading" />
|
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
<div class="card p-4">
|
|
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">{{ t('payment.admin.paymentDistribution') }}</h3>
|
|
<div v-if="!stats.payment_methods?.length" class="flex h-32 items-center justify-center text-sm text-gray-500 dark:text-gray-400">{{ t('payment.admin.noData') }}</div>
|
|
<div v-else class="space-y-3">
|
|
<div v-for="method in stats.payment_methods" :key="method.type" class="flex items-center justify-between">
|
|
<div class="flex items-center gap-2">
|
|
<span :class="['inline-block h-3 w-3 rounded-full', methodColor(method.type)]"></span>
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('payment.methods.' + method.type, method.type) }}</span>
|
|
</div>
|
|
<div class="text-right">
|
|
<span class="text-sm font-medium text-gray-900 dark:text-white">¥{{ method.amount.toFixed(2) }}</span>
|
|
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">({{ method.count }})</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="card p-4">
|
|
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">{{ t('payment.admin.topUsers') }}</h3>
|
|
<div v-if="!stats.top_users?.length" class="flex h-32 items-center justify-center text-sm text-gray-500 dark:text-gray-400">{{ t('payment.admin.noData') }}</div>
|
|
<div v-else class="space-y-2">
|
|
<div v-for="(user, idx) in stats.top_users" :key="user.user_id" class="flex items-center justify-between rounded-lg px-3 py-2 hover:bg-gray-50 dark:hover:bg-dark-700">
|
|
<div class="flex items-center gap-3">
|
|
<span :class="['flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold', rankClass(idx)]">{{ idx + 1 }}</span>
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">{{ user.email }}</span>
|
|
</div>
|
|
<span class="text-sm font-medium text-gray-900 dark:text-white">¥{{ user.amount.toFixed(2) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</AppLayout>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, watch, onMounted } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useAppStore } from '@/stores/app'
|
|
import { adminPaymentAPI } from '@/api/admin/payment'
|
|
import { extractI18nErrorMessage } from '@/utils/apiError'
|
|
import type { DashboardStats } from '@/types/payment'
|
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
|
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
|
import Icon from '@/components/icons/Icon.vue'
|
|
import OrderStatsCards from '@/components/admin/payment/OrderStatsCards.vue'
|
|
import DailyRevenueChart from '@/components/admin/payment/DailyRevenueChart.vue'
|
|
|
|
const { t } = useI18n()
|
|
const appStore = useAppStore()
|
|
|
|
const DAYS_OPTIONS = [7, 30, 90] as const
|
|
const days = ref<number>(30)
|
|
const loading = ref(false)
|
|
const stats = ref<DashboardStats | null>(null)
|
|
|
|
function methodColor(type: string): string {
|
|
const c: Record<string, string> = {
|
|
alipay: 'bg-blue-500', wxpay: 'bg-green-500',
|
|
alipay_direct: 'bg-blue-400', wxpay_direct: 'bg-green-400',
|
|
stripe: 'bg-purple-500',
|
|
}
|
|
return c[type] || 'bg-gray-400'
|
|
}
|
|
|
|
function rankClass(idx: number): string {
|
|
if (idx === 0) return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
|
if (idx === 1) return 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
|
|
if (idx === 2) return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
|
|
return 'bg-gray-100 text-gray-500 dark:bg-dark-700 dark:text-gray-400'
|
|
}
|
|
|
|
async function loadDashboard() {
|
|
loading.value = true
|
|
try {
|
|
const res = await adminPaymentAPI.getDashboard(days.value)
|
|
stats.value = res.data
|
|
} catch (err: unknown) {
|
|
appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
watch(days, () => loadDashboard())
|
|
onMounted(() => loadDashboard())
|
|
</script>
|