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.
187 lines
7.8 KiB
Vue
187 lines
7.8 KiB
Vue
<template>
|
|
<AppLayout>
|
|
<div class="space-y-4">
|
|
<!-- Actions -->
|
|
<div class="flex items-center justify-end gap-2">
|
|
<button @click="loadPlans" :disabled="plansLoading" class="btn btn-secondary" :title="t('common.refresh')">
|
|
<Icon name="refresh" size="md" :class="plansLoading ? 'animate-spin' : ''" />
|
|
</button>
|
|
<button @click="openPlanEdit(null)" class="btn btn-primary">{{ t('payment.admin.createPlan') }}</button>
|
|
</div>
|
|
|
|
<!-- Plans Table -->
|
|
<DataTable :columns="planColumns" :data="plans" :loading="plansLoading">
|
|
<template #cell-name="{ value, row }">
|
|
<span class="text-sm font-medium" :class="getPlanNameClass(row.group_id)">{{ value }}</span>
|
|
</template>
|
|
<template #cell-group_id="{ value }">
|
|
<span v-if="isGroupMissing(value)" class="text-sm">
|
|
<span class="text-gray-400">#{{ value }}</span>
|
|
<span class="ml-1 badge badge-danger">{{ t('payment.admin.groupMissing') }}</span>
|
|
</span>
|
|
<GroupBadge
|
|
v-else-if="getGroup(value)"
|
|
:name="getGroup(value)!.name"
|
|
:platform="getGroup(value)!.platform"
|
|
:rate-multiplier="getGroup(value)!.rate_multiplier"
|
|
/>
|
|
<span v-else class="text-sm text-gray-400">-</span>
|
|
</template>
|
|
<template #cell-price="{ value, row }">
|
|
<div class="text-sm">
|
|
<span class="font-medium text-gray-900 dark:text-white">${{ (value ?? 0).toFixed(2) }}</span>
|
|
<span v-if="row.original_price" class="ml-1 text-xs text-gray-400 line-through">${{ row.original_price.toFixed(2) }}</span>
|
|
</div>
|
|
</template>
|
|
<template #cell-validity_days="{ value, row }">
|
|
<span class="text-sm">{{ value }} {{ t('payment.admin.' + (row.validity_unit || 'days')) }}</span>
|
|
</template>
|
|
<template #cell-for_sale="{ value, row }">
|
|
<button
|
|
type="button"
|
|
:class="[
|
|
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
|
value ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
|
|
]"
|
|
@click="toggleForSale(row)"
|
|
>
|
|
<span :class="[
|
|
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
|
value ? 'translate-x-4' : 'translate-x-0'
|
|
]" />
|
|
</button>
|
|
</template>
|
|
<template #cell-actions="{ row }">
|
|
<div class="flex items-center gap-2">
|
|
<button @click="openPlanEdit(row)" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400">
|
|
<Icon name="edit" size="sm" />
|
|
<span class="text-xs">{{ t('common.edit') }}</span>
|
|
</button>
|
|
<button @click="confirmDeletePlan(row)" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400">
|
|
<Icon name="trash" size="sm" />
|
|
<span class="text-xs">{{ t('common.delete') }}</span>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</DataTable>
|
|
</div>
|
|
|
|
<!-- Plan Edit Dialog -->
|
|
<PlanEditDialog :show="showPlanDialog" :plan="editingPlan" :groups="groups" @close="showPlanDialog = false" @saved="loadPlans" />
|
|
|
|
<ConfirmDialog :show="showDeletePlanDialog" :title="t('payment.admin.deletePlan')" :message="t('payment.admin.deletePlanConfirm')" :confirm-text="t('common.delete')" danger @confirm="handleDeletePlan" @cancel="showDeletePlanDialog = false" />
|
|
</AppLayout>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, 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 adminAPI from '@/api/admin'
|
|
import type { SubscriptionPlan } from '@/types/payment'
|
|
import type { AdminGroup } from '@/types'
|
|
import type { Column } from '@/components/common/types'
|
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
|
import DataTable from '@/components/common/DataTable.vue'
|
|
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
|
import Icon from '@/components/icons/Icon.vue'
|
|
import GroupBadge from '@/components/common/GroupBadge.vue'
|
|
import PlanEditDialog from './PlanEditDialog.vue'
|
|
import { platformTextClass } from '@/utils/platformColors'
|
|
|
|
const { t } = useI18n()
|
|
const appStore = useAppStore()
|
|
|
|
// ==================== Groups ====================
|
|
|
|
const groups = ref<AdminGroup[]>([])
|
|
|
|
async function loadGroups() {
|
|
try {
|
|
groups.value = await adminAPI.groups.getAll()
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
function getGroup(id: number): AdminGroup | undefined {
|
|
return groups.value.find(g => g.id === id)
|
|
}
|
|
|
|
function isGroupMissing(id: number): boolean {
|
|
return id > 0 && !groups.value.find(g => g.id === id)
|
|
}
|
|
|
|
function getPlanNameClass(groupId: number): string {
|
|
const group = getGroup(groupId)
|
|
return group ? platformTextClass(group.platform) : 'text-gray-900 dark:text-white'
|
|
}
|
|
|
|
|
|
// ==================== Plans ====================
|
|
|
|
const plansLoading = ref(false)
|
|
const plans = ref<SubscriptionPlan[]>([])
|
|
const showPlanDialog = ref(false)
|
|
const showDeletePlanDialog = ref(false)
|
|
const editingPlan = ref<SubscriptionPlan | null>(null)
|
|
const deletingPlanId = ref<number | null>(null)
|
|
|
|
const planColumns = computed((): Column[] => [
|
|
{ key: 'id', label: 'ID' },
|
|
{ key: 'name', label: t('payment.admin.planName') },
|
|
{ key: 'group_id', label: t('payment.admin.group') },
|
|
{ key: 'price', label: t('payment.admin.price') },
|
|
{ key: 'validity_days', label: t('payment.admin.validityDays') },
|
|
{ key: 'for_sale', label: t('payment.admin.forSale') },
|
|
{ key: 'sort_order', label: t('payment.admin.sortOrder') },
|
|
{ key: 'actions', label: t('common.actions') },
|
|
])
|
|
|
|
async function loadPlans() {
|
|
plansLoading.value = true
|
|
try {
|
|
const res = await adminPaymentAPI.getPlans()
|
|
// Backend returns features as newline-separated string; parse to array
|
|
plans.value = (res.data || []).map((p: Omit<SubscriptionPlan, 'features'> & { features: string | string[] }) => ({
|
|
...p,
|
|
features: typeof p.features === 'string'
|
|
? p.features.split('\n').map((f: string) => f.trim()).filter(Boolean)
|
|
: (p.features || []),
|
|
}))
|
|
}
|
|
catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) }
|
|
finally { plansLoading.value = false }
|
|
}
|
|
|
|
function openPlanEdit(plan: SubscriptionPlan | null) {
|
|
editingPlan.value = plan
|
|
showPlanDialog.value = true
|
|
}
|
|
|
|
|
|
/** Quick toggle for_sale from the list */
|
|
async function toggleForSale(plan: SubscriptionPlan) {
|
|
try {
|
|
await adminPaymentAPI.updatePlan(plan.id, { for_sale: !plan.for_sale })
|
|
plan.for_sale = !plan.for_sale
|
|
} catch (err: unknown) {
|
|
appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
|
|
}
|
|
}
|
|
|
|
function confirmDeletePlan(plan: SubscriptionPlan) { deletingPlanId.value = plan.id; showDeletePlanDialog.value = true }
|
|
async function handleDeletePlan() {
|
|
if (!deletingPlanId.value) return
|
|
try { await adminPaymentAPI.deletePlan(deletingPlanId.value); appStore.showSuccess(t('common.deleted')); showDeletePlanDialog.value = false; loadPlans() }
|
|
catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) }
|
|
}
|
|
|
|
// ==================== Lifecycle ====================
|
|
|
|
onMounted(() => {
|
|
loadGroups()
|
|
loadPlans()
|
|
})
|
|
</script>
|