cleanup: remove duplicate AdminPaymentConfigView, fix group display

- Delete AdminPaymentConfigView.vue (duplicated in SettingsView Payment tab)
- Remove /admin/orders/config route
- Plans table: show red "缺失/Missing" badge for deleted groups
- Plans table: group_id column shows group name or missing indicator
This commit is contained in:
erio
2026-04-06 16:19:19 +08:00
parent 9b8317fc66
commit 611dcb8ccd
5 changed files with 12 additions and 415 deletions
+1
View File
@@ -5184,6 +5184,7 @@ export default {
tabPlanConfig: 'Plan Configuration',
tabUserSubs: 'User Subscriptions',
selectGroup: 'Select a group',
groupMissing: 'Missing',
groupInfo: 'Group Info',
platform: 'Platform',
rateMultiplierLabel: 'Rate',
+1
View File
@@ -5381,6 +5381,7 @@ export default {
tabPlanConfig: '套餐配置',
tabUserSubs: '用户订阅',
selectGroup: '请选择分组',
groupMissing: '缺失',
groupInfo: '分组信息',
platform: '平台',
rateMultiplierLabel: '倍率',
-12
View File
@@ -450,18 +450,6 @@ const routes: RouteRecordRaw[] = [
requiresPayment: true
}
},
{
path: '/admin/orders/config',
name: 'AdminPaymentConfig',
component: () => import('@/views/admin/orders/AdminPaymentConfigView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Payment Config',
titleKey: 'nav.paymentConfig',
requiresPayment: true
}
},
{
path: '/admin/orders/plans',
name: 'AdminPaymentPlans',
@@ -1,401 +0,0 @@
<template>
<AppLayout>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">{{ t('payment.admin.paymentConfigTitle') }}</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ t('payment.admin.paymentConfigDesc') }}</p>
</div>
</div>
<!-- Basic Settings Card -->
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.settings.payment.title') }}</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.description') }}</p>
</div>
<div class="space-y-6 p-6">
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{ t('admin.settings.payment.enabled') }}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.enabledHint') }}</p>
</div>
<Toggle v-model="configForm.enabled" />
</div>
<template v-if="configForm.enabled">
<div class="grid grid-cols-2 gap-4">
<div><label class="input-label">{{ t('admin.settings.payment.minAmount') }}</label><input v-model.number="configForm.min_amount" type="number" step="0.01" min="0" class="input" /></div>
<div><label class="input-label">{{ t('admin.settings.payment.maxAmount') }}</label><input v-model.number="configForm.max_amount" type="number" step="0.01" min="0" class="input" /></div>
</div>
<div class="grid grid-cols-2 gap-4">
<div><label class="input-label">{{ t('admin.settings.payment.dailyLimit') }}</label><input v-model.number="configForm.daily_limit" type="number" step="0.01" min="0" class="input" /></div>
<div><label class="input-label">{{ t('admin.settings.payment.maxPendingOrders') }}</label><input v-model.number="configForm.max_pending_orders" type="number" min="1" class="input" /></div>
</div>
<div class="grid grid-cols-2 gap-4">
<div><label class="input-label">{{ t('admin.settings.payment.orderTimeout') }}</label><input v-model.number="configForm.order_timeout_minutes" type="number" min="1" class="input" /></div>
<div class="flex items-center gap-2 pt-6">
<input id="balance-disabled" v-model="configForm.balance_disabled" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
<label for="balance-disabled" class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.settings.payment.balancePaymentDisabled') }}</label>
</div>
</div>
<div>
<label class="input-label">{{ t('admin.settings.payment.enabledPaymentTypes') }}</label>
<input v-model="configForm.enabled_types" type="text" class="input" placeholder="alipay,wxpay,stripe" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.enabledPaymentTypesHint') }}</p>
</div>
</template>
<div class="flex justify-end">
<button @click="saveConfig" :disabled="configSaving" class="btn btn-primary">
{{ configSaving ? t('common.saving') : t('common.save') }}
</button>
</div>
</div>
</div>
<!-- Provider Management Card -->
<div v-if="configForm.enabled" class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('payment.admin.providerManagement') }}</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ t('payment.admin.providerManagementDesc') }}</p>
</div>
<div class="flex items-center gap-2">
<button @click="loadProviders" :disabled="providersLoading" class="btn btn-secondary" :title="t('common.refresh')">
<Icon name="refresh" size="md" :class="providersLoading ? 'animate-spin' : ''" />
</button>
<button @click="openCreateDialog" class="btn btn-primary">{{ t('payment.admin.createProvider') }}</button>
</div>
</div>
</div>
<div class="p-6">
<!-- Loading -->
<div v-if="providersLoading && !providers.length" class="flex items-center justify-center py-12"><LoadingSpinner /></div>
<!-- Provider Cards -->
<div v-else-if="providers.length" class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div v-for="provider in providers" :key="provider.id" class="rounded-lg border border-gray-200 dark:border-dark-600">
<div class="flex items-start justify-between p-4">
<div class="flex items-center gap-3">
<div :class="['rounded-lg p-2', provider.enabled ? 'bg-green-100 dark:bg-green-900/30' : 'bg-gray-100 dark:bg-dark-700']">
<Icon name="server" size="md" :class="provider.enabled ? 'text-green-600 dark:text-green-400' : 'text-gray-400'" :stroke-width="2" />
</div>
<div>
<h3 class="font-medium text-gray-900 dark:text-white">{{ provider.name }}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ providerKeyLabel(provider.provider_key) }}</p>
</div>
</div>
<span :class="['badge', provider.enabled ? 'badge-success' : 'badge-secondary']">
{{ provider.enabled ? t('common.enabled') : t('common.disabled') }}
</span>
</div>
<div class="border-t border-gray-100 px-4 py-3 dark:border-dark-700">
<div class="flex flex-wrap gap-2 text-xs">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.admin.supportedTypes') }}:</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ provider.supported_types || '-' }}</span>
</div>
<div class="mt-1 flex items-center gap-2 text-xs">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.admin.refundEnabled') }}:</span>
<span :class="provider.refund_enabled ? 'text-green-600' : 'text-gray-400'">{{ provider.refund_enabled ? 'Yes' : 'No' }}</span>
</div>
</div>
<div class="flex items-center justify-end gap-2 border-t border-gray-100 px-4 py-3 dark:border-dark-700">
<button @click="openEditDialog(provider)" class="btn btn-secondary btn-sm">{{ t('common.edit') }}</button>
<button @click="confirmDelete(provider)" class="btn btn-sm rounded-md bg-red-50 px-3 py-1.5 text-sm text-red-600 hover:bg-red-100 dark:bg-red-900/20 dark:text-red-400 dark:hover:bg-red-900/30">{{ t('common.delete') }}</button>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="py-12 text-center">
<Icon name="server" size="xl" class="mx-auto mb-4 text-gray-300 dark:text-dark-600" />
<h3 class="text-lg font-medium text-gray-900 dark:text-white">{{ t('payment.admin.noProviders') }}</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ t('payment.admin.noProvidersHint') }}</p>
<button @click="openCreateDialog" class="btn btn-primary mt-4">{{ t('payment.admin.createProvider') }}</button>
</div>
</div>
</div>
</div>
<!-- Create/Edit Provider Dialog -->
<BaseDialog :show="showProviderDialog" :title="editingProvider ? t('payment.admin.editProvider') : t('payment.admin.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('payment.admin.providerName') }}</label>
<input v-model="providerForm.name" type="text" class="input" required />
</div>
<div>
<label class="input-label">{{ t('payment.admin.providerKey') }}</label>
<Select v-model="providerForm.provider_key" :options="providerKeyOptions" :disabled="!!editingProvider" />
</div>
</div>
<div>
<label class="input-label">{{ t('payment.admin.supportedTypes') }}</label>
<input v-model="providerForm.supported_types" type="text" class="input" placeholder="alipay,wxpay" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.supportedTypesHint') }}</p>
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<input id="prov-enabled" v-model="providerForm.enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
<label for="prov-enabled" class="text-sm text-gray-700 dark:text-gray-300">{{ t('common.enabled') }}</label>
</div>
<div class="flex items-center gap-2">
<input id="prov-refund" v-model="providerForm.refund_enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
<label for="prov-refund" class="text-sm text-gray-700 dark:text-gray-300">{{ t('payment.admin.refundEnabled') }}</label>
</div>
</div>
<!-- Dynamic 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('payment.admin.providerConfig') }}</h4>
<!-- EasyPay -->
<template v-if="providerForm.provider_key === 'easypay'">
<div class="grid grid-cols-2 gap-4">
<div><label class="input-label">PID</label><input v-model="providerConfig.pid" type="text" class="input" /></div>
<div><label class="input-label">PKey</label><input v-model="providerConfig.pkey" type="password" class="input" /></div>
</div>
<div class="mt-3"><label class="input-label">API Base URL</label><input v-model="providerConfig.apiBase" type="url" class="input" /></div>
<div class="mt-3 grid grid-cols-2 gap-4">
<div><label class="input-label">Notify URL</label><input v-model="providerConfig.notifyUrl" type="url" class="input" /></div>
<div><label class="input-label">Return URL</label><input v-model="providerConfig.returnUrl" type="url" class="input" /></div>
</div>
<div class="mt-3 grid grid-cols-3 gap-4">
<div><label class="input-label">CID</label><input v-model="providerConfig.cid" type="text" class="input" /></div>
<div><label class="input-label">CID Alipay</label><input v-model="providerConfig.cidAlipay" type="text" class="input" /></div>
<div><label class="input-label">CID Wxpay</label><input v-model="providerConfig.cidWxpay" type="text" class="input" /></div>
</div>
</template>
<!-- Alipay -->
<template v-else-if="providerForm.provider_key === 'alipay'">
<div><label class="input-label">App ID</label><input v-model="providerConfig.appId" type="text" class="input" /></div>
<div class="mt-3"><label class="input-label">Private Key</label><textarea v-model="providerConfig.privateKey" rows="3" class="input font-mono text-xs"></textarea></div>
<div class="mt-3"><label class="input-label">Public Key</label><textarea v-model="providerConfig.publicKey" rows="3" class="input font-mono text-xs"></textarea></div>
<div class="mt-3 grid grid-cols-2 gap-4">
<div><label class="input-label">Notify URL</label><input v-model="providerConfig.notifyUrl" type="url" class="input" /></div>
<div><label class="input-label">Return URL</label><input v-model="providerConfig.returnUrl" type="url" class="input" /></div>
</div>
</template>
<!-- Wxpay -->
<template v-else-if="providerForm.provider_key === 'wxpay'">
<div class="grid grid-cols-2 gap-4">
<div><label class="input-label">App ID</label><input v-model="providerConfig.appId" type="text" class="input" /></div>
<div><label class="input-label">Merchant ID</label><input v-model="providerConfig.mchId" type="text" class="input" /></div>
</div>
<div class="mt-3"><label class="input-label">Private Key</label><textarea v-model="providerConfig.privateKey" rows="3" class="input font-mono text-xs"></textarea></div>
<div class="mt-3"><label class="input-label">API V3 Key</label><input v-model="providerConfig.apiV3Key" type="password" class="input" /></div>
<div class="mt-3"><label class="input-label">Public Key</label><textarea v-model="providerConfig.publicKey" rows="2" class="input font-mono text-xs"></textarea></div>
<div class="mt-3 grid grid-cols-2 gap-4">
<div><label class="input-label">Public Key ID</label><input v-model="providerConfig.publicKeyId" type="text" class="input" /></div>
<div><label class="input-label">Cert Serial</label><input v-model="providerConfig.certSerial" type="text" class="input" /></div>
</div>
<div class="mt-3"><label class="input-label">Notify URL</label><input v-model="providerConfig.notifyUrl" type="url" class="input" /></div>
</template>
<!-- Stripe -->
<template v-else-if="providerForm.provider_key === 'stripe'">
<div><label class="input-label">Secret Key</label><input v-model="providerConfig.secretKey" type="password" class="input" /></div>
<div class="mt-3"><label class="input-label">Publishable Key</label><input v-model="providerConfig.publishableKey" type="text" class="input" /></div>
<div class="mt-3"><label class="input-label">Webhook Secret</label><input v-model="providerConfig.webhookSecret" type="password" class="input" /></div>
</template>
<div v-else class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.admin.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>
<ConfirmDialog :show="showDeleteDialog" :title="t('payment.admin.deleteProvider')" :message="t('payment.admin.deleteProviderConfirm')" :confirm-text="t('common.delete')" danger @confirm="handleDeleteProvider" @cancel="showDeleteDialog = false" />
</AppLayout>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminPaymentAPI } from '@/api/admin/payment'
import { extractApiErrorMessage } from '@/utils/apiError'
import type { ProviderInstance } from '@/types/payment'
import AppLayout from '@/components/layout/AppLayout.vue'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Select from '@/components/common/Select.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import Toggle from '@/components/common/Toggle.vue'
import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
const appStore = useAppStore()
// ==================== Basic Config ====================
const configSaving = ref(false)
const configForm = reactive({
enabled: false,
min_amount: 1,
max_amount: 10000,
daily_limit: 50000,
max_pending_orders: 3,
order_timeout_minutes: 30,
balance_disabled: false,
enabled_types: '',
})
async function loadConfig() {
try {
const res = await adminPaymentAPI.getConfig()
const cfg = res.data
configForm.enabled = cfg.enabled
configForm.min_amount = cfg.min_amount
configForm.max_amount = cfg.max_amount
configForm.daily_limit = cfg.daily_limit
configForm.max_pending_orders = cfg.max_pending_orders
configForm.order_timeout_minutes = cfg.order_timeout_minutes
configForm.balance_disabled = cfg.balance_disabled
configForm.enabled_types = (cfg.enabled_payment_types || []).join(',')
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
}
}
async function saveConfig() {
configSaving.value = true
try {
const enabledTypes = configForm.enabled_types
? configForm.enabled_types.split(',').map((s: string) => s.trim()).filter(Boolean)
: []
await adminPaymentAPI.updateConfig({
enabled: configForm.enabled,
min_amount: configForm.min_amount,
max_amount: configForm.max_amount,
daily_limit: configForm.daily_limit,
max_pending_orders: configForm.max_pending_orders,
order_timeout_minutes: configForm.order_timeout_minutes,
balance_disabled: configForm.balance_disabled,
enabled_payment_types: enabledTypes,
})
appStore.showSuccess(t('common.saved'))
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
configSaving.value = false
}
}
// ==================== Provider Management ====================
const providersLoading = ref(false)
const providerSaving = ref(false)
const providers = ref<ProviderInstance[]>([])
const showProviderDialog = ref(false)
const showDeleteDialog = ref(false)
const editingProvider = ref<ProviderInstance | null>(null)
const deletingProviderId = ref<number | null>(null)
const providerForm = reactive({
name: '',
provider_key: 'easypay',
supported_types: '',
enabled: true,
refund_enabled: false,
})
const providerConfig = reactive({
pid: '', pkey: '', apiBase: '', notifyUrl: '', returnUrl: '',
cid: '', cidAlipay: '', cidWxpay: '',
appId: '', privateKey: '', publicKey: '',
mchId: '', apiV3Key: '', publicKeyId: '', certSerial: '',
secretKey: '', publishableKey: '', webhookSecret: '',
})
const providerKeyOptions = [
{ value: 'easypay', label: 'EasyPay' },
{ value: 'alipay', label: 'Alipay (Direct)' },
{ value: 'wxpay', label: 'WeChat Pay (Direct)' },
{ value: 'stripe', label: 'Stripe' },
]
function providerKeyLabel(key: string): string {
const opt = providerKeyOptions.find(o => o.value === key)
return opt ? opt.label : key
}
async function loadProviders() {
providersLoading.value = true
try {
const res = await adminPaymentAPI.getProviders()
providers.value = res.data || []
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally { providersLoading.value = false }
}
function resetProviderForm() {
providerForm.name = ''; providerForm.provider_key = 'easypay'; providerForm.supported_types = ''
providerForm.enabled = true; providerForm.refund_enabled = false
Object.keys(providerConfig).forEach(k => { (providerConfig as any)[k] = '' })
}
function openCreateDialog() {
editingProvider.value = null
resetProviderForm()
showProviderDialog.value = true
}
function openEditDialog(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
Object.keys(providerConfig).forEach(k => { (providerConfig as any)[k] = '' })
showProviderDialog.value = true
}
async function handleSaveProvider() {
providerSaving.value = true
try {
const data = { ...providerForm, config: { ...providerConfig } } as any
if (editingProvider.value) {
await adminPaymentAPI.updateProvider(editingProvider.value.id, data)
} else {
await adminPaymentAPI.createProvider(data)
}
appStore.showSuccess(t('common.saved'))
showProviderDialog.value = false
loadProviders()
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally { providerSaving.value = false }
}
function confirmDelete(provider: ProviderInstance) {
deletingProviderId.value = provider.id
showDeleteDialog.value = true
}
async function handleDeleteProvider() {
if (!deletingProviderId.value) return
try {
await adminPaymentAPI.deleteProvider(deletingProviderId.value)
appStore.showSuccess(t('common.deleted'))
showDeleteDialog.value = false
loadProviders()
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
}
}
onMounted(() => {
loadConfig()
loadProviders()
})
</script>
@@ -12,7 +12,11 @@
<!-- Plans Table -->
<DataTable :columns="planColumns" :data="plans" :loading="plansLoading">
<template #cell-group_id="{ value }">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ groupName(value) }}</span>
<span v-if="isGroupMissing(value)" class="text-sm">
<span class="text-gray-400">#{{ value }}</span>
<span class="ml-1 badge badge-danger">{{ t('payment.admin.groupMissing') }}</span>
</span>
<span v-else class="text-sm text-gray-700 dark:text-gray-300">{{ groupName(value) }}</span>
</template>
<template #cell-price="{ value, row }">
<div class="text-sm">
@@ -125,7 +129,11 @@ async function loadGroups() {
function groupName(id: number): string {
const g = groups.value.find(g => g.id === id)
return g ? `${g.name} (${g.platform})` : `#${id}`
return g ? `${g.name} (${g.platform})` : ''
}
function isGroupMissing(id: number): boolean {
return id > 0 && !groups.value.find(g => g.id === id)
}
const groupOptions = computed(() => [