feat(payment): add complete payment system with multi-provider support
Add a full payment and subscription system supporting EasyPay (Alipay/WeChat), Stripe, and direct Alipay/WeChat Pay providers with multi-instance load balancing.
This commit is contained in:
111
frontend/src/components/payment/AmountInput.vue
Normal file
111
frontend/src/components/payment/AmountInput.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Quick Amount Buttons -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('payment.quickAmounts') }}
|
||||
</label>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
v-for="amt in filteredAmounts"
|
||||
:key="amt"
|
||||
type="button"
|
||||
:class="[
|
||||
'rounded-lg border-2 px-4 py-3 text-center font-medium transition-colors',
|
||||
modelValue === amt
|
||||
? 'border-primary-500 bg-primary-50 text-primary-700 dark:border-primary-400 dark:bg-primary-900/40 dark:text-primary-300'
|
||||
: 'border-gray-200 bg-white text-gray-700 hover:border-gray-300 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-200 dark:hover:border-dark-500',
|
||||
]"
|
||||
@click="selectAmount(amt)"
|
||||
>
|
||||
{{ amt }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Amount Input -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('payment.customAmount') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-dark-500">
|
||||
$
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
:value="customText"
|
||||
:placeholder="placeholderText"
|
||||
class="input w-full py-3 pl-8 pr-4"
|
||||
@input="handleInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
amounts?: number[]
|
||||
modelValue: number | null
|
||||
min?: number
|
||||
max?: number
|
||||
}>(), {
|
||||
amounts: () => [10, 20, 50, 100, 200, 500, 1000, 2000, 5000],
|
||||
min: 0,
|
||||
max: 0,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number | null]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const customText = ref('')
|
||||
|
||||
// 0 = no limit
|
||||
const filteredAmounts = computed(() =>
|
||||
props.amounts.filter((a) => (props.min <= 0 || a >= props.min) && (props.max <= 0 || a <= props.max))
|
||||
)
|
||||
|
||||
const placeholderText = computed(() => {
|
||||
if (props.min > 0 && props.max > 0) return `${props.min} - ${props.max}`
|
||||
if (props.min > 0) return `≥ ${props.min}`
|
||||
if (props.max > 0) return `≤ ${props.max}`
|
||||
return t('payment.enterAmount')
|
||||
})
|
||||
|
||||
const AMOUNT_PATTERN = /^\d*(\.\d{0,2})?$/
|
||||
|
||||
function selectAmount(amt: number) {
|
||||
customText.value = String(amt)
|
||||
emit('update:modelValue', amt)
|
||||
}
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const val = (e.target as HTMLInputElement).value
|
||||
if (!AMOUNT_PATTERN.test(val)) return
|
||||
customText.value = val
|
||||
if (val === '') {
|
||||
emit('update:modelValue', null)
|
||||
return
|
||||
}
|
||||
const num = parseFloat(val)
|
||||
if (!isNaN(num) && num > 0) {
|
||||
emit('update:modelValue', num)
|
||||
} else {
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (v) => {
|
||||
if (v !== null && String(v) !== customText.value) {
|
||||
customText.value = String(v)
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
45
frontend/src/components/payment/OrderStatusBadge.vue
Normal file
45
frontend/src/components/payment/OrderStatusBadge.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
|
||||
:class="statusClass"
|
||||
>
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { OrderStatus } from '@/types/payment'
|
||||
|
||||
const props = defineProps<{
|
||||
status: OrderStatus
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const statusMap: Record<OrderStatus, { key: string; class: string }> = {
|
||||
PENDING: { key: 'payment.status.pending', class: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400' },
|
||||
PAID: { key: 'payment.status.paid', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400' },
|
||||
RECHARGING: { key: 'payment.status.recharging', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400' },
|
||||
COMPLETED: { key: 'payment.status.completed', class: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' },
|
||||
EXPIRED: { key: 'payment.status.expired', class: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400' },
|
||||
CANCELLED: { key: 'payment.status.cancelled', class: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400' },
|
||||
FAILED: { key: 'payment.status.failed', class: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' },
|
||||
REFUND_REQUESTED: { key: 'payment.status.refund_requested', class: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400' },
|
||||
REFUNDING: { key: 'payment.status.refunding', class: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400' },
|
||||
REFUNDED: { key: 'payment.status.refunded', class: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
PARTIALLY_REFUNDED: { key: 'payment.status.partially_refunded', class: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
REFUND_FAILED: { key: 'payment.status.refund_failed', class: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' },
|
||||
}
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
const entry = statusMap[props.status]
|
||||
return entry ? t(entry.key) : props.status
|
||||
})
|
||||
|
||||
const statusClass = computed(() => {
|
||||
const entry = statusMap[props.status]
|
||||
return entry?.class ?? 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400'
|
||||
})
|
||||
</script>
|
||||
71
frontend/src/components/payment/OrderTable.vue
Normal file
71
frontend/src/components/payment/OrderTable.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<DataTable :columns="columns" :data="orders" :loading="loading">
|
||||
<template #cell-id="{ value }">
|
||||
<span class="font-mono text-sm">#{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-out_trade_no="{ value }">
|
||||
<span class="text-sm text-gray-900 dark:text-white">{{ value }}</span>
|
||||
</template>
|
||||
<template v-if="showUser" #cell-user_email="{ value, row }">
|
||||
<div class="text-sm">
|
||||
<span class="text-gray-900 dark:text-white">{{ value || row.user_name || '#' + row.user_id }}</span>
|
||||
<span v-if="row.user_notes" class="ml-1 text-xs text-gray-400">({{ row.user_notes }})</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-amount="{ value, row }">
|
||||
<div class="text-sm">
|
||||
<span class="font-medium text-gray-900 dark:text-white">${{ value.toFixed(2) }}</span>
|
||||
<span v-if="row.pay_amount !== value" class="ml-1 text-xs text-gray-500">(${{ row.pay_amount.toFixed(2) }})</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-payment_type="{ value }">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('payment.methods.' + value, value) }}</span>
|
||||
</template>
|
||||
<template #cell-status="{ value }">
|
||||
<OrderStatusBadge :status="value" />
|
||||
</template>
|
||||
<template #cell-created_at="{ value }">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ formatDate(value) }}</span>
|
||||
</template>
|
||||
<template #cell-actions="{ row }">
|
||||
<slot name="actions" :row="row" />
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { PaymentOrder } from '@/types/payment'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import OrderStatusBadge from '@/components/payment/OrderStatusBadge.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
orders: PaymentOrder[]
|
||||
loading: boolean
|
||||
showUser?: boolean
|
||||
}>()
|
||||
|
||||
function formatDate(dateStr: string) { return new Date(dateStr).toLocaleString() }
|
||||
|
||||
const columns = computed((): Column[] => {
|
||||
const cols: Column[] = [
|
||||
{ key: 'id', label: t('payment.orders.orderId') },
|
||||
{ key: 'out_trade_no', label: t('payment.orders.orderNo') },
|
||||
]
|
||||
if (props.showUser) {
|
||||
cols.push({ key: 'user_email', label: t('payment.admin.colUser') })
|
||||
}
|
||||
cols.push(
|
||||
{ key: 'amount', label: t('payment.orders.amount') },
|
||||
{ key: 'payment_type', label: t('payment.orders.paymentMethod') },
|
||||
{ key: 'status', label: t('payment.orders.status') },
|
||||
{ key: 'created_at', label: t('payment.orders.createdAt') },
|
||||
{ key: 'actions', label: t('common.actions') },
|
||||
)
|
||||
return cols
|
||||
})
|
||||
</script>
|
||||
91
frontend/src/components/payment/PaymentMethodSelector.vue
Normal file
91
frontend/src/components/payment/PaymentMethodSelector.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('payment.paymentMethod') }}
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-3 sm:flex">
|
||||
<button
|
||||
v-for="method in sortedMethods"
|
||||
:key="method.type"
|
||||
type="button"
|
||||
:disabled="!method.available"
|
||||
:class="[
|
||||
'relative flex h-[60px] flex-col items-center justify-center rounded-lg border px-3 transition-all sm:flex-1',
|
||||
!method.available
|
||||
? 'cursor-not-allowed border-gray-200 bg-gray-50 opacity-50 dark:border-dark-700 dark:bg-dark-800/50'
|
||||
: selected === method.type
|
||||
? methodSelectedClass(method.type)
|
||||
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-200 dark:hover:border-dark-500',
|
||||
]"
|
||||
@click="method.available && emit('select', method.type)"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<img :src="methodIcon(method.type)" :alt="t(`payment.methods.${method.type}`)" class="h-7 w-7" />
|
||||
<span class="flex flex-col items-start leading-none">
|
||||
<span class="text-base font-semibold">{{ t(`payment.methods.${method.type}`) }}</span>
|
||||
<span
|
||||
v-if="method.fee_rate > 0"
|
||||
class="text-[10px] tracking-wide text-gray-500 dark:text-dark-400"
|
||||
>
|
||||
{{ t('payment.fee') }} {{ method.fee_rate }}%
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { METHOD_ORDER } from './providerConfig'
|
||||
import alipayIcon from '@/assets/icons/alipay.svg'
|
||||
import wxpayIcon from '@/assets/icons/wxpay.svg'
|
||||
import stripeIcon from '@/assets/icons/stripe.svg'
|
||||
|
||||
export interface PaymentMethodOption {
|
||||
type: string
|
||||
fee_rate: number
|
||||
available: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
methods: PaymentMethodOption[]
|
||||
selected: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [type: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const METHOD_ICONS: Record<string, string> = {
|
||||
alipay: alipayIcon,
|
||||
wxpay: wxpayIcon,
|
||||
stripe: stripeIcon,
|
||||
}
|
||||
|
||||
const sortedMethods = computed(() => {
|
||||
const order: readonly string[] = METHOD_ORDER
|
||||
return [...props.methods].sort((a, b) => {
|
||||
const ai = order.indexOf(a.type)
|
||||
const bi = order.indexOf(b.type)
|
||||
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi)
|
||||
})
|
||||
})
|
||||
|
||||
function methodIcon(type: string): string {
|
||||
if (type.includes('alipay')) return METHOD_ICONS.alipay
|
||||
if (type.includes('wxpay')) return METHOD_ICONS.wxpay
|
||||
return METHOD_ICONS[type] || alipayIcon
|
||||
}
|
||||
|
||||
function methodSelectedClass(type: string): string {
|
||||
if (type.includes('alipay')) return 'border-[#02A9F1] bg-blue-50 text-gray-900 shadow-sm dark:bg-blue-950 dark:text-gray-100'
|
||||
if (type.includes('wxpay')) return 'border-[#09BB07] bg-green-50 text-gray-900 shadow-sm dark:bg-green-950 dark:text-gray-100'
|
||||
if (type === 'stripe') return 'border-[#676BE5] bg-indigo-50 text-gray-900 shadow-sm dark:bg-indigo-950 dark:text-gray-100'
|
||||
return 'border-primary-500 bg-primary-50 text-gray-900 shadow-sm dark:bg-primary-950 dark:text-gray-100'
|
||||
}
|
||||
</script>
|
||||
497
frontend/src/components/payment/PaymentProviderDialog.vue
Normal file
497
frontend/src/components/payment/PaymentProviderDialog.vue
Normal file
@@ -0,0 +1,497 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="editing ? t('admin.settings.payment.editProvider') : t('admin.settings.payment.createProvider')"
|
||||
width="wide"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<form id="provider-form" @submit.prevent="handleSave" class="space-y-4">
|
||||
<!-- Name + Key -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">
|
||||
{{ t('admin.settings.payment.providerName') }}
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input v-model="form.name" type="text" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">
|
||||
{{ t('admin.settings.payment.providerKey') }}
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<Select
|
||||
v-model="form.provider_key"
|
||||
:options="(!!editing ? allKeyOptions : enabledKeyOptions) as SelectOption[]"
|
||||
:disabled="!!editing"
|
||||
@change="onKeyChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toggles + Payment mode + Supported types (single row) -->
|
||||
<div class="flex flex-wrap items-center gap-x-5 gap-y-2">
|
||||
<ToggleSwitch :label="t('common.enabled')" :checked="form.enabled" @toggle="form.enabled = !form.enabled" />
|
||||
<ToggleSwitch :label="t('admin.settings.payment.refundEnabled')" :checked="form.refund_enabled" @toggle="form.refund_enabled = !form.refund_enabled" />
|
||||
<div v-if="form.provider_key === 'easypay'" class="flex items-center gap-2">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.paymentMode') }}</span>
|
||||
<div class="flex gap-1.5">
|
||||
<button
|
||||
v-for="mode in paymentModeOptions"
|
||||
:key="mode.value"
|
||||
type="button"
|
||||
@click="form.payment_mode = mode.value"
|
||||
:class="[
|
||||
'rounded-lg border px-2.5 py-1 text-xs font-medium transition-all',
|
||||
form.payment_mode === mode.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',
|
||||
]"
|
||||
>{{ mode.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="availableTypes.length > 1" class="flex items-center gap-2">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.supportedTypes') }}</span>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="pt in availableTypes"
|
||||
:key="pt.value"
|
||||
type="button"
|
||||
@click="toggleType(pt.value)"
|
||||
:class="[
|
||||
'rounded-lg border px-2.5 py-1 text-xs font-medium transition-all',
|
||||
isTypeSelected(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>
|
||||
|
||||
|
||||
<!-- Config fields -->
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-700">
|
||||
<h4 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.payment.providerConfig') }}
|
||||
</h4>
|
||||
<div class="space-y-3">
|
||||
<div v-for="field in resolvedFields" :key="field.key">
|
||||
<label class="input-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.optional" class="text-xs text-gray-400">({{ t('common.optional') }})</span>
|
||||
<span v-else class="text-red-500"> *</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-if="field.sensitive && field.key.toLowerCase().includes('key') && field.key !== 'pkey'"
|
||||
v-model="config[field.key]"
|
||||
rows="3"
|
||||
class="input font-mono text-xs"
|
||||
/>
|
||||
<div v-else-if="field.sensitive" class="relative">
|
||||
<input
|
||||
:type="visibleFields[field.key] ? 'text' : 'password'"
|
||||
v-model="config[field.key]"
|
||||
class="input pr-10"
|
||||
:placeholder="field.defaultValue || ''"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="visibleFields[field.key] = !visibleFields[field.key]"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg v-if="visibleFields[field.key]" class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" /></svg>
|
||||
<svg v-else class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-else
|
||||
type="text"
|
||||
v-model="config[field.key]"
|
||||
class="input"
|
||||
:placeholder="field.defaultValue || ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Callback URLs (each = editable URL + fixed path) -->
|
||||
<div v-if="callbackPaths" class="mt-4 space-y-3">
|
||||
<div v-if="callbackPaths.notifyUrl">
|
||||
<label class="input-label">{{ t('admin.settings.payment.field_notifyUrl') }} <span class="text-red-500">*</span></label>
|
||||
<div class="flex">
|
||||
<input v-model="notifyBaseUrl" type="text" class="input min-w-0 flex-1 !rounded-r-none !border-r-0" :placeholder="defaultBaseUrl" />
|
||||
<span class="inline-flex items-center whitespace-nowrap rounded-r-lg border border-gray-300 bg-gray-50 px-3 text-xs text-gray-500 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-400">{{ callbackPaths.notifyUrl }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="callbackPaths.returnUrl">
|
||||
<label class="input-label">{{ t('admin.settings.payment.field_returnUrl') }} <span class="text-red-500">*</span></label>
|
||||
<div class="flex">
|
||||
<input v-model="returnBaseUrl" type="text" class="input min-w-0 flex-1 !rounded-r-none !border-r-0" :placeholder="defaultBaseUrl" />
|
||||
<span class="inline-flex items-center whitespace-nowrap rounded-r-lg border border-gray-300 bg-gray-50 px-3 text-xs text-gray-500 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-400">{{ callbackPaths.returnUrl }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stripe webhook hint -->
|
||||
<div v-if="stripeWebhookUrl" class="mt-3 rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-800/50 dark:bg-blue-900/20">
|
||||
<p class="text-xs text-blue-700 dark:text-blue-300">
|
||||
{{ t('admin.settings.payment.stripeWebhookHint') }}
|
||||
</p>
|
||||
<code class="mt-1 block break-all rounded bg-blue-100 px-2 py-1 text-xs text-blue-800 dark:bg-blue-900/40 dark:text-blue-200">
|
||||
{{ stripeWebhookUrl }}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-type limits (collapsible) -->
|
||||
<div v-if="limitableTypes.length" class="border-t border-gray-200 pt-4 dark:border-dark-700">
|
||||
<button type="button" @click="limitsExpanded = !limitsExpanded" class="flex w-full items-center justify-between">
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.payment.limitsTitle') }}
|
||||
</h4>
|
||||
<svg :class="['h-4 w-4 text-gray-400 transition-transform', limitsExpanded && 'rotate-180']" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
|
||||
</button>
|
||||
<div v-show="limitsExpanded" class="mt-3 space-y-3">
|
||||
<div
|
||||
v-for="lt in limitableTypes"
|
||||
:key="lt.value"
|
||||
class="rounded-lg border border-gray-100 p-3 dark:border-dark-700"
|
||||
>
|
||||
<p class="mb-2 text-xs font-medium text-gray-700 dark:text-gray-300">{{ lt.label }}</p>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.limitSingleMin') }}</label>
|
||||
<input
|
||||
type="number"
|
||||
:value="getLimitVal(lt.value, 'singleMin')"
|
||||
@input="setLimitVal(lt.value, 'singleMin', ($event.target as HTMLInputElement).value)"
|
||||
class="input mt-0.5" min="1" step="0.01" :placeholder="limitPlaceholder(lt.value)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.limitSingleMax') }}</label>
|
||||
<input
|
||||
type="number"
|
||||
:value="getLimitVal(lt.value, 'singleMax')"
|
||||
@input="setLimitVal(lt.value, 'singleMax', ($event.target as HTMLInputElement).value)"
|
||||
class="input mt-0.5" min="1" step="0.01" :placeholder="limitPlaceholder(lt.value)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.limitDaily') }}</label>
|
||||
<input
|
||||
type="number"
|
||||
:value="getLimitVal(lt.value, 'dailyLimit')"
|
||||
@input="setLimitVal(lt.value, 'dailyLimit', ($event.target as HTMLInputElement).value)"
|
||||
class="input mt-0.5" min="1" step="0.01" :placeholder="limitPlaceholder(lt.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">{{ t('admin.settings.payment.limitsHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" @click="emit('close')" class="btn btn-secondary">{{ t('common.cancel') }}</button>
|
||||
<button type="submit" form="provider-form" :disabled="saving" class="btn btn-primary">
|
||||
{{ saving ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import type { SelectOption } from '@/components/common/Select.vue'
|
||||
import ToggleSwitch from './ToggleSwitch.vue'
|
||||
import type { ProviderInstance } from '@/types/payment'
|
||||
import type { TypeOption } from './providerConfig'
|
||||
import {
|
||||
PROVIDER_CONFIG_FIELDS,
|
||||
PROVIDER_SUPPORTED_TYPES,
|
||||
PROVIDER_CALLBACK_PATHS,
|
||||
WEBHOOK_PATHS,
|
||||
PAYMENT_MODE_QRCODE,
|
||||
PAYMENT_MODE_POPUP,
|
||||
getAvailableTypes,
|
||||
extractBaseUrl,
|
||||
} from './providerConfig'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
saving: boolean
|
||||
editing: ProviderInstance | null
|
||||
allKeyOptions: TypeOption[]
|
||||
enabledKeyOptions: TypeOption[]
|
||||
allPaymentTypes: TypeOption[]
|
||||
redirectLabel: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
save: [payload: {
|
||||
provider_key: string
|
||||
name: string
|
||||
supported_types: string[]
|
||||
enabled: boolean
|
||||
payment_mode: string
|
||||
refund_enabled: boolean
|
||||
config: Record<string, string>
|
||||
limits: string
|
||||
}]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// --- Form state ---
|
||||
const form = reactive({
|
||||
name: '',
|
||||
provider_key: 'easypay',
|
||||
supported_types: [] as string[],
|
||||
enabled: true,
|
||||
payment_mode: PAYMENT_MODE_QRCODE,
|
||||
refund_enabled: false,
|
||||
})
|
||||
const config = reactive<Record<string, string>>({})
|
||||
const limits = reactive<Record<string, Record<string, number>>>({})
|
||||
const notifyBaseUrl = ref('')
|
||||
const returnBaseUrl = ref('')
|
||||
const limitsExpanded = ref(false)
|
||||
const visibleFields = reactive<Record<string, boolean>>({})
|
||||
|
||||
// --- Computed ---
|
||||
const defaultBaseUrl = typeof window !== 'undefined' ? window.location.origin : ''
|
||||
|
||||
const stripeWebhookUrl = computed(() =>
|
||||
form.provider_key === 'stripe' ? defaultBaseUrl + WEBHOOK_PATHS.stripe : '',
|
||||
)
|
||||
|
||||
const callbackPaths = computed(() => PROVIDER_CALLBACK_PATHS[form.provider_key] || null)
|
||||
|
||||
const paymentModeOptions = computed(() => {
|
||||
return [
|
||||
{ value: PAYMENT_MODE_QRCODE, label: t('admin.settings.payment.modeQRCode') },
|
||||
{ value: PAYMENT_MODE_POPUP, label: t('admin.settings.payment.modePopup') },
|
||||
]
|
||||
})
|
||||
|
||||
const availableTypes = computed(() => {
|
||||
const base = getAvailableTypes(form.provider_key, props.allPaymentTypes, props.redirectLabel)
|
||||
// Resolve i18n labels for types not in allPaymentTypes (e.g. card, link inside stripe)
|
||||
return base.map(opt =>
|
||||
opt.label === opt.value
|
||||
? { ...opt, label: t(`payment.methods.${opt.value}`, opt.value) }
|
||||
: opt,
|
||||
)
|
||||
})
|
||||
|
||||
const resolvedFields = computed(() => {
|
||||
const fields = PROVIDER_CONFIG_FIELDS[form.provider_key] || []
|
||||
return fields.map(f => ({
|
||||
...f,
|
||||
label: f.label || t(`admin.settings.payment.field_${f.key}`),
|
||||
}))
|
||||
})
|
||||
|
||||
const limitableTypes = computed(() => {
|
||||
// Stripe: single "stripe" entry (one set of shared limits)
|
||||
if (form.provider_key === 'stripe') {
|
||||
return [{ value: 'stripe', label: 'Stripe' }]
|
||||
}
|
||||
const selected = form.supported_types.filter(t => t !== 'easypay')
|
||||
return selected.map(v => {
|
||||
const found = props.allPaymentTypes.find(pt => pt.value === v)
|
||||
return found || { value: v, label: v }
|
||||
})
|
||||
})
|
||||
|
||||
// --- Methods ---
|
||||
function isTypeSelected(type: string): boolean {
|
||||
return form.supported_types.includes(type)
|
||||
}
|
||||
|
||||
function toggleType(type: string) {
|
||||
if (form.supported_types.includes(type)) {
|
||||
form.supported_types = form.supported_types.filter(t => t !== type)
|
||||
} else {
|
||||
form.supported_types = [...form.supported_types, type]
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyChange() {
|
||||
form.supported_types = [...(PROVIDER_SUPPORTED_TYPES[form.provider_key] || [])]
|
||||
clearConfig()
|
||||
applyDefaults()
|
||||
}
|
||||
|
||||
function clearConfig() {
|
||||
Object.keys(config).forEach(k => delete config[k])
|
||||
Object.keys(limits).forEach(k => delete limits[k])
|
||||
Object.keys(visibleFields).forEach(k => delete visibleFields[k])
|
||||
notifyBaseUrl.value = ''
|
||||
returnBaseUrl.value = ''
|
||||
limitsExpanded.value = false
|
||||
}
|
||||
|
||||
function applyDefaults() {
|
||||
for (const f of PROVIDER_CONFIG_FIELDS[form.provider_key] || []) {
|
||||
if (f.defaultValue && !config[f.key]) config[f.key] = f.defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
function getLimitVal(paymentType: string, field: string): string {
|
||||
const val = limits[paymentType]?.[field]
|
||||
return val && val > 0 ? String(val) : ''
|
||||
}
|
||||
|
||||
/** Returns true if any limit field for this payment type has a value */
|
||||
function hasAnyLimit(paymentType: string): boolean {
|
||||
const l = limits[paymentType]
|
||||
if (!l) return false
|
||||
return (l.singleMin > 0) || (l.singleMax > 0) || (l.dailyLimit > 0)
|
||||
}
|
||||
|
||||
/** Dynamic placeholder: "不限制" if sibling has value, "使用全局配置" if all empty */
|
||||
function limitPlaceholder(paymentType: string): string {
|
||||
return hasAnyLimit(paymentType)
|
||||
? t('admin.settings.payment.limitsNoLimit')
|
||||
: t('admin.settings.payment.limitsUseGlobal')
|
||||
}
|
||||
|
||||
function setLimitVal(paymentType: string, field: string, val: string) {
|
||||
if (!limits[paymentType]) limits[paymentType] = {}
|
||||
const num = Number(val)
|
||||
// Empty → clear the field (use global); reject ≤0
|
||||
if (val === '' || isNaN(num)) {
|
||||
delete limits[paymentType][field]
|
||||
return
|
||||
}
|
||||
if (num <= 0) return
|
||||
limits[paymentType][field] = num
|
||||
}
|
||||
|
||||
function serializeLimits(): string {
|
||||
const result: Record<string, Record<string, number>> = {}
|
||||
for (const [pt, fields] of Object.entries(limits)) {
|
||||
const clean: Record<string, number> = {}
|
||||
for (const [k, v] of Object.entries(fields)) {
|
||||
if (v > 0) clean[k] = v
|
||||
}
|
||||
if (Object.keys(clean).length > 0) result[pt] = clean
|
||||
}
|
||||
return Object.keys(result).length > 0 ? JSON.stringify(result) : ''
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
// Validate required fields
|
||||
if (!form.name.trim()) {
|
||||
emitValidationError(t('admin.settings.payment.validationNameRequired'))
|
||||
return
|
||||
}
|
||||
// Validate required config fields — all non-optional fields must be filled
|
||||
for (const f of PROVIDER_CONFIG_FIELDS[form.provider_key] || []) {
|
||||
if (f.optional) continue
|
||||
const val = (config[f.key] || '').trim()
|
||||
if (!val) {
|
||||
const label = f.label || t(`admin.settings.payment.field_${f.key}`)
|
||||
emitValidationError(t('admin.settings.payment.validationFieldRequired', { field: label }))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const filteredConfig: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries(config)) {
|
||||
if (!v || !v.trim()) continue
|
||||
// Skip masked values — backend keeps existing credentials
|
||||
if (v === '••••••••') continue
|
||||
filteredConfig[k] = v
|
||||
}
|
||||
|
||||
// Inject computed callback URLs (each URL = independent base + fixed path)
|
||||
// If base URL is empty, auto-fill with current domain
|
||||
const paths = PROVIDER_CALLBACK_PATHS[form.provider_key]
|
||||
if (paths) {
|
||||
const notifyBase = notifyBaseUrl.value.trim() || defaultBaseUrl
|
||||
const returnBase = returnBaseUrl.value.trim() || defaultBaseUrl
|
||||
notifyBaseUrl.value = notifyBase
|
||||
returnBaseUrl.value = returnBase
|
||||
if (paths.notifyUrl) filteredConfig['notifyUrl'] = notifyBase + paths.notifyUrl
|
||||
if (paths.returnUrl) filteredConfig['returnUrl'] = returnBase + paths.returnUrl
|
||||
}
|
||||
|
||||
emit('save', {
|
||||
provider_key: form.provider_key,
|
||||
name: form.name,
|
||||
supported_types: form.supported_types,
|
||||
enabled: form.enabled,
|
||||
payment_mode: form.provider_key === 'easypay' ? form.payment_mode : '',
|
||||
refund_enabled: form.refund_enabled,
|
||||
config: filteredConfig,
|
||||
limits: serializeLimits(),
|
||||
})
|
||||
}
|
||||
|
||||
function emitValidationError(msg: string) {
|
||||
// Use a custom event or inject appStore — for now use window alert fallback
|
||||
// The parent handles this via the save event validation
|
||||
import('@/stores').then(m => m.useAppStore().showError(msg))
|
||||
}
|
||||
|
||||
// --- Public API for parent to call ---
|
||||
function reset(defaultKey: string) {
|
||||
form.name = ''
|
||||
form.provider_key = defaultKey
|
||||
form.supported_types = [...(PROVIDER_SUPPORTED_TYPES[defaultKey] || [])]
|
||||
form.enabled = true
|
||||
form.payment_mode = defaultKey === 'easypay' ? PAYMENT_MODE_QRCODE : ''
|
||||
form.refund_enabled = false
|
||||
clearConfig()
|
||||
applyDefaults()
|
||||
}
|
||||
|
||||
function loadProvider(provider: ProviderInstance) {
|
||||
form.name = provider.name
|
||||
form.provider_key = provider.provider_key
|
||||
form.supported_types = provider.supported_types
|
||||
form.enabled = provider.enabled
|
||||
form.payment_mode = provider.payment_mode || (provider.provider_key === 'easypay' ? PAYMENT_MODE_QRCODE : '')
|
||||
form.refund_enabled = provider.refund_enabled
|
||||
clearConfig()
|
||||
// Pre-fill config from API response (non-sensitive in cleartext, sensitive masked as ••••••••)
|
||||
if (provider.config) {
|
||||
for (const [k, v] of Object.entries(provider.config)) {
|
||||
// Skip notifyUrl/returnUrl — they are derived from callbackBaseUrl
|
||||
if (k === 'notifyUrl' || k === 'returnUrl') continue
|
||||
config[k] = v
|
||||
}
|
||||
// Extract base URLs from existing callback URLs
|
||||
const paths = PROVIDER_CALLBACK_PATHS[provider.provider_key]
|
||||
if (paths?.notifyUrl && provider.config['notifyUrl']) {
|
||||
notifyBaseUrl.value = extractBaseUrl(provider.config['notifyUrl'], paths.notifyUrl)
|
||||
}
|
||||
if (paths?.returnUrl && provider.config['returnUrl']) {
|
||||
returnBaseUrl.value = extractBaseUrl(provider.config['returnUrl'], paths.returnUrl)
|
||||
}
|
||||
}
|
||||
applyDefaults()
|
||||
// Parse existing limits
|
||||
if (provider.limits) {
|
||||
try {
|
||||
const parsed = JSON.parse(provider.limits)
|
||||
for (const [pt, fields] of Object.entries(parsed as Record<string, Record<string, number>>)) {
|
||||
limits[pt] = { ...fields }
|
||||
}
|
||||
limitsExpanded.value = Object.keys(limits).length > 0
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ reset, loadProvider })
|
||||
</script>
|
||||
150
frontend/src/components/payment/PaymentProviderList.vue
Normal file
150
frontend/src/components/payment/PaymentProviderList.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<!-- Header -->
|
||||
<div class="border-b border-gray-100 px-4 py-3 dark:border-dark-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.payment.providerManagement') }}
|
||||
</h2>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.payment.providerManagementDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="emit('refresh')"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary btn-sm"
|
||||
:title="t('common.refresh')"
|
||||
>
|
||||
<Icon name="refresh" size="sm" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="emit('create')"
|
||||
:disabled="!canCreate"
|
||||
:class="canCreate
|
||||
? 'btn btn-primary btn-sm'
|
||||
: 'btn btn-secondary btn-sm cursor-not-allowed opacity-50'"
|
||||
>
|
||||
{{ t('admin.settings.payment.createProvider') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
<div class="p-4">
|
||||
<!-- Loading -->
|
||||
<div v-if="loading && !providers.length" class="flex items-center justify-center py-6">
|
||||
<div class="h-5 w-5 animate-spin rounded-full border-2 border-primary-500 border-t-transparent" />
|
||||
</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-if="!loading" class="py-6 text-center">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ canCreate
|
||||
? t('admin.settings.payment.noProviders')
|
||||
: t('admin.settings.payment.enableTypesFirst') }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
v-if="canCreate"
|
||||
@click="emit('create')"
|
||||
class="btn btn-primary btn-sm mt-2"
|
||||
>
|
||||
{{ t('admin.settings.payment.createProvider') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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'
|
||||
import type { TypeOption } from './providerConfig'
|
||||
import { getAvailableTypes } from './providerConfig'
|
||||
|
||||
const props = defineProps<{
|
||||
providers: ProviderInstance[]
|
||||
loading: boolean
|
||||
canCreate: boolean
|
||||
enabledPaymentTypes: string[]
|
||||
allPaymentTypes: TypeOption[]
|
||||
redirectLabel: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
create: []
|
||||
edit: [provider: ProviderInstance]
|
||||
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 {
|
||||
return props.enabledPaymentTypes.includes(providerKey)
|
||||
}
|
||||
|
||||
function getTypes(providerKey: string): TypeOption[] {
|
||||
return getAvailableTypes(providerKey, props.allPaymentTypes, props.redirectLabel)
|
||||
.map(opt => opt.label === opt.value
|
||||
? { ...opt, label: t(`payment.methods.${opt.value}`, opt.value) }
|
||||
: opt,
|
||||
)
|
||||
}
|
||||
</script>
|
||||
272
frontend/src/components/payment/PaymentQRDialog.vue
Normal file
272
frontend/src/components/payment/PaymentQRDialog.vue
Normal file
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<BaseDialog :show="show" :title="dialogTitle" width="narrow" @close="handleClose">
|
||||
<!-- QR Code + Polling State -->
|
||||
<div v-if="!success" class="flex flex-col items-center space-y-4">
|
||||
<!-- QR Code mode -->
|
||||
<template v-if="qrUrl">
|
||||
<div class="rounded-2xl bg-white p-4 shadow-sm dark:bg-dark-800">
|
||||
<canvas ref="qrCanvas" class="mx-auto"></canvas>
|
||||
</div>
|
||||
<p v-if="scanHint" class="text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ scanHint }}
|
||||
</p>
|
||||
</template>
|
||||
<!-- Popup window waiting mode (no QR code) -->
|
||||
<template v-else>
|
||||
<div class="flex flex-col items-center py-4">
|
||||
<div class="h-10 w-10 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"></div>
|
||||
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.payInNewWindowHint') }}</p>
|
||||
<button v-if="payUrl" class="btn btn-secondary mt-3 text-sm" @click="reopenPopup">
|
||||
{{ t('payment.qr.openPayWindow') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Countdown -->
|
||||
<div v-if="expired" class="text-center">
|
||||
<p class="text-lg font-medium text-red-500">{{ t('payment.qr.expired') }}</p>
|
||||
</div>
|
||||
<div v-else class="text-center">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ qrUrl ? t('payment.qr.expiresIn') : '' }}</p>
|
||||
<p class="mt-1 text-2xl font-bold tabular-nums text-gray-900 dark:text-white">{{ countdownDisplay }}</p>
|
||||
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">{{ t('payment.qr.waitingPayment') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Success State -->
|
||||
<div v-else class="flex flex-col items-center space-y-4 py-4">
|
||||
<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>
|
||||
<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">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">#{{ paidOrder.id }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">${{ paidOrder.pay_amount.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button v-if="!success && !expired" class="btn btn-secondary" :disabled="cancelling" @click="handleCancel">
|
||||
{{ cancelling ? t('common.processing') : t('payment.qr.cancelOrder') }}
|
||||
</button>
|
||||
<button v-if="success" class="btn btn-primary" @click="handleDone">
|
||||
{{ t('common.confirm') }}
|
||||
</button>
|
||||
<button v-if="expired" class="btn btn-primary" @click="handleClose">
|
||||
{{ t('payment.result.backToRecharge') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { usePaymentStore } from '@/stores/payment'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
|
||||
import type { PaymentOrder } from '@/types/payment'
|
||||
import QRCode from 'qrcode'
|
||||
import alipayIcon from '@/assets/icons/alipay.svg'
|
||||
import wxpayIcon from '@/assets/icons/wxpay.svg'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
orderId: number
|
||||
qrCode: string
|
||||
expiresAt: string
|
||||
paymentType: string
|
||||
/** URL for reopening the payment popup window */
|
||||
payUrl?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const paymentStore = usePaymentStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const qrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const qrUrl = ref('')
|
||||
const remainingSeconds = ref(0)
|
||||
const expired = ref(false)
|
||||
const cancelling = ref(false)
|
||||
const success = ref(false)
|
||||
const paidOrder = ref<PaymentOrder | null>(null)
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const isAlipay = computed(() => props.paymentType.includes('alipay'))
|
||||
const isWxpay = computed(() => props.paymentType.includes('wxpay'))
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
if (success.value) return t('payment.result.success')
|
||||
if (!qrUrl.value) return t('payment.qr.payInNewWindow')
|
||||
if (isAlipay.value) return t('payment.qr.scanAlipay')
|
||||
if (isWxpay.value) return t('payment.qr.scanWxpay')
|
||||
return t('payment.qr.scanToPay')
|
||||
})
|
||||
|
||||
const scanHint = computed(() => {
|
||||
if (isAlipay.value) return t('payment.qr.scanAlipayHint')
|
||||
if (isWxpay.value) return t('payment.qr.scanWxpayHint')
|
||||
return ''
|
||||
})
|
||||
|
||||
const countdownDisplay = computed(() => {
|
||||
const m = Math.floor(remainingSeconds.value / 60)
|
||||
const s = remainingSeconds.value % 60
|
||||
return m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0')
|
||||
})
|
||||
|
||||
function getLogoForType(): string | null {
|
||||
if (isAlipay.value) return alipayIcon
|
||||
if (isWxpay.value) return wxpayIcon
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
function reopenPopup() {
|
||||
if (props.payUrl) {
|
||||
window.open(props.payUrl, 'paymentPopup', POPUP_WINDOW_FEATURES)
|
||||
}
|
||||
}
|
||||
|
||||
async function renderQR() {
|
||||
await nextTick()
|
||||
if (!qrCanvas.value || !qrUrl.value) return
|
||||
const logoSrc = getLogoForType()
|
||||
await QRCode.toCanvas(qrCanvas.value, qrUrl.value, {
|
||||
width: 220,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: logoSrc ? 'H' : 'M',
|
||||
})
|
||||
if (!logoSrc) return
|
||||
const canvas = qrCanvas.value
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
const img = new Image()
|
||||
img.src = logoSrc
|
||||
img.onload = () => {
|
||||
const logoSize = 40
|
||||
const x = (canvas.width - logoSize) / 2
|
||||
const y = (canvas.height - logoSize) / 2
|
||||
const pad = 4
|
||||
ctx.fillStyle = '#FFFFFF'
|
||||
ctx.beginPath()
|
||||
const r = 5
|
||||
ctx.moveTo(x - pad + r, y - pad)
|
||||
ctx.arcTo(x + logoSize + pad, y - pad, x + logoSize + pad, y + logoSize + pad, r)
|
||||
ctx.arcTo(x + logoSize + pad, y + logoSize + pad, x - pad, y + logoSize + pad, r)
|
||||
ctx.arcTo(x - pad, y + logoSize + pad, x - pad, y - pad, r)
|
||||
ctx.arcTo(x - pad, y - pad, x + logoSize + pad, y - pad, r)
|
||||
ctx.fill()
|
||||
ctx.drawImage(img, x, y, logoSize, logoSize)
|
||||
}
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
if (!props.orderId) return
|
||||
const order = await paymentStore.pollOrderStatus(props.orderId)
|
||||
if (!order) return
|
||||
if (order.status === 'COMPLETED' || order.status === 'PAID') {
|
||||
cleanup()
|
||||
paidOrder.value = order
|
||||
success.value = true
|
||||
emit('success')
|
||||
} else if (order.status === 'EXPIRED' || order.status === 'CANCELLED' || order.status === 'FAILED') {
|
||||
cleanup()
|
||||
expired.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function startCountdown(seconds: number) {
|
||||
remainingSeconds.value = Math.max(0, seconds)
|
||||
if (remainingSeconds.value <= 0) {
|
||||
expired.value = true
|
||||
return
|
||||
}
|
||||
countdownTimer = setInterval(() => {
|
||||
remainingSeconds.value--
|
||||
if (remainingSeconds.value <= 0) {
|
||||
expired.value = true
|
||||
cleanup()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if (!props.orderId || cancelling.value) return
|
||||
cancelling.value = true
|
||||
try {
|
||||
await paymentAPI.cancelOrder(props.orderId)
|
||||
cleanup()
|
||||
emit('close')
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
cancelling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
cleanup()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function handleDone() {
|
||||
cleanup()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null }
|
||||
}
|
||||
|
||||
function init() {
|
||||
// Reset state
|
||||
success.value = false
|
||||
paidOrder.value = null
|
||||
expired.value = false
|
||||
cancelling.value = false
|
||||
qrUrl.value = props.qrCode
|
||||
|
||||
let seconds = 30 * 60
|
||||
if (props.expiresAt) {
|
||||
const expiresAt = new Date(props.expiresAt)
|
||||
seconds = Math.floor((expiresAt.getTime() - Date.now()) / 1000)
|
||||
}
|
||||
startCountdown(seconds)
|
||||
pollTimer = setInterval(pollStatus, 3000)
|
||||
renderQR()
|
||||
}
|
||||
|
||||
// Watch for dialog open/close
|
||||
watch(() => props.show, (isOpen) => {
|
||||
if (isOpen) {
|
||||
init()
|
||||
} else {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
watch(qrUrl, () => renderQR())
|
||||
|
||||
onUnmounted(() => cleanup())
|
||||
</script>
|
||||
266
frontend/src/components/payment/PaymentStatusPanel.vue
Normal file
266
frontend/src/components/payment/PaymentStatusPanel.vue
Normal file
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- ═══ Terminal States: show result, user clicks to return ═══ -->
|
||||
|
||||
<!-- Success -->
|
||||
<template v-if="outcome === 'success'">
|
||||
<div class="card p-6">
|
||||
<div class="flex flex-col items-center space-y-4 py-4">
|
||||
<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">{{ 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">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">#{{ paidOrder.id }}</span>
|
||||
</div>
|
||||
<div v-if="paidOrder.out_trade_no" class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderNo') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ paidOrder.out_trade_no }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">${{ paidOrder.pay_amount.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="handleDone">{{ t('common.confirm') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Cancelled -->
|
||||
<template v-else-if="outcome === 'cancelled'">
|
||||
<div class="card p-6">
|
||||
<div class="flex flex-col items-center space-y-4 py-4">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700">
|
||||
<svg class="h-8 w-8 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-lg font-bold text-gray-900 dark:text-white">{{ t('payment.qr.cancelled') }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.cancelledDesc') }}</p>
|
||||
<button class="btn btn-primary" @click="handleDone">{{ t('common.confirm') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Expired / Failed -->
|
||||
<template v-else-if="outcome === 'expired'">
|
||||
<div class="card p-6">
|
||||
<div class="flex flex-col items-center space-y-4 py-4">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-orange-100 dark:bg-orange-900/30">
|
||||
<svg class="h-8 w-8 text-orange-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-lg font-bold text-gray-900 dark:text-white">{{ t('payment.qr.expired') }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.expiredDesc') }}</p>
|
||||
<button class="btn btn-primary" @click="handleDone">{{ t('common.confirm') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ═══ Active States: QR or Popup waiting ═══ -->
|
||||
|
||||
<!-- QR Code Mode -->
|
||||
<template v-else-if="qrUrl">
|
||||
<div class="card p-6">
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ scanTitle }}</p>
|
||||
<div :class="['relative rounded-lg border-2 p-4', qrBorderClass]">
|
||||
<canvas ref="qrCanvas" class="mx-auto"></canvas>
|
||||
<!-- Brand logo overlay -->
|
||||
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<span :class="['rounded-full p-2 shadow ring-2 ring-white', qrLogoBgClass]">
|
||||
<img :src="isAlipay ? alipayIcon : wxpayIcon" alt="" class="h-5 w-5 brightness-0 invert" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="scanHint" class="text-center text-sm text-gray-500 dark:text-gray-400">{{ scanHint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-4 text-center">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.expiresIn') }}</p>
|
||||
<p class="mt-1 text-2xl font-bold tabular-nums text-gray-900 dark:text-white">{{ countdownDisplay }}</p>
|
||||
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">{{ t('payment.qr.waitingPayment') }}</p>
|
||||
</div>
|
||||
<button class="btn btn-secondary w-full" :disabled="cancelling" @click="handleCancel">
|
||||
{{ cancelling ? t('common.processing') : t('payment.qr.cancelOrder') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Waiting for Popup/Redirect Mode -->
|
||||
<template v-else>
|
||||
<div class="card p-6">
|
||||
<div class="flex flex-col items-center space-y-4 py-4">
|
||||
<div class="h-10 w-10 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"></div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.payInNewWindowHint') }}</p>
|
||||
<button v-if="payUrl" class="btn btn-secondary text-sm" @click="reopenPopup">
|
||||
{{ t('payment.qr.openPayWindow') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-4 text-center">
|
||||
<p class="mt-1 text-2xl font-bold tabular-nums text-gray-900 dark:text-white">{{ countdownDisplay }}</p>
|
||||
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">{{ t('payment.qr.waitingPayment') }}</p>
|
||||
</div>
|
||||
<button class="btn btn-secondary w-full" :disabled="cancelling" @click="handleCancel">
|
||||
{{ cancelling ? t('common.processing') : t('payment.qr.cancelOrder') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePaymentStore } from '@/stores/payment'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
|
||||
import type { PaymentOrder } from '@/types/payment'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import QRCode from 'qrcode'
|
||||
import alipayIcon from '@/assets/icons/alipay.svg'
|
||||
import wxpayIcon from '@/assets/icons/wxpay.svg'
|
||||
|
||||
const props = defineProps<{
|
||||
orderId: number
|
||||
qrCode: string
|
||||
expiresAt: string
|
||||
paymentType: string
|
||||
payUrl?: string
|
||||
orderType?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ done: []; success: [] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const paymentStore = usePaymentStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const qrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const qrUrl = ref('')
|
||||
const remainingSeconds = ref(0)
|
||||
const cancelling = ref(false)
|
||||
const paidOrder = ref<PaymentOrder | null>(null)
|
||||
|
||||
// Terminal outcome: null = still active, 'success' | 'cancelled' | 'expired'
|
||||
const outcome = ref<'success' | 'cancelled' | 'expired' | null>(null)
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const isAlipay = computed(() => props.paymentType.includes('alipay'))
|
||||
const isWxpay = computed(() => props.paymentType.includes('wxpay'))
|
||||
|
||||
const qrBorderClass = computed(() => {
|
||||
if (isAlipay.value) return 'border-[#00AEEF] bg-blue-50 dark:border-[#00AEEF]/70 dark:bg-blue-950/20'
|
||||
if (isWxpay.value) return 'border-[#2BB741] bg-green-50 dark:border-[#2BB741]/70 dark:bg-green-950/20'
|
||||
return 'border-gray-200 bg-white dark:border-dark-600 dark:bg-dark-800'
|
||||
})
|
||||
|
||||
const qrLogoBgClass = computed(() => {
|
||||
if (isAlipay.value) return 'bg-[#00AEEF]'
|
||||
if (isWxpay.value) return 'bg-[#2BB741]'
|
||||
return 'bg-gray-400'
|
||||
})
|
||||
|
||||
const scanTitle = computed(() => {
|
||||
if (isAlipay.value) return t('payment.qr.scanAlipay')
|
||||
if (isWxpay.value) return t('payment.qr.scanWxpay')
|
||||
return t('payment.qr.scanToPay')
|
||||
})
|
||||
|
||||
const scanHint = computed(() => {
|
||||
if (isAlipay.value) return t('payment.qr.scanAlipayHint')
|
||||
if (isWxpay.value) return t('payment.qr.scanWxpayHint')
|
||||
return ''
|
||||
})
|
||||
|
||||
const countdownDisplay = computed(() => {
|
||||
const m = Math.floor(remainingSeconds.value / 60)
|
||||
const s = remainingSeconds.value % 60
|
||||
return m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0')
|
||||
})
|
||||
|
||||
function reopenPopup() {
|
||||
if (props.payUrl) {
|
||||
window.open(props.payUrl, 'paymentPopup', POPUP_WINDOW_FEATURES)
|
||||
}
|
||||
}
|
||||
|
||||
async function renderQR() {
|
||||
await nextTick()
|
||||
if (!qrCanvas.value || !qrUrl.value) return
|
||||
await QRCode.toCanvas(qrCanvas.value, qrUrl.value, {
|
||||
width: 220, margin: 2,
|
||||
errorCorrectionLevel: 'H',
|
||||
})
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
if (!props.orderId || outcome.value) return
|
||||
const order = await paymentStore.pollOrderStatus(props.orderId)
|
||||
if (!order) return
|
||||
if (order.status === 'COMPLETED' || order.status === 'PAID') {
|
||||
cleanup()
|
||||
paidOrder.value = order
|
||||
outcome.value = 'success'
|
||||
emit('success')
|
||||
} else if (order.status === 'CANCELLED') {
|
||||
cleanup()
|
||||
outcome.value = 'cancelled'
|
||||
} else if (order.status === 'EXPIRED' || order.status === 'FAILED') {
|
||||
cleanup()
|
||||
outcome.value = 'expired'
|
||||
}
|
||||
}
|
||||
|
||||
function startCountdown(seconds: number) {
|
||||
remainingSeconds.value = Math.max(0, seconds)
|
||||
if (remainingSeconds.value <= 0) { outcome.value = 'expired'; return }
|
||||
countdownTimer = setInterval(() => {
|
||||
remainingSeconds.value--
|
||||
if (remainingSeconds.value <= 0) { outcome.value = 'expired'; cleanup() }
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if (!props.orderId || cancelling.value) return
|
||||
cancelling.value = true
|
||||
try {
|
||||
await paymentAPI.cancelOrder(props.orderId)
|
||||
cleanup()
|
||||
outcome.value = 'cancelled'
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
cancelling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDone() { cleanup(); emit('done') }
|
||||
|
||||
function cleanup() {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null }
|
||||
}
|
||||
|
||||
// Initialize on mount
|
||||
qrUrl.value = props.qrCode
|
||||
let seconds = 30 * 60
|
||||
if (props.expiresAt) {
|
||||
seconds = Math.floor((new Date(props.expiresAt).getTime() - Date.now()) / 1000)
|
||||
}
|
||||
startCountdown(seconds)
|
||||
pollTimer = setInterval(pollStatus, 3000)
|
||||
renderQR()
|
||||
|
||||
watch(() => qrUrl.value, () => renderQR())
|
||||
onUnmounted(() => cleanup())
|
||||
</script>
|
||||
106
frontend/src/components/payment/ProviderCard.vue
Normal file
106
frontend/src/components/payment/ProviderCard.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'group relative rounded-lg border transition-all',
|
||||
enabled ? 'border-gray-200 dark:border-dark-600' : 'border-gray-200 bg-gray-50 opacity-50 dark:border-dark-700 dark:bg-dark-800/50',
|
||||
]"
|
||||
:title="!enabled ? t('admin.settings.payment.typeDisabled') + ' — ' + t('admin.settings.payment.enableTypesFirst') : undefined"
|
||||
>
|
||||
<div :class="[
|
||||
'flex items-center justify-between px-4 py-2.5',
|
||||
!enabled && 'pointer-events-none',
|
||||
]">
|
||||
<!-- Left: icon + name + key badge + type badges -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div :class="[
|
||||
'rounded-md p-1.5',
|
||||
provider.enabled && enabled ? 'bg-green-100 dark:bg-green-900/30' : 'bg-gray-100 dark:bg-dark-700',
|
||||
]">
|
||||
<Icon
|
||||
name="server"
|
||||
size="sm"
|
||||
:class="provider.enabled && enabled ? 'text-green-600 dark:text-green-400' : 'text-gray-400'"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ provider.name }}</span>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">{{ keyLabel }}</span>
|
||||
<span v-if="provider.payment_mode" class="text-xs text-gray-400 dark:text-gray-500">· {{ modeLabel }}</span>
|
||||
<span v-if="enabled && availableTypes.length" class="text-xs text-gray-300 dark:text-gray-600">|</span>
|
||||
<div v-if="enabled" class="flex items-center gap-1">
|
||||
<button
|
||||
v-for="pt in availableTypes"
|
||||
:key="pt.value"
|
||||
type="button"
|
||||
@click="emit('toggleType', pt.value)"
|
||||
:class="[
|
||||
'rounded px-2 py-0.5 text-xs font-medium transition-all',
|
||||
isSelected(pt.value)
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-gray-100 text-gray-400 dark:bg-dark-700 dark:text-gray-500',
|
||||
]"
|
||||
>{{ pt.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: toggles + actions -->
|
||||
<div class="flex items-center gap-4">
|
||||
<ToggleSwitch :label="t('common.enabled')" :checked="provider.enabled" @toggle="emit('toggleField', 'enabled')" />
|
||||
<ToggleSwitch :label="t('admin.settings.payment.refundEnabled')" :checked="provider.refund_enabled" @toggle="emit('toggleField', 'refund_enabled')" />
|
||||
<div class="flex items-center gap-2 border-l border-gray-200 pl-3 dark:border-dark-600">
|
||||
<button type="button" @click="emit('edit')" 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 type="button" @click="emit('delete')" 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import ToggleSwitch from './ToggleSwitch.vue'
|
||||
import type { ProviderInstance } from '@/types/payment'
|
||||
import type { TypeOption } from './providerConfig'
|
||||
import { PAYMENT_MODE_QRCODE, PAYMENT_MODE_POPUP } from './providerConfig'
|
||||
|
||||
const PROVIDER_KEY_LABELS: Record<string, string> = {
|
||||
easypay: 'admin.settings.payment.providerEasypay',
|
||||
alipay: 'admin.settings.payment.providerAlipay',
|
||||
wxpay: 'admin.settings.payment.providerWxpay',
|
||||
stripe: 'admin.settings.payment.providerStripe',
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
provider: ProviderInstance
|
||||
enabled: boolean
|
||||
availableTypes: TypeOption[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleField: [field: 'enabled' | 'refund_enabled']
|
||||
toggleType: [type: string]
|
||||
edit: []
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const keyLabel = computed(() => t(PROVIDER_KEY_LABELS[props.provider.provider_key] || props.provider.provider_key))
|
||||
|
||||
const modeLabel = computed(() => {
|
||||
if (props.provider.payment_mode === PAYMENT_MODE_QRCODE) return t('admin.settings.payment.modeQRCode')
|
||||
if (props.provider.payment_mode === PAYMENT_MODE_POPUP) return t('admin.settings.payment.modePopup')
|
||||
return ''
|
||||
})
|
||||
|
||||
function isSelected(type: string): boolean {
|
||||
return props.provider.supported_types.includes(type)
|
||||
}
|
||||
</script>
|
||||
201
frontend/src/components/payment/StripePaymentInline.vue
Normal file
201
frontend/src/components/payment/StripePaymentInline.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"></div>
|
||||
</div>
|
||||
<div v-else-if="initError" class="card p-6 text-center">
|
||||
<p class="text-sm text-red-600 dark:text-red-400">{{ initError }}</p>
|
||||
<button class="btn btn-secondary mt-4" @click="$emit('back')">{{ t('payment.result.backToRecharge') }}</button>
|
||||
</div>
|
||||
<!-- Success -->
|
||||
<template v-else-if="success">
|
||||
<div class="card p-6">
|
||||
<div class="flex flex-col items-center space-y-4 py-4">
|
||||
<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>
|
||||
<div 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">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">#{{ orderId }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">${{ payAmount.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="$emit('done')">{{ t('common.confirm') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- Amount -->
|
||||
<div class="card overflow-hidden">
|
||||
<div class="bg-gradient-to-br from-[#635bff] to-[#4f46e5] px-6 py-5 text-center">
|
||||
<p class="text-sm font-medium text-indigo-200">{{ t('payment.actualPay') }}</p>
|
||||
<p class="mt-1 text-3xl font-bold text-white">${{ payAmount.toFixed(2) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Stripe Payment Element -->
|
||||
<div class="card p-6">
|
||||
<div ref="stripeMount" class="min-h-[200px]"></div>
|
||||
<p v-if="error" class="mt-4 text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
<button class="btn btn-stripe mt-6 w-full py-3 text-base" :disabled="submitting || !ready" @click="handlePay">
|
||||
<span v-if="submitting" class="flex items-center justify-center gap-2">
|
||||
<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.stripePay') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Cancel order -->
|
||||
<button class="btn btn-secondary w-full" :disabled="cancelling" @click="handleCancel">
|
||||
{{ cancelling ? t('common.processing') : t('payment.qr.cancelOrder') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { STRIPE_POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
|
||||
import type { Stripe, StripeElements } from '@stripe/stripe-js'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
// Stripe payment methods that open a popup (redirect or QR code)
|
||||
const POPUP_METHODS = new Set(['alipay', 'wechat_pay'])
|
||||
|
||||
const props = defineProps<{
|
||||
orderId: number
|
||||
clientSecret: string
|
||||
publishableKey: string
|
||||
payAmount: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ success: []; done: []; back: []; redirect: [orderId: number, payUrl: string] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const stripeMount = ref<HTMLElement | null>(null)
|
||||
const loading = ref(true)
|
||||
const initError = ref('')
|
||||
const error = ref('')
|
||||
const submitting = ref(false)
|
||||
const cancelling = ref(false)
|
||||
const success = ref(false)
|
||||
const ready = ref(false)
|
||||
const selectedType = ref('')
|
||||
|
||||
let stripeInstance: Stripe | null = null
|
||||
let elementsInstance: StripeElements | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { loadStripe } = await import('@stripe/stripe-js')
|
||||
const stripe = await loadStripe(props.publishableKey)
|
||||
if (!stripe) { initError.value = t('payment.stripeLoadFailed'); return }
|
||||
|
||||
stripeInstance = stripe
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
if (!stripeMount.value) return
|
||||
|
||||
const isDark = document.documentElement.classList.contains('dark')
|
||||
const elements = stripe.elements({
|
||||
clientSecret: props.clientSecret,
|
||||
appearance: { theme: isDark ? 'night' : 'stripe', variables: { borderRadius: '8px' } },
|
||||
})
|
||||
elementsInstance = elements
|
||||
const paymentElement = elements.create('payment', {
|
||||
layout: 'tabs',
|
||||
paymentMethodOrder: ['alipay', 'wechat_pay', 'card', 'link'],
|
||||
} as Record<string, unknown>)
|
||||
paymentElement.mount(stripeMount.value)
|
||||
paymentElement.on('ready', () => { ready.value = true })
|
||||
paymentElement.on('change', (event: { value: { type: string } }) => {
|
||||
selectedType.value = event.value.type
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
initError.value = extractApiErrorMessage(err, t('payment.stripeLoadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function handlePay() {
|
||||
if (!stripeInstance || !elementsInstance || submitting.value) return
|
||||
|
||||
// Alipay / WeChat Pay: open popup for redirect or QR display
|
||||
if (POPUP_METHODS.has(selectedType.value)) {
|
||||
const popupUrl = router.resolve({
|
||||
path: '/payment/stripe-popup',
|
||||
query: {
|
||||
order_id: String(props.orderId),
|
||||
method: selectedType.value,
|
||||
amount: String(props.payAmount),
|
||||
},
|
||||
}).href
|
||||
const popup = window.open(popupUrl, 'paymentPopup', STRIPE_POPUP_WINDOW_FEATURES)
|
||||
|
||||
const onReady = (event: MessageEvent) => {
|
||||
if (event.source !== popup || event.data?.type !== 'STRIPE_POPUP_READY') return
|
||||
window.removeEventListener('message', onReady)
|
||||
popup?.postMessage({
|
||||
type: 'STRIPE_POPUP_INIT',
|
||||
clientSecret: props.clientSecret,
|
||||
publishableKey: props.publishableKey,
|
||||
}, window.location.origin)
|
||||
}
|
||||
window.addEventListener('message', onReady)
|
||||
|
||||
emit('redirect', props.orderId, popupUrl)
|
||||
return
|
||||
}
|
||||
|
||||
// Card / Link: confirm inline
|
||||
submitting.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const { error: stripeError } = await stripeInstance.confirmPayment({
|
||||
elements: elementsInstance,
|
||||
confirmParams: {
|
||||
return_url: window.location.origin + '/payment/result?order_id=' + props.orderId + '&status=success',
|
||||
},
|
||||
redirect: 'if_required',
|
||||
})
|
||||
if (stripeError) {
|
||||
error.value = stripeError.message || t('payment.result.failed')
|
||||
} else {
|
||||
success.value = true
|
||||
emit('success')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error.value = extractApiErrorMessage(err, t('payment.result.failed'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if (!props.orderId || cancelling.value) return
|
||||
cancelling.value = true
|
||||
try {
|
||||
await paymentAPI.cancelOrder(props.orderId)
|
||||
emit('back')
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
cancelling.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
161
frontend/src/components/payment/SubscriptionPlanCard.vue
Normal file
161
frontend/src/components/payment/SubscriptionPlanCard.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'group relative flex flex-col overflow-hidden rounded-2xl border transition-all',
|
||||
'hover:shadow-xl hover:-translate-y-0.5',
|
||||
borderClass,
|
||||
'bg-white dark:bg-dark-800',
|
||||
]"
|
||||
>
|
||||
<!-- Colored top accent bar -->
|
||||
<div :class="['h-1.5', accentClass]" />
|
||||
|
||||
<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>
|
||||
<div class="shrink-0 text-right">
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-xs text-gray-400 dark:text-dark-500">$</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">${{ 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 (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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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="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"
|
||||
class="rounded bg-gray-200/80 px-1.5 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-dark-600 dark:text-gray-300">
|
||||
{{ scope }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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-xs text-gray-600 dark:text-gray-300">{{ feature }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1" />
|
||||
|
||||
<!-- Subscribe Button -->
|
||||
<button
|
||||
type="button"
|
||||
:class="['w-full rounded-xl py-2.5 text-sm font-semibold transition-all active:scale-[0.98]', btnClass]"
|
||||
@click="emit('select', plan)"
|
||||
>
|
||||
{{ isRenewal ? t('payment.renewNow') : t('payment.subscribeNow') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { SubscriptionPlan } from '@/types/payment'
|
||||
import type { UserSubscription } from '@/types'
|
||||
import {
|
||||
platformAccentBarClass,
|
||||
platformBadgeLightClass,
|
||||
platformBorderClass,
|
||||
platformTextClass,
|
||||
platformIconClass,
|
||||
platformButtonClass,
|
||||
platformDiscountClass,
|
||||
platformLabel,
|
||||
} from '@/utils/platformColors'
|
||||
|
||||
const props = defineProps<{ plan: SubscriptionPlan; activeSubscriptions?: UserSubscription[] }>()
|
||||
const emit = defineEmits<{ select: [plan: SubscriptionPlan] }>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const platform = computed(() => props.plan.group_platform || '')
|
||||
const isRenewal = computed(() =>
|
||||
props.activeSubscriptions?.some(s => s.group_id === props.plan.group_id && s.status === 'active') ?? false
|
||||
)
|
||||
|
||||
// Derived color classes from central config
|
||||
const accentClass = computed(() => platformAccentBarClass(platform.value))
|
||||
const borderClass = computed(() => platformBorderClass(platform.value))
|
||||
const badgeLightClass = computed(() => platformBadgeLightClass(platform.value))
|
||||
const textClass = computed(() => platformTextClass(platform.value))
|
||||
const iconClass = computed(() => platformIconClass(platform.value))
|
||||
const btnClass = computed(() => platformButtonClass(platform.value))
|
||||
const discountClass = computed(() => platformDiscountClass(platform.value))
|
||||
const pLabel = computed(() => platformLabel(platform.value))
|
||||
|
||||
const discountText = computed(() => {
|
||||
if (!props.plan.original_price || props.plan.original_price <= 0) return ''
|
||||
const pct = Math.round((1 - props.plan.price / props.plan.original_price) * 100)
|
||||
return pct > 0 ? `-${pct}%` : ''
|
||||
})
|
||||
|
||||
const rateDisplay = computed(() => {
|
||||
const rate = props.plan.rate_multiplier ?? 1
|
||||
return `×${Number(rate.toPrecision(10))}`
|
||||
})
|
||||
|
||||
const MODEL_SCOPE_LABELS: Record<string, string> = {
|
||||
claude: 'Claude',
|
||||
gemini_text: 'Gemini',
|
||||
gemini_image: 'Imagen',
|
||||
}
|
||||
|
||||
const modelScopeLabels = computed(() => {
|
||||
const scopes = props.plan.supported_model_scopes
|
||||
if (!scopes || scopes.length === 0) return []
|
||||
return scopes.map(s => MODEL_SCOPE_LABELS[s] || s)
|
||||
})
|
||||
|
||||
const validitySuffix = computed(() => {
|
||||
const u = props.plan.validity_unit || 'day'
|
||||
if (u === 'month') return t('payment.perMonth')
|
||||
if (u === 'year') return t('payment.perYear')
|
||||
return `${props.plan.validity_days}${t('payment.days')}`
|
||||
})
|
||||
</script>
|
||||
25
frontend/src/components/payment/ToggleSwitch.vue
Normal file
25
frontend/src/components/payment/ToggleSwitch.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ label }}</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="checked"
|
||||
@click="emit('toggle')"
|
||||
:class="[
|
||||
'relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors duration-200',
|
||||
checked ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600',
|
||||
]"
|
||||
>
|
||||
<span :class="[
|
||||
'pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200',
|
||||
checked ? 'translate-x-4' : 'translate-x-0',
|
||||
]" />
|
||||
</button>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ label: string; checked: boolean }>()
|
||||
const emit = defineEmits<{ toggle: [] }>()
|
||||
</script>
|
||||
128
frontend/src/components/payment/providerConfig.ts
Normal file
128
frontend/src/components/payment/providerConfig.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Shared constants and types for payment provider management.
|
||||
*/
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface ConfigFieldDef {
|
||||
key: string
|
||||
label: string
|
||||
sensitive: boolean
|
||||
optional?: boolean
|
||||
defaultValue?: string
|
||||
}
|
||||
|
||||
export interface TypeOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Callback URL paths for a provider. */
|
||||
export interface CallbackPaths {
|
||||
notifyUrl?: string
|
||||
returnUrl?: string
|
||||
}
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
/** Maps provider key → available payment types. */
|
||||
export const PROVIDER_SUPPORTED_TYPES: Record<string, string[]> = {
|
||||
easypay: ['alipay', 'wxpay'],
|
||||
alipay: ['alipay'],
|
||||
wxpay: ['wxpay'],
|
||||
stripe: ['card', 'alipay', 'wxpay', 'link'],
|
||||
}
|
||||
|
||||
/** Available payment modes for EasyPay providers. */
|
||||
export const EASYPAY_PAYMENT_MODES = ['qrcode', 'popup'] as const
|
||||
|
||||
/** Fixed display order for user-facing payment methods */
|
||||
export const METHOD_ORDER = ['alipay', 'alipay_direct', 'wxpay', 'wxpay_direct', 'stripe'] as const
|
||||
|
||||
/** Payment mode constants */
|
||||
export const PAYMENT_MODE_QRCODE = 'qrcode'
|
||||
export const PAYMENT_MODE_POPUP = 'popup'
|
||||
|
||||
/** Window features for payment popup windows */
|
||||
export const POPUP_WINDOW_FEATURES = 'width=1000,height=750,left=100,top=80,scrollbars=yes,resizable=yes'
|
||||
|
||||
/** Wider popup for Stripe redirect methods (Alipay checkout page needs ~1200px) */
|
||||
export const STRIPE_POPUP_WINDOW_FEATURES = 'width=1250,height=780,left=80,top=60,scrollbars=yes,resizable=yes'
|
||||
|
||||
/** Webhook paths for each provider (relative to origin). */
|
||||
export const WEBHOOK_PATHS: Record<string, string> = {
|
||||
easypay: '/api/v1/payment/webhook/easypay',
|
||||
alipay: '/api/v1/payment/webhook/alipay',
|
||||
wxpay: '/api/v1/payment/webhook/wxpay',
|
||||
stripe: '/api/v1/payment/webhook/stripe',
|
||||
}
|
||||
|
||||
export const RETURN_PATH = '/payment/result'
|
||||
|
||||
/** Fixed callback paths per provider — displayed as read-only after base URL. */
|
||||
export const PROVIDER_CALLBACK_PATHS: Record<string, CallbackPaths> = {
|
||||
easypay: { notifyUrl: WEBHOOK_PATHS.easypay, returnUrl: RETURN_PATH },
|
||||
alipay: { notifyUrl: WEBHOOK_PATHS.alipay, returnUrl: RETURN_PATH },
|
||||
wxpay: { notifyUrl: WEBHOOK_PATHS.wxpay },
|
||||
// stripe: no callback URL config needed (webhook is separate)
|
||||
}
|
||||
|
||||
/** Per-provider config fields (excludes notifyUrl/returnUrl which are handled separately). */
|
||||
export const PROVIDER_CONFIG_FIELDS: Record<string, ConfigFieldDef[]> = {
|
||||
easypay: [
|
||||
{ key: 'pid', label: 'PID', sensitive: false },
|
||||
{ key: 'pkey', label: 'PKey', sensitive: true },
|
||||
{ key: 'apiBase', label: '', sensitive: false },
|
||||
{ key: 'cidAlipay', label: '', sensitive: false, optional: true },
|
||||
{ key: 'cidWxpay', label: '', sensitive: false, optional: true },
|
||||
],
|
||||
alipay: [
|
||||
{ key: 'appId', label: 'App ID', sensitive: false },
|
||||
{ key: 'privateKey', label: '', sensitive: true },
|
||||
{ key: 'publicKey', label: '', sensitive: true },
|
||||
],
|
||||
wxpay: [
|
||||
{ key: 'appId', label: 'App ID', sensitive: false },
|
||||
{ key: 'mchId', label: '', sensitive: false },
|
||||
{ key: 'privateKey', label: '', sensitive: true },
|
||||
{ key: 'apiV3Key', label: '', sensitive: true },
|
||||
{ key: 'publicKey', label: '', sensitive: true },
|
||||
{ key: 'publicKeyId', label: '', sensitive: false, optional: true },
|
||||
{ key: 'certSerial', label: '', sensitive: false, optional: true },
|
||||
],
|
||||
stripe: [
|
||||
{ key: 'secretKey', label: '', sensitive: true },
|
||||
{ key: 'publishableKey', label: '', sensitive: false },
|
||||
{ key: 'webhookSecret', label: '', sensitive: true },
|
||||
],
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
/** Resolve type label for display. */
|
||||
export function resolveTypeLabel(
|
||||
typeVal: string,
|
||||
_providerKey: string,
|
||||
allTypes: TypeOption[],
|
||||
_redirectLabel: string,
|
||||
): TypeOption {
|
||||
return allTypes.find(pt => pt.value === typeVal) || { value: typeVal, label: typeVal }
|
||||
}
|
||||
|
||||
/** Get available type options for a provider key. */
|
||||
export function getAvailableTypes(
|
||||
providerKey: string,
|
||||
allTypes: TypeOption[],
|
||||
redirectLabel: string,
|
||||
): TypeOption[] {
|
||||
const types = PROVIDER_SUPPORTED_TYPES[providerKey] || []
|
||||
return types.map(t => resolveTypeLabel(t, providerKey, allTypes, redirectLabel))
|
||||
}
|
||||
|
||||
/** Extract base URL from a full callback URL by removing the known path suffix. */
|
||||
export function extractBaseUrl(fullUrl: string, path: string): string {
|
||||
if (!fullUrl) return ''
|
||||
if (fullUrl.endsWith(path)) return fullUrl.slice(0, -path.length)
|
||||
// Fallback: try to extract origin
|
||||
try { return new URL(fullUrl).origin } catch { return fullUrl }
|
||||
}
|
||||
Reference in New Issue
Block a user