fix(payment): inject paymentMode into provider config for correct redirect

The EasyPay provider reads paymentMode from e.config["paymentMode"] to decide
between redirect (submit.php) and API (mapi.php) paths. However, paymentMode
is stored as a separate DB column, not inside the encrypted config JSON.
SelectInstance() returned it in the InstanceSelection struct but never injected
it into the Config map, so the provider always fell through to the API/QR path.

Also includes:
- Subscription success flow: show "订阅成功" and refresh on return
- Active subscription card: platform colors, rate/quota display
- Subscription plan card: compact layout to save height
- Provider list: drag-and-drop reorder via sort_order
This commit is contained in:
erio
2026-04-09 01:13:16 +08:00
parent 47021dae14
commit 4f88ee637a
9 changed files with 159 additions and 88 deletions
@@ -86,6 +86,11 @@ func (lb *DefaultLoadBalancer) SelectInstance(ctx context.Context, providerKey s
return nil, fmt.Errorf("decrypt instance %d config: %w", selected.ID, err)
}
// Inject payment_mode into config so providers can read it uniformly
if selected.PaymentMode != "" {
config["paymentMode"] = selected.PaymentMode
}
return &InstanceSelection{
InstanceID: fmt.Sprintf("%d", selected.ID),
Config: config,
@@ -40,23 +40,37 @@
<div class="h-5 w-5 animate-spin rounded-full border-2 border-primary-500 border-t-transparent" />
</div>
<!-- Provider cards -->
<div v-else-if="providers.length" class="space-y-3">
<ProviderCard
v-for="p in providers"
:key="p.id"
:provider="p"
:enabled="isEnabled(p.provider_key)"
:available-types="getTypes(p.provider_key)"
@toggle-field="(field) => emit('toggleField', p, field)"
@toggle-type="(type) => emit('toggleType', p, type)"
@edit="emit('edit', p)"
@delete="emit('delete', p)"
/>
</div>
<!-- Provider cards (draggable) -->
<VueDraggable
v-if="providers.length"
v-model="localProviders"
:animation="200"
handle=".drag-handle"
class="space-y-3"
@end="onDragEnd"
>
<div v-for="p in localProviders" :key="p.id" class="flex items-start gap-2">
<div class="drag-handle mt-3 flex cursor-grab items-center text-gray-300 hover:text-gray-500 active:cursor-grabbing dark:text-dark-600 dark:hover:text-dark-400">
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M7 2a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM13 2a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM7 8a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM13 8a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM7 14a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM13 14a2 2 0 1 0 0 4 2 2 0 0 0 0-4z"/>
</svg>
</div>
<div class="min-w-0 flex-1">
<ProviderCard
:provider="p"
:enabled="isEnabled(p.provider_key)"
:available-types="getTypes(p.provider_key)"
@toggle-field="(field) => emit('toggleField', p, field)"
@toggle-type="(type) => emit('toggleType', p, type)"
@edit="emit('edit', p)"
@delete="emit('delete', p)"
/>
</div>
</div>
</VueDraggable>
<!-- Empty -->
<div v-else class="py-6 text-center">
<div v-else-if="!loading" class="py-6 text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ canCreate
? t('admin.settings.payment.noProviders')
@@ -75,7 +89,9 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { VueDraggable } from 'vue-draggable-plus'
import Icon from '@/components/icons/Icon.vue'
import ProviderCard from './ProviderCard.vue'
import type { ProviderInstance } from '@/types/payment'
@@ -98,10 +114,25 @@ const emit = defineEmits<{
delete: [provider: ProviderInstance]
toggleField: [provider: ProviderInstance, field: 'enabled' | 'refund_enabled']
toggleType: [provider: ProviderInstance, type: string]
reorder: [providers: { id: number; sort_order: number }[]]
}>()
const { t } = useI18n()
const localProviders = ref<ProviderInstance[]>([])
watch(() => props.providers, (val) => {
localProviders.value = [...val]
}, { immediate: true })
function onDragEnd() {
const updates = localProviders.value.map((p, idx) => ({
id: p.id,
sort_order: idx,
}))
emit('reorder', updates)
}
function isEnabled(providerKey: string): boolean {
const enabled = props.enabledPaymentTypes
if (providerKey === 'easypay') {
@@ -7,7 +7,7 @@
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
<Icon name="check" size="lg" class="text-green-500" />
</div>
<p class="text-lg font-bold text-gray-900 dark:text-white">{{ t('payment.result.success') }}</p>
<p class="text-lg font-bold text-gray-900 dark:text-white">{{ props.orderType === 'subscription' ? t('payment.result.subscriptionSuccess') : t('payment.result.success') }}</p>
<div v-if="paidOrder" class="w-full rounded-xl bg-gray-50 p-4 dark:bg-dark-800">
<div class="space-y-2 text-sm">
<div class="flex justify-between">
@@ -98,6 +98,7 @@ const props = defineProps<{
expiresAt: string
paymentType: string
payUrl?: string
orderType?: string
}>()
const emit = defineEmits<{ done: []; success: [] }>()
@@ -10,63 +10,56 @@
<!-- Colored top accent bar -->
<div :class="['h-1.5', accentClass]" />
<div class="flex flex-1 flex-col p-5">
<!-- Header: name + platform badge -->
<div class="mb-4">
<div class="flex items-center gap-2">
<h3 class="text-base font-bold text-gray-900 dark:text-white">{{ plan.name }}</h3>
<span :class="['rounded-full px-2 py-0.5 text-[11px] font-medium', badgeLightClass]">
{{ pLabel }}
</span>
<div class="flex flex-1 flex-col p-4">
<!-- Header: name + badge + price -->
<div class="mb-3 flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h3 class="truncate text-base font-bold text-gray-900 dark:text-white">{{ plan.name }}</h3>
<span :class="['shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium', badgeLightClass]">
{{ pLabel }}
</span>
</div>
<p v-if="plan.description" class="mt-0.5 text-xs leading-relaxed text-gray-500 dark:text-dark-400 line-clamp-2">
{{ plan.description }}
</p>
</div>
<p v-if="plan.group_name" class="mt-1 text-xs text-gray-400 dark:text-dark-500">
{{ plan.group_name }}
</p>
<p v-if="plan.description" class="mt-1.5 text-sm leading-relaxed text-gray-500 dark:text-dark-400">
{{ plan.description }}
</p>
</div>
<!-- Price section -->
<div class="mb-4">
<div class="flex items-baseline gap-1.5">
<span class="text-sm text-gray-400 dark:text-dark-500">&yen;</span>
<span :class="['text-4xl font-extrabold tracking-tight', textClass]">{{ plan.price }}</span>
<span class="text-sm text-gray-400 dark:text-dark-500">/ {{ validitySuffix }}</span>
</div>
<div v-if="plan.original_price" class="mt-1 flex items-center gap-2">
<span class="text-sm text-gray-400 line-through dark:text-dark-500">
&yen;{{ plan.original_price }}
</span>
<span :class="['rounded px-1.5 py-0.5 text-[11px] font-semibold', discountClass]">
{{ discountText }}
</span>
<div class="shrink-0 text-right">
<div class="flex items-baseline gap-1">
<span class="text-xs text-gray-400 dark:text-dark-500">&yen;</span>
<span :class="['text-2xl font-extrabold tracking-tight', textClass]">{{ plan.price }}</span>
</div>
<span class="text-[11px] text-gray-400 dark:text-dark-500">/ {{ validitySuffix }}</span>
<div v-if="plan.original_price" class="mt-0.5 flex items-center justify-end gap-1.5">
<span class="text-xs text-gray-400 line-through dark:text-dark-500">&yen;{{ plan.original_price }}</span>
<span :class="['rounded px-1 py-0.5 text-[10px] font-semibold', discountClass]">{{ discountText }}</span>
</div>
</div>
</div>
<!-- Group quota info -->
<div class="mb-4 space-y-1.5 rounded-lg bg-gray-50 px-3 py-2.5 dark:bg-dark-700/50">
<div class="flex items-center justify-between text-xs">
<!-- Group quota info (compact) -->
<div class="mb-3 grid grid-cols-2 gap-x-3 gap-y-1 rounded-lg bg-gray-50 px-3 py-2 text-xs dark:bg-dark-700/50">
<div class="flex items-center justify-between">
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.rate') }}</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ rateDisplay }}</span>
</div>
<div v-if="plan.daily_limit_usd != null" class="flex items-center justify-between text-xs">
<div v-if="plan.daily_limit_usd != null" class="flex items-center justify-between">
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.dailyLimit') }}</span>
<span class="font-medium text-gray-700 dark:text-gray-300">${{ plan.daily_limit_usd }}</span>
</div>
<div v-if="plan.weekly_limit_usd != null" class="flex items-center justify-between text-xs">
<div v-if="plan.weekly_limit_usd != null" class="flex items-center justify-between">
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.weeklyLimit') }}</span>
<span class="font-medium text-gray-700 dark:text-gray-300">${{ plan.weekly_limit_usd }}</span>
</div>
<div v-if="plan.monthly_limit_usd != null" class="flex items-center justify-between text-xs">
<div v-if="plan.monthly_limit_usd != null" class="flex items-center justify-between">
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.monthlyLimit') }}</span>
<span class="font-medium text-gray-700 dark:text-gray-300">${{ plan.monthly_limit_usd }}</span>
</div>
<div v-if="plan.daily_limit_usd == null && plan.weekly_limit_usd == null && plan.monthly_limit_usd == null" class="flex items-center justify-between text-xs">
<div v-if="plan.daily_limit_usd == null && plan.weekly_limit_usd == null && plan.monthly_limit_usd == null" class="flex items-center justify-between">
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.quota') }}</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.planCard.unlimited') }}</span>
</div>
<div v-if="modelScopeLabels.length > 0" class="flex items-center justify-between text-xs">
<div v-if="modelScopeLabels.length > 0" class="col-span-2 flex items-center justify-between">
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.models') }}</span>
<div class="flex flex-wrap justify-end gap-1">
<span v-for="scope in modelScopeLabels" :key="scope"
@@ -77,25 +70,18 @@
</div>
</div>
<!-- Features list with checkmarks -->
<div v-if="plan.features.length > 0" class="mb-4 space-y-2">
<div v-for="feature in plan.features" :key="feature" class="flex items-start gap-2">
<svg :class="['mt-0.5 h-4 w-4 flex-shrink-0', iconClass]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<!-- Features list (compact) -->
<div v-if="plan.features.length > 0" class="mb-3 space-y-1">
<div v-for="feature in plan.features" :key="feature" class="flex items-start gap-1.5">
<svg :class="['mt-0.5 h-3.5 w-3.5 flex-shrink-0', iconClass]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
<span class="text-sm text-gray-600 dark:text-gray-300">{{ feature }}</span>
<span class="text-xs text-gray-600 dark:text-gray-300">{{ feature }}</span>
</div>
</div>
<div class="flex-1" />
<!-- Validity badge -->
<div class="mb-3 text-center">
<span :class="['rounded-full px-3 py-1 text-xs font-medium', badgeLightClass]">
{{ validityLabel }}
</span>
</div>
<!-- Subscribe Button -->
<button
type="button"
@@ -162,14 +148,6 @@ const modelScopeLabels = computed(() => {
return scopes.map(s => MODEL_SCOPE_LABELS[s] || s)
})
const validityLabel = computed(() => {
const d = props.plan.validity_days
const u = props.plan.validity_unit || 'day'
if (u === 'month') return d === 1 ? t('payment.oneMonth') : `${d} ${t('payment.months')}`
if (u === 'year') return d === 1 ? t('payment.oneYear') : `${d} ${t('payment.years')}`
return `${d} ${t('payment.days')}`
})
const validitySuffix = computed(() => {
const u = props.plan.validity_unit || 'day'
if (u === 'month') return t('payment.perMonth')
+1
View File
@@ -5131,6 +5131,7 @@ export default {
},
result: {
success: 'Payment Successful',
subscriptionSuccess: 'Subscription Successful',
failed: 'Payment Failed',
backToRecharge: 'Back to Recharge',
viewOrders: 'View Orders',
+1
View File
@@ -5328,6 +5328,7 @@ export default {
},
result: {
success: '支付成功',
subscriptionSuccess: '订阅成功',
failed: '支付失败',
backToRecharge: '返回充值',
viewOrders: '查看订单',
+1
View File
@@ -135,6 +135,7 @@ export interface ProviderInstance {
payment_mode: string
refund_enabled: boolean
limits: string
sort_order: number
}
// ==================== Request / Response ====================
+17
View File
@@ -1746,6 +1746,7 @@
@delete="confirmDeleteProvider"
@toggle-field="handleToggleField"
@toggle-type="handleToggleType"
@reorder="handleReorderProviders"
/>
</div>
@@ -2957,6 +2958,22 @@ function confirmDeleteProvider(provider: ProviderInstance) {
showDeleteProviderDialog.value = true
}
async function handleReorderProviders(updates: { id: number; sort_order: number }[]) {
try {
await Promise.all(
updates.map(u => adminAPI.payment.updateProvider(u.id, { sort_order: u.sort_order } as Partial<ProviderInstance>))
)
// Update local state to match new order
for (const u of updates) {
const p = providers.value.find(p => p.id === u.id)
if (p) p.sort_order = u.sort_order
}
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
loadProviders()
}
}
async function handleDeleteProvider() {
if (!deletingProviderId.value) return
try {
+50 -14
View File
@@ -20,7 +20,8 @@
:expires-at="paymentState.expiresAt"
:payment-type="paymentState.paymentType"
:pay-url="paymentState.payUrl"
@done="resetPayment"
:order-type="paymentState.orderType"
@done="onPaymentDone"
@success="onPaymentSuccess"
/>
</template>
@@ -96,17 +97,35 @@
<!-- Subscribe Tab -->
<template v-else-if="activeTab === 'subscription'">
<!-- Active Subscription Card -->
<div v-if="activeSubscriptions.length > 0" class="card p-5">
<div v-if="activeSubscriptions.length > 0" class="space-y-3">
<p class="text-xs font-medium text-gray-400 dark:text-gray-500">{{ t('payment.activeSubscription') }}</p>
<div v-for="sub in activeSubscriptions" :key="sub.id" class="mt-2 flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-gray-900 dark:text-white">{{ sub.group?.name || `Group #${sub.group_id}` }}</p>
<p v-if="sub.expires_at" class="text-xs text-gray-500 dark:text-gray-400">
{{ t('userSubscriptions.daysRemaining', { days: getDaysRemaining(sub.expires_at) }) }}
</p>
<p v-else class="text-xs text-gray-500 dark:text-gray-400">{{ t('userSubscriptions.noExpiration') }}</p>
<div v-for="sub in activeSubscriptions" :key="sub.id"
class="card overflow-hidden">
<div :class="['h-1', platformAccentBarClass(sub.group?.platform || '')]" />
<div class="p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ sub.group?.name || `Group #${sub.group_id}` }}
</h4>
<span :class="['rounded-full px-2 py-0.5 text-[10px] font-medium', platformBadgeLightClass(sub.group?.platform || '')]">
{{ platformLabel(sub.group?.platform || '') }}
</span>
</div>
<span class="badge badge-success text-xs">{{ t('userSubscriptions.status.active') }}</span>
</div>
<div class="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
<span>{{ t('payment.planCard.rate') }}: ×{{ sub.group?.rate_multiplier ?? 1 }}</span>
<span v-if="sub.group?.daily_limit_usd != null">{{ t('payment.planCard.dailyLimit') }}: ${{ sub.group.daily_limit_usd }}</span>
<span v-if="sub.group?.weekly_limit_usd != null">{{ t('payment.planCard.weeklyLimit') }}: ${{ sub.group.weekly_limit_usd }}</span>
<span v-if="sub.group?.monthly_limit_usd != null">{{ t('payment.planCard.monthlyLimit') }}: ${{ sub.group.monthly_limit_usd }}</span>
<span v-if="!sub.group?.daily_limit_usd && !sub.group?.weekly_limit_usd && !sub.group?.monthly_limit_usd">{{ t('payment.planCard.quota') }}: {{ t('payment.planCard.unlimited') }}</span>
</div>
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<span v-if="sub.expires_at">{{ t('userSubscriptions.daysRemaining', { days: getDaysRemaining(sub.expires_at) }) }}</span>
<span v-else>{{ t('userSubscriptions.noExpiration') }}</span>
</div>
</div>
<span class="badge badge-success text-xs">{{ t('userSubscriptions.status.active') }}</span>
</div>
</div>
<div v-else class="card p-5">
@@ -207,6 +226,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
import AmountInput from '@/components/payment/AmountInput.vue'
import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue'
import { METHOD_ORDER, POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
import { platformAccentBarClass, platformBadgeLightClass, platformLabel } from '@/utils/platformColors'
import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue'
import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue'
import StripePaymentInline from '@/components/payment/StripePaymentInline.vue'
@@ -238,21 +258,34 @@ const previewImage = ref('')
// Payment phase: 'select' → 'paying' (QR/redirect) or 'stripe' (inline Stripe)
const paymentPhase = ref<'select' | 'paying' | 'stripe'>('select')
const paymentState = ref({ orderId: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '', clientSecret: '', payAmount: 0 })
const paymentState = ref({ orderId: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '', clientSecret: '', payAmount: 0, orderType: '' })
function resetPayment() {
paymentPhase.value = 'select'
paymentState.value = { orderId: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '', clientSecret: '', payAmount: 0 }
paymentState.value = { orderId: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '', clientSecret: '', payAmount: 0, orderType: '' }
}
function onPaymentDone() {
const wasSubscription = paymentState.value.orderType === 'subscription'
resetPayment()
selectedPlan.value = null
if (wasSubscription) {
subscriptionStore.fetchActiveSubscriptions(true).catch(() => {})
}
}
function onPaymentSuccess() {
authStore.refreshUser()
resetPayment()
selectedPlan.value = null
if (paymentState.value.orderType === 'subscription') {
subscriptionStore.fetchActiveSubscriptions(true).catch(() => {})
}
}
function onStripeSuccess() {
authStore.refreshUser()
if (paymentState.value.orderType === 'subscription') {
subscriptionStore.fetchActiveSubscriptions(true).catch(() => {})
}
resetPayment()
selectedPlan.value = null
}
@@ -424,6 +457,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
orderId: result.order_id, qrCode: '', expiresAt: '',
paymentType: selectedMethod.value, payUrl: '',
clientSecret: result.client_secret, payAmount: result.pay_amount,
orderType,
}
paymentPhase.value = 'stripe'
} else if (result.qr_code) {
@@ -432,6 +466,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
orderId: result.order_id, qrCode: result.qr_code,
expiresAt: result.expires_at || '', paymentType: selectedMethod.value, payUrl: '',
clientSecret: '', payAmount: 0,
orderType,
}
paymentPhase.value = 'paying'
} else if (result.pay_url) {
@@ -441,6 +476,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
orderId: result.order_id, qrCode: '', expiresAt: result.expires_at || '',
paymentType: selectedMethod.value, payUrl: result.pay_url,
clientSecret: '', payAmount: 0,
orderType,
}
paymentPhase.value = 'paying'
} else {