mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
fix(frontend): 7 payment UI issues
1. Remove duplicate page titles (AppLayout bar already shows them) 2. Fix daily_limit empty value causing Go unmarshal error (NaN -> 0) 3. Enabled payment types: text input -> toggle badges 4. Provider dialog: supported types -> toggle badges per provider key 5. Auto-derive supported types when changing provider key 6. Fix ¥ symbol showing as literal \u00A5 in Vue templates 7. Show payment method selector when >= 1 method (was > 1)
This commit is contained in:
@@ -4345,6 +4345,7 @@ export default {
|
||||
helpImageUrl: 'Help Image URL',
|
||||
manageProviders: 'Manage Providers',
|
||||
balancePaymentDisabled: 'Disable Balance Recharge',
|
||||
noLimit: 'Empty = no limit',
|
||||
productNamePrefix: 'Product Name Prefix',
|
||||
productNameSuffix: 'Product Name Suffix',
|
||||
preview: 'Preview',
|
||||
|
||||
@@ -4518,6 +4518,7 @@ export default {
|
||||
helpImageUrl: '帮助图片链接',
|
||||
manageProviders: '管理服务商',
|
||||
balancePaymentDisabled: '禁用余额充值',
|
||||
noLimit: '留空表示不限制',
|
||||
productNamePrefix: '商品名前缀',
|
||||
productNameSuffix: '商品名后缀',
|
||||
preview: '预览',
|
||||
|
||||
@@ -1671,16 +1671,32 @@
|
||||
</div>
|
||||
<!-- Amount / timeout / pending -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div><label class="input-label">{{ t('admin.settings.payment.minAmount') }}</label><input v-model.number="form.payment_min_amount" type="number" step="0.01" min="0" class="input" /></div>
|
||||
<div><label class="input-label">{{ t('admin.settings.payment.maxAmount') }}</label><input v-model.number="form.payment_max_amount" type="number" step="0.01" min="0" class="input" /></div>
|
||||
<div><label class="input-label">{{ t('admin.settings.payment.minAmount') }}</label><input v-model.number="form.payment_min_amount" type="number" step="0.01" min="0" class="input" :placeholder="t('admin.settings.payment.noLimit')" /></div>
|
||||
<div><label class="input-label">{{ t('admin.settings.payment.maxAmount') }}</label><input v-model.number="form.payment_max_amount" type="number" step="0.01" min="0" class="input" :placeholder="t('admin.settings.payment.noLimit')" /></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div><label class="input-label">{{ t('admin.settings.payment.dailyLimit') }}</label><input v-model.number="form.payment_daily_limit" type="number" step="0.01" min="0" class="input" /></div>
|
||||
<div><label class="input-label">{{ t('admin.settings.payment.dailyLimit') }}</label><input v-model.number="form.payment_daily_limit" type="number" step="0.01" min="0" class="input" :placeholder="t('admin.settings.payment.noLimit')" /></div>
|
||||
<div><label class="input-label">{{ t('admin.settings.payment.maxPendingOrders') }}</label><input v-model.number="form.payment_max_pending_orders" type="number" min="1" class="input" /></div>
|
||||
<div><label class="input-label">{{ t('admin.settings.payment.orderTimeout') }}</label><input v-model.number="form.payment_order_timeout_minutes" type="number" min="1" class="input" /></div>
|
||||
</div>
|
||||
<!-- Enabled payment types -->
|
||||
<div><label class="input-label">{{ t('admin.settings.payment.enabledPaymentTypes') }}</label><input v-model="form.payment_enabled_types" type="text" class="input" placeholder="alipay,wxpay,stripe" /><p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.enabledPaymentTypesHint') }}</p></div>
|
||||
<!-- Enabled payment types as toggle badges -->
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.settings.payment.enabledPaymentTypes') }}</label>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="pt in allPaymentTypes"
|
||||
:key="pt.value"
|
||||
type="button"
|
||||
@click="togglePaymentType(pt.value)"
|
||||
:class="[
|
||||
'rounded-lg border px-4 py-2 text-sm font-medium transition-all',
|
||||
isPaymentTypeEnabled(pt.value)
|
||||
? 'border-primary-500 bg-primary-500 text-white shadow-sm'
|
||||
: 'border-gray-300 bg-white text-gray-600 hover:border-gray-400 hover:bg-gray-50 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300 dark:hover:border-dark-500',
|
||||
]"
|
||||
>{{ pt.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Load balance strategy -->
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.settings.payment.loadBalanceStrategy') }}</label>
|
||||
@@ -1756,9 +1772,26 @@
|
||||
<form id="provider-form" @submit.prevent="handleSaveProvider" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div><label class="input-label">{{ t('admin.settings.payment.providerName') }}</label><input v-model="providerForm.name" type="text" class="input" required /></div>
|
||||
<div><label class="input-label">{{ t('admin.settings.payment.providerKey') }}</label><Select v-model="providerForm.provider_key" :options="providerKeyOptions" :disabled="!!editingProvider" /></div>
|
||||
<div><label class="input-label">{{ t('admin.settings.payment.providerKey') }}</label><Select v-model="providerForm.provider_key" :options="providerKeyOptions" :disabled="!!editingProvider" @change="onProviderKeyChange" /></div>
|
||||
</div>
|
||||
<!-- Supported types as toggle badges -->
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.settings.payment.supportedTypes') }}</label>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="pt in providerAvailableTypes"
|
||||
:key="pt.value"
|
||||
type="button"
|
||||
@click="toggleProviderSupportedType(pt.value)"
|
||||
:class="[
|
||||
'rounded-lg border px-4 py-2 text-sm font-medium transition-all',
|
||||
isProviderTypeSelected(pt.value)
|
||||
? 'border-primary-500 bg-primary-500 text-white shadow-sm'
|
||||
: 'border-gray-300 bg-white text-gray-600 hover:border-gray-400 hover:bg-gray-50 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300 dark:hover:border-dark-500',
|
||||
]"
|
||||
>{{ pt.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div><label class="input-label">{{ t('admin.settings.payment.supportedTypes') }}</label><input v-model="providerForm.supported_types" type="text" class="input" placeholder="alipay,wxpay" /><p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.supportedTypesHint') }}</p></div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2"><input id="prov-enabled" v-model="providerForm.enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500" /><label for="prov-enabled" class="text-sm text-gray-700 dark:text-gray-300">{{ t('common.enabled') }}</label></div>
|
||||
<div class="flex items-center gap-2"><input id="prov-refund" v-model="providerForm.refund_enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500" /><label for="prov-refund" class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.settings.payment.refundEnabled') }}</label></div>
|
||||
@@ -2574,11 +2607,11 @@ async function saveSettings() {
|
||||
: []
|
||||
const paymentPayload = {
|
||||
enabled: form.payment_enabled,
|
||||
min_amount: form.payment_min_amount,
|
||||
max_amount: form.payment_max_amount,
|
||||
daily_limit: form.payment_daily_limit,
|
||||
max_pending_orders: form.payment_max_pending_orders,
|
||||
order_timeout_minutes: form.payment_order_timeout_minutes,
|
||||
min_amount: Number(form.payment_min_amount) || 0,
|
||||
max_amount: Number(form.payment_max_amount) || 0,
|
||||
daily_limit: Number(form.payment_daily_limit) || 0,
|
||||
max_pending_orders: Number(form.payment_max_pending_orders) || 0,
|
||||
order_timeout_minutes: Number(form.payment_order_timeout_minutes) || 0,
|
||||
balance_disabled: form.payment_balance_disabled,
|
||||
enabled_payment_types: enabledTypes,
|
||||
product_name_prefix: form.payment_product_name_prefix,
|
||||
@@ -2887,6 +2920,32 @@ async function saveBetaPolicySettings() {
|
||||
|
||||
// ==================== Provider Management ====================
|
||||
|
||||
const PROVIDER_SUPPORTED_TYPES: Record<string, string[]> = {
|
||||
easypay: ['alipay', 'wxpay'],
|
||||
alipay: ['alipay'],
|
||||
wxpay: ['wxpay'],
|
||||
stripe: ['stripe'],
|
||||
}
|
||||
|
||||
const allPaymentTypes = computed(() => [
|
||||
{ value: 'alipay', label: t('payment.methods.alipay') },
|
||||
{ value: 'wxpay', label: t('payment.methods.wxpay') },
|
||||
{ value: 'stripe', label: t('payment.methods.stripe') },
|
||||
])
|
||||
|
||||
function isPaymentTypeEnabled(type: string): boolean {
|
||||
return form.payment_enabled_types.split(',').map(s => s.trim()).filter(Boolean).includes(type)
|
||||
}
|
||||
|
||||
function togglePaymentType(type: string) {
|
||||
const current = form.payment_enabled_types.split(',').map(s => s.trim()).filter(Boolean)
|
||||
if (current.includes(type)) {
|
||||
form.payment_enabled_types = current.filter(t => t !== type).join(',')
|
||||
} else {
|
||||
form.payment_enabled_types = [...current, type].join(',')
|
||||
}
|
||||
}
|
||||
|
||||
const providersLoading = ref(false)
|
||||
const providerSaving = ref(false)
|
||||
const providers = ref<ProviderInstance[]>([])
|
||||
@@ -2915,6 +2974,33 @@ const loadBalanceOptions = computed(() => [
|
||||
{ value: 'round-robin', label: t('admin.settings.payment.strategyRoundRobin') },
|
||||
{ value: 'least-amount', label: t('admin.settings.payment.strategyLeastAmount') },
|
||||
])
|
||||
|
||||
const providerAvailableTypes = computed(() => {
|
||||
const types = PROVIDER_SUPPORTED_TYPES[providerForm.provider_key] || []
|
||||
return types.map(t => {
|
||||
const found = allPaymentTypes.value.find(pt => pt.value === t)
|
||||
return found || { value: t, label: t }
|
||||
})
|
||||
})
|
||||
|
||||
function isProviderTypeSelected(type: string): boolean {
|
||||
return providerForm.supported_types.split(',').map(s => s.trim()).filter(Boolean).includes(type)
|
||||
}
|
||||
|
||||
function toggleProviderSupportedType(type: string) {
|
||||
const current = providerForm.supported_types.split(',').map(s => s.trim()).filter(Boolean)
|
||||
if (current.includes(type)) {
|
||||
providerForm.supported_types = current.filter(t => t !== type).join(',')
|
||||
} else {
|
||||
providerForm.supported_types = [...current, type].join(',')
|
||||
}
|
||||
}
|
||||
|
||||
function onProviderKeyChange() {
|
||||
// Auto-select all supported types for the new provider key
|
||||
providerForm.supported_types = (PROVIDER_SUPPORTED_TYPES[providerForm.provider_key] || []).join(',')
|
||||
}
|
||||
|
||||
function providerKeyLabel(key: string): string {
|
||||
return providerKeyOptions.find(o => o.value === key)?.label || key
|
||||
}
|
||||
@@ -2925,7 +3011,8 @@ async function loadProviders() {
|
||||
finally { providersLoading.value = false }
|
||||
}
|
||||
function resetProviderForm() {
|
||||
providerForm.name = ''; providerForm.provider_key = 'easypay'; providerForm.supported_types = ''
|
||||
providerForm.name = ''; providerForm.provider_key = 'easypay'
|
||||
providerForm.supported_types = (PROVIDER_SUPPORTED_TYPES['easypay'] || []).join(',')
|
||||
providerForm.enabled = true; providerForm.refund_enabled = false
|
||||
Object.keys(providerConfig).forEach(k => { (providerConfig as any)[k] = '' })
|
||||
}
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-4">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">{{ t('payment.admin.orders') }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card p-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Header with Day Switcher -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">{{ t('payment.admin.dashboardTitle') }}</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ t('payment.admin.dashboardDesc') }}</p>
|
||||
</div>
|
||||
<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
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">{{ t('payment.admin.plansPageTitle') }}</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ t('payment.admin.plansPageDesc') }}</p>
|
||||
</div>
|
||||
<div class="flex items-center 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>
|
||||
<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 -->
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
/>
|
||||
<p v-if="amountError" class="mt-2 text-xs text-amber-600 dark:text-amber-300">{{ amountError }}</p>
|
||||
</div>
|
||||
<div v-if="enabledMethods.length > 1" class="card p-6">
|
||||
<div v-if="enabledMethods.length >= 1" class="card p-6">
|
||||
<PaymentMethodSelector
|
||||
:methods="methodOptions"
|
||||
:selected="selectedMethod"
|
||||
@@ -41,15 +41,15 @@
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.amountLabel') }}</span>
|
||||
<span class="text-gray-900 dark:text-white">\u00A5{{ validAmount.toFixed(2) }}</span>
|
||||
<span class="text-gray-900 dark:text-white">¥{{ validAmount.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.fee') }} ({{ feeRate }}%)</span>
|
||||
<span class="text-gray-900 dark:text-white">\u00A5{{ feeAmount.toFixed(2) }}</span>
|
||||
<span class="text-gray-900 dark:text-white">¥{{ feeAmount.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-t border-gray-200 pt-2 dark:border-dark-600">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.actualPay') }}</span>
|
||||
<span class="text-lg font-bold text-primary-600 dark:text-primary-400">\u00A5{{ totalAmount.toFixed(2) }}</span>
|
||||
<span class="text-lg font-bold text-primary-600 dark:text-primary-400">¥{{ totalAmount.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,7 +58,7 @@
|
||||
<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.createOrder') }} \u00A5{{ (feeRate > 0 && validAmount > 0 ? totalAmount : validAmount).toFixed(2) }}</span>
|
||||
<span v-else>{{ t('payment.createOrder') }} ¥{{ (feeRate > 0 && validAmount > 0 ? totalAmount : validAmount).toFixed(2) }}</span>
|
||||
</button>
|
||||
<div v-if="errorMessage" class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20">
|
||||
<p class="text-sm text-red-700 dark:text-red-400">{{ errorMessage }}</p>
|
||||
@@ -84,7 +84,7 @@
|
||||
<div v-if="selectedPlan" class="space-y-4">
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-800">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ selectedPlan.name }}</p>
|
||||
<p class="mt-1 text-2xl font-bold text-primary-600 dark:text-primary-400">\u00A5{{ selectedPlan.price }}</p>
|
||||
<p class="mt-1 text-2xl font-bold text-primary-600 dark:text-primary-400">¥{{ selectedPlan.price }}</p>
|
||||
<p v-if="selectedPlan.description" class="mt-1 text-xs text-gray-500 dark:text-dark-400">{{ selectedPlan.description }}</p>
|
||||
</div>
|
||||
<PaymentMethodSelector
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-4">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">{{ t('payment.orders.title') }}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="fetchOrders" :disabled="loading" class="btn btn-secondary" :title="t('common.refresh')">
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
<button class="btn btn-primary" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card p-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Select v-model="currentFilter" :options="statusFilters" class="w-36" @change="fetchOrders" />
|
||||
<div class="flex flex-1 items-center justify-end gap-2">
|
||||
<button @click="fetchOrders" :disabled="loading" class="btn btn-secondary" :title="t('common.refresh')">
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
<button class="btn btn-primary" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user