refactor(payment): extract provider management into components + add limits UI

Components extracted from SettingsView:
- providerConfig.ts: shared constants, types, helpers
- PaymentProviderList.vue: provider card list with header
- ProviderCard.vue: single provider card with inline toggles
- ToggleSwitch.vue: reusable toggle switch component
- PaymentProviderDialog.vue: create/edit dialog with config + limits

Other changes:
- Add per-payment-type limits config UI (singleMin, singleMax, dailyLimit)
- Remove generic cid from EasyPay config (keep cidAlipay/cidWxpay only)
- Backend: remove paymentMode constants, determine mode by PaymentType
- Backend: add validateProviderRequest for create
- Add limits field to ProviderInstance type
- SettingsView reduced by ~200 lines of provider management code
This commit is contained in:
erio
2026-04-06 22:34:06 +08:00
parent 072297fd08
commit c891439d2a
9 changed files with 814 additions and 360 deletions
@@ -0,0 +1,365 @@
<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 any"
:disabled="!!editing"
@change="onKeyChange"
/>
</div>
</div>
<!-- Supported types -->
<div>
<label class="input-label">
{{ t('admin.settings.payment.supportedTypes') }}
<span class="text-red-500">*</span>
</label>
<div class="mt-2 flex flex-wrap gap-2">
<button
v-for="pt in availableTypes"
:key="pt.value"
type="button"
@click="toggleType(pt.value)"
:class="[
'rounded-lg border px-4 py-2 text-sm 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>
<!-- Enabled + Refund checkboxes -->
<div class="flex items-center gap-4">
<label class="flex items-center gap-2">
<input v-model="form.enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('common.enabled') }}</span>
</label>
<label class="flex items-center gap-2">
<input v-model="form.refund_enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.settings.payment.refundEnabled') }}</span>
</label>
</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"
/>
<input
v-else
:type="field.sensitive ? 'password' : 'text'"
v-model="config[field.key]"
class="input"
:placeholder="field.defaultValue || ''"
/>
</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 -->
<div v-if="limitableTypes.length" 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.limitsTitle') }}
</h4>
<div class="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="0" step="0.01" placeholder="0"
/>
</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="0" step="0.01" placeholder="0"
/>
</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="0" step="0.01" placeholder="0"
/>
</div>
</div>
</div>
</div>
<p class="mt-2 text-xs text-gray-400 dark:text-gray-500">{{ t('admin.settings.payment.limitsHint') }}</p>
</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 } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue'
import type { ProviderInstance } from '@/types/payment'
import type { TypeOption } from './providerConfig'
import {
PROVIDER_CONFIG_FIELDS,
PROVIDER_SUPPORTED_TYPES,
WEBHOOK_PATHS,
parseTypes,
getAvailableTypes,
} 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: {
providerKey: string
name: string
supportedTypes: string
enabled: boolean
refundEnabled: boolean
config: Record<string, string>
limits: string
}]
}>()
const { t } = useI18n()
// --- Form state ---
const form = reactive({
name: '',
provider_key: 'easypay',
supported_types: '',
enabled: true,
refund_enabled: false,
})
const config = reactive<Record<string, string>>({})
const limits = reactive<Record<string, Record<string, number>>>({})
// --- Computed ---
const baseURL = typeof window !== 'undefined' ? window.location.origin : ''
const stripeWebhookUrl = computed(() =>
form.provider_key === 'stripe' ? baseURL + WEBHOOK_PATHS.stripe : '',
)
const availableTypes = computed(() =>
getAvailableTypes(form.provider_key, props.allPaymentTypes, props.redirectLabel),
)
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(() => {
const selected = parseTypes(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 parseTypes(form.supported_types).includes(type)
}
function toggleType(type: string) {
const current = parseTypes(form.supported_types)
form.supported_types = current.includes(type)
? current.filter(t => t !== type).join(',')
: [...current, type].join(',')
}
function onKeyChange() {
form.supported_types = (PROVIDER_SUPPORTED_TYPES[form.provider_key] || []).join(',')
clearConfig()
applyDefaults()
}
function clearConfig() {
Object.keys(config).forEach(k => delete config[k])
Object.keys(limits).forEach(k => delete limits[k])
}
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) : ''
}
function setLimitVal(paymentType: string, field: string, val: string) {
if (!limits[paymentType]) limits[paymentType] = {}
limits[paymentType][field] = Number(val) || 0
}
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
}
if (!form.supported_types.trim()) {
emitValidationError(t('admin.settings.payment.validationTypesRequired'))
return
}
// Validate required config on create
if (!props.editing) {
for (const f of PROVIDER_CONFIG_FIELDS[form.provider_key] || []) {
if (!f.optional && !(config[f.key] || '').trim()) {
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()) filteredConfig[k] = v
}
emit('save', {
providerKey: form.provider_key,
name: form.name,
supportedTypes: form.supported_types,
enabled: form.enabled,
refundEnabled: 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] || []).join(',')
form.enabled = true
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.refund_enabled = provider.refund_enabled
clearConfig()
// 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 }
}
} catch { /* ignore */ }
}
}
defineExpose({ reset, loadProvider })
</script>
@@ -0,0 +1,118 @@
<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
@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
@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 -->
<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>
<!-- Empty -->
<div v-else 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
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 { useI18n } from 'vue-i18n'
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, parseTypes } 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' | 'refundEnabled']
toggleType: [provider: ProviderInstance, type: string]
}>()
const { t } = useI18n()
function isEnabled(providerKey: string): boolean {
const enabled = parseTypes(props.enabledPaymentTypes)
if (providerKey === 'easypay') {
return enabled.includes('easypay') || enabled.includes('alipay') || enabled.includes('wxpay')
}
if (providerKey === 'alipay') return enabled.includes('alipay')
if (providerKey === 'wxpay') return enabled.includes('wxpay')
return enabled.includes(providerKey)
}
function getTypes(providerKey: string): TypeOption[] {
return getAvailableTypes(providerKey, props.allPaymentTypes, props.redirectLabel)
}
</script>
@@ -0,0 +1,102 @@
<template>
<div :class="[
'rounded-lg border transition-all',
enabled ? 'border-gray-200 dark:border-dark-600' : 'border-gray-200 bg-gray-50 dark:border-dark-700 dark:bg-dark-800/50',
]">
<div :class="[
'flex items-center justify-between px-4 py-2.5',
!enabled && 'opacity-50 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="rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-500 dark:bg-dark-700 dark:text-gray-400">
{{ keyLabel }}
</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', 'refundEnabled')" />
<div class="flex items-center gap-1 border-l border-gray-200 pl-3 dark:border-dark-600">
<button @click="emit('edit')" class="btn-icon text-blue-500 hover:text-blue-700" :title="t('common.edit')">
<Icon name="edit" size="sm" />
</button>
<button @click="emit('delete')" class="btn-icon text-red-500 hover:text-red-700" :title="t('common.delete')">
<Icon name="trash" size="sm" />
</button>
</div>
</div>
</div>
<!-- Disabled hint -->
<div v-if="!enabled" class="border-t border-gray-100 px-4 py-2 dark:border-dark-700">
<span class="text-xs text-amber-500">
{{ t('admin.settings.payment.typeDisabled') }} — {{ t('admin.settings.payment.enableTypesFirst') }}
</span>
</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 { parseTypes } 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' | 'refundEnabled']
toggleType: [type: string]
edit: []
delete: []
}>()
const { t } = useI18n()
const keyLabel = computed(() => t(PROVIDER_KEY_LABELS[props.provider.provider_key] || props.provider.provider_key))
function isSelected(type: string): boolean {
return parseTypes(props.provider.supported_types).includes(type)
}
</script>
@@ -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>
@@ -0,0 +1,105 @@
/**
* 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
}
// --- Constants ---
/** Maps provider key → available payment types. */
export const PROVIDER_SUPPORTED_TYPES: Record<string, string[]> = {
easypay: ['easypay', 'alipay', 'wxpay'],
alipay: ['alipay'],
wxpay: ['wxpay'],
stripe: ['stripe'],
}
/** 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'
const baseURL = typeof window !== 'undefined' ? window.location.origin : ''
/** Per-provider config fields with defaults. */
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: 'notifyUrl', label: '', sensitive: false, defaultValue: baseURL + WEBHOOK_PATHS.easypay },
{ key: 'returnUrl', label: '', sensitive: false, defaultValue: baseURL + RETURN_PATH },
{ 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 },
{ key: 'notifyUrl', label: '', sensitive: false, defaultValue: baseURL + WEBHOOK_PATHS.alipay },
{ key: 'returnUrl', label: '', sensitive: false, defaultValue: baseURL + RETURN_PATH },
],
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 },
{ key: 'notifyUrl', label: '', sensitive: false, defaultValue: baseURL + WEBHOOK_PATHS.wxpay },
],
stripe: [
{ key: 'secretKey', label: '', sensitive: true },
{ key: 'publishableKey', label: '', sensitive: false },
{ key: 'webhookSecret', label: '', sensitive: true },
],
}
// --- Helpers ---
/** Parse comma-separated types string into array. */
export function parseTypes(raw: string): string[] {
return raw.split(',').map(s => s.trim()).filter(Boolean)
}
/** Resolve type label: for easypay provider, show "跳转" for the easypay type. */
export function resolveTypeLabel(
typeVal: string,
providerKey: string,
allTypes: TypeOption[],
redirectLabel: string,
): TypeOption {
if (typeVal === 'easypay' && providerKey === 'easypay') {
return { value: typeVal, label: redirectLabel }
}
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))
}
+5
View File
@@ -4376,6 +4376,11 @@ export default {
field_cidAlipay: 'Alipay Channel ID',
field_cidWxpay: 'WeChat Channel ID',
stripeWebhookHint: 'Configure the following URL as a Webhook endpoint in Stripe Dashboard:',
limitsTitle: 'Limits',
limitSingleMin: 'Min per order',
limitSingleMax: 'Max per order',
limitDaily: 'Daily limit',
limitsHint: 'Leave empty or 0 for no limit',
productNamePrefix: 'Product Name Prefix',
productNameSuffix: 'Product Name Suffix',
preview: 'Preview',
+5
View File
@@ -4549,6 +4549,11 @@ export default {
field_cidAlipay: '支付宝渠道 ID',
field_cidWxpay: '微信渠道 ID',
stripeWebhookHint: '请在 Stripe Dashboard 中将以下地址配置为 Webhook 端点:',
limitsTitle: '限额配置',
limitSingleMin: '单笔最低',
limitSingleMax: '单笔最高',
limitDaily: '每日限额',
limitsHint: '留空或填 0 表示不限制',
productNamePrefix: '商品名前缀',
productNameSuffix: '商品名后缀',
preview: '预览',
+1
View File
@@ -106,6 +106,7 @@ export interface ProviderInstance {
supported_types: string
enabled: boolean
refund_enabled: boolean
limits: string
}
// ==================== Request / Response ====================
+88 -360
View File
@@ -1710,135 +1710,38 @@
</div>
</div>
<!-- Provider Management (inline) -->
<div v-if="form.payment_enabled" class="card">
<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 @click="loadProviders" :disabled="providersLoading" class="btn btn-secondary btn-sm" :title="t('common.refresh')"><Icon name="refresh" size="sm" :class="providersLoading ? 'animate-spin' : ''" /></button>
<button @click="openCreateProvider" :disabled="!hasAnyPaymentTypeEnabled" :class="hasAnyPaymentTypeEnabled ? 'btn btn-primary btn-sm' : 'btn btn-secondary btn-sm cursor-not-allowed opacity-50'">{{ t('admin.settings.payment.createProvider') }}</button>
</div>
</div>
</div>
<div class="p-4">
<div v-if="providersLoading && !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></div>
<div v-else-if="providers.length" class="space-y-3">
<div v-for="provider in providers" :key="provider.id" :class="['rounded-lg border transition-all', isProviderEnabled(provider.provider_key) ? 'border-gray-200 dark:border-dark-600' : 'border-gray-200 bg-gray-50 dark:border-dark-700 dark:bg-dark-800/50']">
<div :class="['flex items-center justify-between px-4 py-2.5', !isProviderEnabled(provider.provider_key) && 'opacity-50 pointer-events-none']">
<!-- Left: icon + name + key badge + supported types badges -->
<div class="flex items-center gap-3">
<div :class="['rounded-md p-1.5', provider.enabled && isProviderEnabled(provider.provider_key) ? 'bg-green-100 dark:bg-green-900/30' : 'bg-gray-100 dark:bg-dark-700']">
<Icon name="server" size="sm" :class="provider.enabled && isProviderEnabled(provider.provider_key) ? '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="rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-500 dark:bg-dark-700 dark:text-gray-400">{{ providerKeyLabel(provider.provider_key) }}</span>
<div v-if="isProviderEnabled(provider.provider_key)" class="flex items-center gap-1">
<button
v-for="pt in getProviderAvailableTypes(provider.provider_key)"
:key="pt.value"
type="button"
@click="toggleCardSupportedType(provider, pt.value)"
:class="[
'rounded px-2 py-0.5 text-xs font-medium transition-all',
isCardTypeSelected(provider, 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">
<label class="flex items-center gap-1.5 cursor-pointer">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('common.enabled') }}</span>
<button type="button" role="switch" :aria-checked="provider.enabled" @click="toggleProviderField(provider, 'enabled')"
:class="['relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors duration-200', provider.enabled ? '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', provider.enabled ? 'translate-x-4' : 'translate-x-0']" />
</button>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.refundEnabled') }}</span>
<button type="button" role="switch" :aria-checked="provider.refund_enabled" @click="toggleProviderField(provider, 'refundEnabled')"
:class="['relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors duration-200', provider.refund_enabled ? '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', provider.refund_enabled ? 'translate-x-4' : 'translate-x-0']" />
</button>
</label>
<div class="flex items-center gap-1 border-l border-gray-200 pl-3 dark:border-dark-600">
<button @click="openEditProvider(provider)" class="btn-icon text-blue-500 hover:text-blue-700" :title="t('common.edit')"><Icon name="edit" size="sm" /></button>
<button @click="confirmDeleteProvider(provider)" class="btn-icon text-red-500 hover:text-red-700" :title="t('common.delete')"><Icon name="trash" size="sm" /></button>
</div>
</div>
</div>
<!-- Disabled overlay hint -->
<div v-if="!isProviderEnabled(provider.provider_key)" class="border-t border-gray-100 px-4 py-2 dark:border-dark-700">
<span class="text-xs text-amber-500">{{ t('admin.settings.payment.typeDisabled') }} — {{ t('admin.settings.payment.enableTypesFirst') }}</span>
</div>
</div>
</div>
<div v-else class="py-6 text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">{{ hasAnyPaymentTypeEnabled ? t('admin.settings.payment.noProviders') : t('admin.settings.payment.enableTypesFirst') }}</p>
<button v-if="hasAnyPaymentTypeEnabled" @click="openCreateProvider" class="btn btn-primary btn-sm mt-2">{{ t('admin.settings.payment.createProvider') }}</button>
</div>
</div>
</div>
<!-- Provider Management -->
<PaymentProviderList
v-if="form.payment_enabled"
:providers="providers"
:loading="providersLoading"
:can-create="hasAnyPaymentTypeEnabled"
:enabled-payment-types="form.payment_enabled_types"
:all-payment-types="allPaymentTypes"
:redirect-label="t('admin.settings.payment.easypayRedirect')"
@refresh="loadProviders"
@create="openCreateProvider"
@edit="openEditProvider"
@delete="confirmDeleteProvider"
@toggle-field="handleToggleField"
@toggle-type="handleToggleType"
/>
</div>
<!-- Provider Create/Edit Dialog -->
<BaseDialog :show="showProviderDialog" :title="editingProvider ? t('admin.settings.payment.editProvider') : t('admin.settings.payment.createProvider')" width="wide" @close="showProviderDialog = false">
<form id="provider-form" @submit.prevent="handleSaveProvider" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div><label class="input-label">{{ t('admin.settings.payment.providerName') }} <span class="text-red-500">*</span></label><input v-model="providerForm.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="providerForm.provider_key" :options="editingProvider ? providerKeyOptions : enabledProviderKeyOptions" :disabled="!!editingProvider" @change="onProviderKeyChange" /></div>
</div>
<!-- Supported types as toggle badges -->
<div>
<label class="input-label">{{ t('admin.settings.payment.supportedTypes') }} <span class="text-red-500">*</span></label>
<div class="mt-2 flex flex-wrap gap-2">
<button
v-for="pt in providerAvailableTypes"
:key="pt.value"
type="button"
@click="toggleProviderSupportedType(pt.value)"
:class="[
'rounded-lg border px-4 py-2 text-sm font-medium transition-all',
isProviderTypeSelected(pt.value)
? 'border-primary-500 bg-primary-500 text-white shadow-sm'
: 'border-gray-300 bg-white text-gray-600 hover:border-gray-400 hover:bg-gray-50 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300 dark:hover:border-dark-500',
]"
>{{ pt.label }}</button>
</div>
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2"><input id="prov-enabled" v-model="providerForm.enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500" /><label for="prov-enabled" class="text-sm text-gray-700 dark:text-gray-300">{{ t('common.enabled') }}</label></div>
<div class="flex items-center gap-2"><input id="prov-refund" v-model="providerForm.refund_enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500" /><label for="prov-refund" class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.settings.payment.refundEnabled') }}</label></div>
</div>
<!-- Dynamic config fields driven by PROVIDER_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 currentProviderFields" :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="providerConfig[field.key]" rows="3" class="input font-mono text-xs" :required="!field.optional && !editingProvider" />
<input v-else :type="field.sensitive ? 'password' : 'text'" v-model="providerConfig[field.key]" class="input" :required="!field.optional && !editingProvider" :placeholder="field.defaultValue || ''" />
</div>
</div>
<!-- Stripe webhook URL hint -->
<div v-if="stripeWebhookHint" 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">{{ stripeWebhookHint }}</code>
</div>
<div v-if="!currentProviderFields.length" class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.selectProviderKey') }}</div>
</div>
</form>
<template #footer><div class="flex justify-end gap-3"><button type="button" @click="showProviderDialog = false" class="btn btn-secondary">{{ t('common.cancel') }}</button><button type="submit" form="provider-form" :disabled="providerSaving" class="btn btn-primary">{{ providerSaving ? t('common.saving') : t('common.save') }}</button></div></template>
</BaseDialog>
<PaymentProviderDialog
ref="providerDialogRef"
:show="showProviderDialog"
:saving="providerSaving"
:editing="editingProvider"
:all-key-options="providerKeyOptions"
:enabled-key-options="enabledProviderKeyOptions"
:all-payment-types="allPaymentTypes"
:redirect-label="t('admin.settings.payment.easypayRedirect')"
@close="showProviderDialog = false"
@save="handleSaveProvider"
/>
<ConfirmDialog :show="showDeleteProviderDialog" :title="t('admin.settings.payment.deleteProvider')" :message="t('admin.settings.payment.deleteProviderConfirm')" :confirm-text="t('common.delete')" danger @confirm="handleDeleteProvider" @cancel="showDeleteProviderDialog = false" />
<div v-show="activeTab === 'email'" class="space-y-6">
<!-- Email disabled hint - show when email_verify_enabled is off -->
@@ -2110,8 +2013,9 @@ import type { ProviderInstance } from '@/types/payment'
import AppLayout from '@/components/layout/AppLayout.vue'
import Icon from '@/components/icons/Icon.vue'
import Select from '@/components/common/Select.vue'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import PaymentProviderList from '@/components/payment/PaymentProviderList.vue'
import PaymentProviderDialog from '@/components/payment/PaymentProviderDialog.vue'
import GroupBadge from '@/components/common/GroupBadge.vue'
import GroupOptionItem from '@/components/common/GroupOptionItem.vue'
import Toggle from '@/components/common/Toggle.vue'
@@ -2931,12 +2835,7 @@ async function saveBetaPolicySettings() {
// ==================== Provider Management ====================
const PROVIDER_SUPPORTED_TYPES: Record<string, string[]> = {
easypay: ['easypay', 'alipay', 'wxpay'],
alipay: ['alipay'],
wxpay: ['wxpay'],
stripe: ['stripe'],
}
import { parseTypes } from '@/components/payment/providerConfig'
const allPaymentTypes = computed(() => [
{ value: 'easypay', label: t('payment.methods.easypay') },
@@ -2946,20 +2845,16 @@ const allPaymentTypes = computed(() => [
])
function isPaymentTypeEnabled(type: string): boolean {
return form.payment_enabled_types.split(',').map(s => s.trim()).filter(Boolean).includes(type)
return parseTypes(form.payment_enabled_types).includes(type)
}
const hasAnyPaymentTypeEnabled = computed(() => {
return form.payment_enabled_types.split(',').map(s => s.trim()).filter(Boolean).length > 0
})
const hasAnyPaymentTypeEnabled = computed(() => parseTypes(form.payment_enabled_types).length > 0)
function togglePaymentType(type: string) {
const current = form.payment_enabled_types.split(',').map(s => s.trim()).filter(Boolean)
if (current.includes(type)) {
form.payment_enabled_types = current.filter(t => t !== type).join(',')
} else {
form.payment_enabled_types = [...current, type].join(',')
}
const current = parseTypes(form.payment_enabled_types)
form.payment_enabled_types = current.includes(type)
? current.filter(t => t !== type).join(',')
: [...current, type].join(',')
}
const providersLoading = ref(false)
@@ -2969,79 +2864,8 @@ const showProviderDialog = ref(false)
const showDeleteProviderDialog = ref(false)
const editingProvider = ref<ProviderInstance | null>(null)
const deletingProviderId = ref<number | null>(null)
const providerDialogRef = ref<InstanceType<typeof PaymentProviderDialog> | null>(null)
const providerForm = reactive({
name: '', provider_key: 'easypay', supported_types: '', enabled: true, refund_enabled: false,
})
const providerConfig = reactive<Record<string, string>>({})
interface ConfigFieldDef {
key: string
label: string
sensitive: boolean
optional?: boolean
defaultValue?: string // auto-fill value for new providers
hint?: string // hint text shown below the field
}
const baseURL = window.location.origin
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',
}
const RETURN_PATH = '/payment/result'
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: 'notifyUrl', label: '', sensitive: false, defaultValue: baseURL + WEBHOOK_PATHS.easypay },
{ key: 'returnUrl', label: '', sensitive: false, defaultValue: baseURL + RETURN_PATH },
{ key: 'cid', label: '', sensitive: false, optional: true },
{ 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 },
{ key: 'notifyUrl', label: '', sensitive: false, defaultValue: baseURL + WEBHOOK_PATHS.alipay },
{ key: 'returnUrl', label: '', sensitive: false, defaultValue: baseURL + RETURN_PATH },
],
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 },
{ key: 'notifyUrl', label: '', sensitive: false, defaultValue: baseURL + WEBHOOK_PATHS.wxpay },
],
stripe: [
{ key: 'secretKey', label: '', sensitive: true },
{ key: 'publishableKey', label: '', sensitive: false },
{ key: 'webhookSecret', label: '', sensitive: true },
],
}
// Stripe webhook URL hint (shown below the config section)
const stripeWebhookHint = computed(() =>
providerForm.provider_key === 'stripe'
? baseURL + WEBHOOK_PATHS.stripe
: ''
)
const currentProviderFields = computed(() => {
const fields = PROVIDER_CONFIG_FIELDS[providerForm.provider_key] || []
return fields.map(f => ({
...f,
label: f.label || t(`admin.settings.payment.field_${f.key}`),
}))
})
const providerKeyOptions = computed(() => [
{ value: 'easypay', label: t('admin.settings.payment.providerEasypay') },
{ value: 'alipay', label: t('admin.settings.payment.providerAlipay') },
@@ -3049,69 +2873,59 @@ const providerKeyOptions = computed(() => [
{ value: 'stripe', label: t('admin.settings.payment.providerStripe') },
])
// Only show provider types whose payment types are enabled above
const enabledProviderKeyOptions = computed(() => {
const enabled = form.payment_enabled_types.split(',').map(s => s.trim()).filter(Boolean)
const enabled = parseTypes(form.payment_enabled_types)
return providerKeyOptions.value.filter(opt => {
// easypay provider is available if easypay, alipay, or wxpay payment type is enabled
if (opt.value === 'easypay') return enabled.includes('easypay') || enabled.includes('alipay') || enabled.includes('wxpay')
// alipay/wxpay direct providers match their payment type
if (opt.value === 'alipay') return enabled.includes('alipay')
if (opt.value === 'wxpay') return enabled.includes('wxpay')
return enabled.includes(opt.value)
})
})
// Check if a provider instance's type is still enabled
function isProviderEnabled(providerKey: string): boolean {
const enabled = form.payment_enabled_types.split(',').map(s => s.trim()).filter(Boolean)
if (providerKey === 'easypay') return enabled.includes('easypay') || enabled.includes('alipay') || enabled.includes('wxpay')
if (providerKey === 'alipay') return enabled.includes('alipay')
if (providerKey === 'wxpay') return enabled.includes('wxpay')
return enabled.includes(providerKey)
}
const loadBalanceOptions = computed(() => [
{ value: 'round-robin', label: t('admin.settings.payment.strategyRoundRobin') },
{ value: 'least-amount', label: t('admin.settings.payment.strategyLeastAmount') },
])
const providerAvailableTypes = computed(() => {
const types = PROVIDER_SUPPORTED_TYPES[providerForm.provider_key] || []
return types.map(typeVal => {
// In EasyPay provider context, show "跳转" instead of "易支付" for the easypay type
if (typeVal === 'easypay' && providerForm.provider_key === 'easypay') {
return { value: typeVal, label: t('admin.settings.payment.easypayRedirect') }
}
const found = allPaymentTypes.value.find(pt => pt.value === typeVal)
return found || { value: typeVal, label: typeVal }
})
})
function isProviderTypeSelected(type: string): boolean {
return providerForm.supported_types.split(',').map(s => s.trim()).filter(Boolean).includes(type)
async function loadProviders() {
providersLoading.value = true
try { const res = await adminAPI.payment.getProviders(); providers.value = res.data || [] }
catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
finally { providersLoading.value = false }
}
function toggleProviderSupportedType(type: string) {
const current = providerForm.supported_types.split(',').map(s => s.trim()).filter(Boolean)
if (current.includes(type)) {
providerForm.supported_types = current.filter(t => t !== type).join(',')
} else {
providerForm.supported_types = [...current, type].join(',')
function openCreateProvider() {
editingProvider.value = null
providerDialogRef.value?.reset(enabledProviderKeyOptions.value[0]?.value || 'easypay')
showProviderDialog.value = true
}
function openEditProvider(provider: ProviderInstance) {
editingProvider.value = provider
providerDialogRef.value?.loadProvider(provider)
showProviderDialog.value = true
}
async function handleSaveProvider(payload: any) {
providerSaving.value = true
try {
if (editingProvider.value) {
await adminAPI.payment.updateProvider(editingProvider.value.id, payload)
} else {
await adminAPI.payment.createProvider(payload)
}
appStore.showSuccess(t('common.saved'))
showProviderDialog.value = false
loadProviders()
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
providerSaving.value = false
}
}
function onProviderKeyChange() {
// Auto-select all supported types for the new provider key
providerForm.supported_types = (PROVIDER_SUPPORTED_TYPES[providerForm.provider_key] || []).join(',')
// Clear config and re-apply defaults
Object.keys(providerConfig).forEach(k => delete providerConfig[k])
applyConfigDefaults(providerForm.provider_key)
}
function providerKeyLabel(key: string): string {
return providerKeyOptions.value.find(o => o.value === key)?.label || key
}
async function toggleProviderField(provider: ProviderInstance, field: 'enabled' | 'refundEnabled') {
async function handleToggleField(provider: ProviderInstance, field: 'enabled' | 'refundEnabled') {
const newValue = field === 'enabled' ? !provider.enabled : !provider.refund_enabled
try {
await adminAPI.payment.updateProvider(provider.id, { [field]: newValue })
@@ -3119,27 +2933,12 @@ async function toggleProviderField(provider: ProviderInstance, field: 'enabled'
else provider.refund_enabled = newValue
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
}
function getProviderAvailableTypes(providerKey: string) {
const types = PROVIDER_SUPPORTED_TYPES[providerKey] || []
return types.map(typeVal => {
if (typeVal === 'easypay' && providerKey === 'easypay') {
return { value: typeVal, label: t('admin.settings.payment.easypayRedirect') }
}
const found = allPaymentTypes.value.find(pt => pt.value === typeVal)
return found || { value: typeVal, label: typeVal }
})
}
function isCardTypeSelected(provider: ProviderInstance, type: string): boolean {
return (provider.supported_types || '').split(',').map(s => s.trim()).filter(Boolean).includes(type)
}
async function toggleCardSupportedType(provider: ProviderInstance, type: string) {
const current = (provider.supported_types || '').split(',').map(s => s.trim()).filter(Boolean)
let updated: string[]
if (current.includes(type)) {
updated = current.filter(t => t !== type)
} else {
updated = [...current, type]
}
async function handleToggleType(provider: ProviderInstance, type: string) {
const current = parseTypes(provider.supported_types)
const updated = current.includes(type)
? current.filter(t => t !== type)
: [...current, type]
if (updated.length === 0) {
appStore.showError(t('admin.settings.payment.validationTypesRequired'))
return
@@ -3150,91 +2949,20 @@ async function toggleCardSupportedType(provider: ProviderInstance, type: string)
provider.supported_types = newVal
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
}
async function loadProviders() {
providersLoading.value = true
try { const res = await adminAPI.payment.getProviders(); providers.value = res.data || [] }
catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
finally { providersLoading.value = false }
}
function resetProviderForm() {
const defaultKey = enabledProviderKeyOptions.value[0]?.value || 'easypay'
providerForm.name = ''; providerForm.provider_key = defaultKey
providerForm.supported_types = (PROVIDER_SUPPORTED_TYPES[defaultKey] || []).join(',')
providerForm.enabled = true; providerForm.refund_enabled = false
Object.keys(providerConfig).forEach(k => delete providerConfig[k])
// Auto-fill default values for new providers
applyConfigDefaults(defaultKey)
function confirmDeleteProvider(provider: ProviderInstance) {
deletingProviderId.value = provider.id
showDeleteProviderDialog.value = true
}
function applyConfigDefaults(providerKey: string) {
const fields = PROVIDER_CONFIG_FIELDS[providerKey] || []
for (const f of fields) {
if (f.defaultValue && !providerConfig[f.key]) {
providerConfig[f.key] = f.defaultValue
}
}
}
function openCreateProvider() { editingProvider.value = null; resetProviderForm(); showProviderDialog.value = true }
function openEditProvider(provider: ProviderInstance) {
editingProvider.value = provider
providerForm.name = provider.name; providerForm.provider_key = provider.provider_key
providerForm.supported_types = provider.supported_types; providerForm.enabled = provider.enabled
providerForm.refund_enabled = provider.refund_enabled
// Clear and don't pre-fill config (sensitive fields, same as sub2apipay)
Object.keys(providerConfig).forEach(k => delete providerConfig[k])
showProviderDialog.value = true
}
async function handleSaveProvider() {
// Strict validation for required fields
if (!providerForm.name.trim()) {
appStore.showError(t('admin.settings.payment.validationNameRequired'))
return
}
if (!providerForm.supported_types.trim()) {
appStore.showError(t('admin.settings.payment.validationTypesRequired'))
return
}
// Validate required config fields
const fields = PROVIDER_CONFIG_FIELDS[providerForm.provider_key] || []
for (const f of fields) {
if (f.optional) continue
const val = (providerConfig[f.key] || '').trim()
// On create: all required fields must be filled
// On edit: sensitive fields can be left empty (backend keeps existing values)
// non-sensitive required fields must be filled if user entered any config
if (!editingProvider.value && !val) {
const label = f.label || t(`admin.settings.payment.field_${f.key}`)
appStore.showError(t('admin.settings.payment.validationFieldRequired', { field: label }))
return
}
}
providerSaving.value = true
try {
// Filter out empty config values to avoid overwriting existing credentials
const filteredConfig: Record<string, string> = {}
for (const [k, v] of Object.entries(providerConfig)) {
if (v && v.trim()) filteredConfig[k] = v
}
// Build camelCase payload matching backend struct
const payload: any = {
providerKey: providerForm.provider_key,
name: providerForm.name,
supportedTypes: providerForm.supported_types,
enabled: providerForm.enabled,
refundEnabled: providerForm.refund_enabled,
config: filteredConfig,
}
if (editingProvider.value) { await adminAPI.payment.updateProvider(editingProvider.value.id, payload) }
else { await adminAPI.payment.createProvider(payload) }
appStore.showSuccess(t('common.saved')); showProviderDialog.value = false; loadProviders()
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
finally { providerSaving.value = false }
}
function confirmDeleteProvider(provider: ProviderInstance) { deletingProviderId.value = provider.id; showDeleteProviderDialog.value = true }
async function handleDeleteProvider() {
if (!deletingProviderId.value) return
try { await adminAPI.payment.deleteProvider(deletingProviderId.value); appStore.showSuccess(t('common.deleted')); showDeleteProviderDialog.value = false; loadProviders() }
catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
try {
await adminAPI.payment.deleteProvider(deletingProviderId.value)
appStore.showSuccess(t('common.deleted'))
showDeleteProviderDialog.value = false
loadProviders()
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
}
onMounted(() => {