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:
erio
2026-04-10 21:08:51 +08:00
parent 00c08c574e
commit 63d1860dc0
166 changed files with 42743 additions and 220 deletions

View File

@@ -1947,70 +1947,6 @@
</div>
</div>
<!-- Purchase Subscription Page -->
<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.purchase.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.purchase.description') }}
</p>
</div>
<div class="space-y-6 p-6">
<!-- Enable Toggle -->
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t('admin.settings.purchase.enabled')
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.purchase.enabledHint') }}
</p>
</div>
<Toggle v-model="form.purchase_subscription_enabled" />
</div>
<!-- URL -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.purchase.url') }}
</label>
<input
v-model="form.purchase_subscription_url"
type="url"
class="input font-mono text-sm"
:placeholder="t('admin.settings.purchase.urlPlaceholder')"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.purchase.urlHint') }}
</p>
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
{{ t('admin.settings.purchase.iframeWarning') }}
</p>
</div>
<!-- Integration Docs -->
<div class="flex items-center gap-2 text-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<a
href="https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/docs/ADMIN_PAYMENT_INTEGRATION_API.md"
target="_blank"
rel="noopener noreferrer"
class="text-blue-600 hover:underline dark:text-blue-400"
download="ADMIN_PAYMENT_INTEGRATION_API.md"
>
{{ t('admin.settings.purchase.integrationDoc') }}
</a>
<span class="text-gray-400 dark:text-gray-500"></span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.purchase.integrationDocHint') }}
</span>
</div>
</div>
</div>
<!-- Custom Menu Items -->
<div class="card">
@@ -2136,6 +2072,124 @@
</div><!-- /Tab: General -->
<!-- Tab: Email -->
<!-- Tab: Payment -->
<div v-show="activeTab === 'payment'" class="space-y-6">
<!-- Payment System Settings -->
<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-4 p-6">
<!-- Enable toggle -->
<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="form.payment_enabled" />
</div>
<template v-if="form.payment_enabled">
<!-- Row 1: Product name -->
<div class="grid grid-cols-3 gap-3">
<div><label class="input-label">{{ t('admin.settings.payment.productNamePrefix') }}</label><input v-model="form.payment_product_name_prefix" type="text" class="input" placeholder="Sub2API" /></div>
<div><label class="input-label">{{ t('admin.settings.payment.productNameSuffix') }}</label><input v-model="form.payment_product_name_suffix" type="text" class="input" placeholder="CNY" /></div>
<div><label class="input-label">{{ t('admin.settings.payment.preview') }}</label><div class="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-600 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300">{{ (form.payment_product_name_prefix || 'Sub2API') + ' 100 ' + (form.payment_product_name_suffix || 'CNY') }}</div></div>
</div>
<!-- Row 2: Balance toggle + amounts -->
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div><label class="input-label">{{ t('admin.settings.payment.minAmount') }}</label><input :value="form.payment_min_amount || ''" @input="form.payment_min_amount = parseFloat(($event.target as HTMLInputElement).value) || 0" type="number" step="0.01" min="0" class="input" :placeholder="t('admin.settings.payment.noLimit')" /></div>
<div><label class="input-label">{{ t('admin.settings.payment.maxAmount') }}</label><input :value="form.payment_max_amount || ''" @input="form.payment_max_amount = parseFloat(($event.target as HTMLInputElement).value) || 0" type="number" step="0.01" min="0" class="input" :placeholder="t('admin.settings.payment.noLimit')" /></div>
<div><label class="input-label">{{ t('admin.settings.payment.dailyLimit') }}</label><input :value="form.payment_daily_limit || ''" @input="form.payment_daily_limit = parseFloat(($event.target as HTMLInputElement).value) || 0" type="number" step="0.01" min="0" class="input" :placeholder="t('admin.settings.payment.noLimit')" /></div>
<div><label class="input-label">{{ t('admin.settings.payment.orderTimeout') }} <span class="text-red-500">*</span></label><input v-model.number="form.payment_order_timeout_minutes" type="number" min="1" class="input" required /><p class="mt-0.5 text-xs text-gray-400">{{ t('admin.settings.payment.orderTimeoutHint') }}</p></div>
</div>
<!-- Row 3: Pending orders + load balance + cancel rate limit (all in one row) -->
<div class="flex flex-wrap items-end gap-4">
<div class="w-28"><label class="input-label">{{ t('admin.settings.payment.maxPendingOrders') }}</label><input v-model.number="form.payment_max_pending_orders" type="number" min="1" class="input" /></div>
<div>
<label class="input-label">{{ t('admin.settings.payment.loadBalanceStrategy') }}</label>
<Select v-model="form.payment_load_balance_strategy" :options="loadBalanceOptions" class="w-40" />
</div>
<div>
<label class="input-label">{{ t('admin.settings.payment.cancelRateLimit') }}</label>
<div class="flex items-center gap-2">
<button
type="button"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
form.payment_cancel_rate_limit_enabled ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]"
@click="form.payment_cancel_rate_limit_enabled = !form.payment_cancel_rate_limit_enabled"
>
<span :class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
form.payment_cancel_rate_limit_enabled ? 'translate-x-5' : 'translate-x-0'
]" />
</button>
<Select v-model="form.payment_cancel_rate_limit_window_mode" :options="cancelRateLimitModeOptions" class="w-24" :disabled="!form.payment_cancel_rate_limit_enabled" />
<span :class="['text-sm whitespace-nowrap', form.payment_cancel_rate_limit_enabled ? 'text-gray-700 dark:text-gray-300' : 'text-gray-400 dark:text-gray-600']">{{ t('admin.settings.payment.cancelRateLimitEvery') }}</span>
<input v-model.number="form.payment_cancel_rate_limit_window" type="number" min="1" required class="input w-14 text-center" :disabled="!form.payment_cancel_rate_limit_enabled" />
<Select v-model="form.payment_cancel_rate_limit_unit" :options="cancelRateLimitUnitOptions" class="w-28" :disabled="!form.payment_cancel_rate_limit_enabled" />
<span :class="['text-sm whitespace-nowrap', form.payment_cancel_rate_limit_enabled ? 'text-gray-700 dark:text-gray-300' : 'text-gray-400 dark:text-gray-600']">{{ t('admin.settings.payment.cancelRateLimitAllowMax') }}</span>
<input v-model.number="form.payment_cancel_rate_limit_max" type="number" min="1" required class="input w-14 text-center" :disabled="!form.payment_cancel_rate_limit_enabled" />
<span :class="['text-sm whitespace-nowrap', form.payment_cancel_rate_limit_enabled ? 'text-gray-700 dark:text-gray-300' : 'text-gray-400 dark:text-gray-600']">{{ t('admin.settings.payment.cancelRateLimitTimes') }}</span>
</div>
</div>
</div>
<!-- Row 4: Enabled payment types (provider badges like sub2apipay) -->
<div>
<label class="input-label">{{ t('admin.settings.payment.enabledPaymentTypes') }}</label>
<div class="mt-1.5 flex flex-wrap gap-2">
<button
v-for="pt in allPaymentTypes"
:key="pt.value"
type="button"
@click="togglePaymentType(pt.value)"
:class="[
'rounded-lg border px-3 py-1.5 text-sm font-medium transition-all',
isPaymentTypeEnabled(pt.value)
? 'border-primary-500 bg-primary-500 text-white shadow-sm'
: 'border-gray-300 bg-white text-gray-600 hover:border-gray-400 hover:bg-gray-50 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300 dark:hover:border-dark-500',
]"
>{{ pt.label }}</button>
</div>
</div>
<!-- Row 5: Help image + text -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="input-label">{{ t('admin.settings.payment.helpImage') }}</label>
<ImageUpload v-model="form.payment_help_image_url" :placeholder="t('admin.settings.payment.helpImagePlaceholder')" />
</div>
<div>
<label class="input-label">{{ t('admin.settings.payment.helpText') }}</label>
<textarea v-model="form.payment_help_text" rows="3" class="input" :placeholder="t('admin.settings.payment.helpTextPlaceholder')"></textarea>
</div>
</div>
</template>
</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"
@reorder="handleReorderProviders"
/>
</div>
<div v-show="activeTab === 'email'" class="space-y-6">
<!-- Email disabled hint - show when email_verify_enabled is off -->
<div v-if="!form.email_verify_enabled" class="card">
@@ -2388,6 +2442,21 @@
</button>
</div>
</form>
<!-- Provider dialogs placed outside the settings form to prevent form submission bubbling -->
<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>
</AppLayout>
</template>
@@ -2402,15 +2471,20 @@ import type {
DefaultSubscriptionSetting
} from '@/api/admin/settings'
import type { AdminGroup } from '@/types'
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 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'
import ImageUpload from '@/components/common/ImageUpload.vue'
import BackupSettings from '@/views/admin/BackupView.vue'
import { useClipboard } from '@/composables/useClipboard'
import { extractApiErrorMessage } from '@/utils/apiError'
import { useAppStore } from '@/stores'
import { useAdminSettingsStore } from '@/stores/adminSettings'
import {
@@ -2424,13 +2498,14 @@ const { t } = useI18n()
const appStore = useAppStore()
const adminSettingsStore = useAdminSettingsStore()
type SettingsTab = 'general' | 'security' | 'users' | 'gateway' | 'email' | 'backup'
type SettingsTab = 'general' | 'security' | 'users' | 'gateway' | 'payment' | 'email' | 'backup' | 'data'
const activeTab = ref<SettingsTab>('general')
const settingsTabs = [
{ key: 'general' as SettingsTab, icon: 'home' as const },
{ key: 'security' as SettingsTab, icon: 'shield' as const },
{ key: 'users' as SettingsTab, icon: 'user' as const },
{ key: 'gateway' as SettingsTab, icon: 'server' as const },
{ key: 'payment' as SettingsTab, icon: 'creditCard' as const },
{ key: 'email' as SettingsTab, icon: 'mail' as const },
{ key: 'backup' as SettingsTab, icon: 'database' as const },
]
@@ -2537,8 +2612,8 @@ const form = reactive<SettingsForm>({
home_content: '',
backend_mode_enabled: false,
hide_ccs_import_button: false,
purchase_subscription_enabled: false,
purchase_subscription_url: '',
payment_enabled: false, payment_min_amount: 1, payment_max_amount: 10000, payment_daily_limit: 50000, payment_max_pending_orders: 3, payment_order_timeout_minutes: 30, payment_balance_disabled: false, payment_enabled_types: [], payment_help_image_url: '', payment_help_text: '', payment_product_name_prefix: '', payment_product_name_suffix: '', payment_load_balance_strategy: 'round-robin', payment_cancel_rate_limit_enabled: false, payment_cancel_rate_limit_max: 10, payment_cancel_rate_limit_window: 1, payment_cancel_rate_limit_unit: 'day', payment_cancel_rate_limit_window_mode: 'rolling',
sora_client_enabled: false,
custom_menu_items: [] as Array<{id: string; label: string; icon_svg: string; url: string; visibility: 'user' | 'admin'; sort_order: number}>,
custom_endpoints: [] as Array<{name: string; endpoint: string; description: string}>,
frontend_url: '',
@@ -2767,7 +2842,13 @@ async function loadSettings() {
loadFailed.value = false
try {
const settings = await adminAPI.settings.getSettings()
Object.assign(form, settings)
settings.payment_load_balance_strategy = settings.payment_load_balance_strategy || 'round-robin'
// Only assign non-null values from backend (null means unconfigured, keep defaults)
for (const [key, value] of Object.entries(settings)) {
if (value !== null && value !== undefined) {
(form as Record<string, unknown>)[key] = value
}
}
form.backend_mode_enabled = settings.backend_mode_enabled
form.default_subscriptions = Array.isArray(settings.default_subscriptions)
? settings.default_subscriptions
@@ -2786,11 +2867,9 @@ async function loadSettings() {
form.turnstile_secret_key = ''
form.linuxdo_connect_client_secret = ''
form.oidc_connect_client_secret = ''
} catch (error: any) {
} catch (error: unknown) {
loadFailed.value = true
appStore.showError(
t('admin.settings.failedToLoad') + ': ' + (error.message || t('common.unknownError'))
)
appStore.showError(extractApiErrorMessage(error, t('admin.settings.failedToLoad')))
} finally {
loading.value = false
}
@@ -2802,8 +2881,7 @@ async function loadSubscriptionGroups() {
subscriptionGroups.value = groups.filter(
(group) => group.subscription_type === 'subscription' && group.status === 'active'
)
} catch (error) {
console.error('Failed to load subscription groups:', error)
} catch (_error: unknown) {
subscriptionGroups.value = []
}
}
@@ -2863,21 +2941,6 @@ async function saveSettings() {
// Optional URL fields: auto-clear invalid values so they don't cause backend 400 errors
if (!isValidHttpUrl(form.frontend_url)) form.frontend_url = ''
if (!isValidHttpUrl(form.doc_url)) form.doc_url = ''
// Purchase URL: required when enabled; auto-clear when disabled to avoid backend rejection
if (form.purchase_subscription_enabled) {
if (!form.purchase_subscription_url) {
appStore.showError(t('admin.settings.purchase.url') + ': URL is required when purchase is enabled')
saving.value = false
return
}
if (!isValidHttpUrl(form.purchase_subscription_url)) {
appStore.showError(t('admin.settings.purchase.url') + ': must be an absolute http(s) URL (e.g. https://example.com)')
saving.value = false
return
}
} else if (!isValidHttpUrl(form.purchase_subscription_url)) {
form.purchase_subscription_url = ''
}
const payload: UpdateSettingsRequest = {
registration_enabled: form.registration_enabled,
@@ -2901,8 +2964,6 @@ async function saveSettings() {
home_content: form.home_content,
backend_mode_enabled: form.backend_mode_enabled,
hide_ccs_import_button: form.hide_ccs_import_button,
purchase_subscription_enabled: form.purchase_subscription_enabled,
purchase_subscription_url: form.purchase_subscription_url,
custom_menu_items: form.custom_menu_items,
custom_endpoints: form.custom_endpoints,
frontend_url: form.frontend_url,
@@ -2954,10 +3015,34 @@ async function saveSettings() {
allow_ungrouped_key_scheduling: form.allow_ungrouped_key_scheduling,
enable_fingerprint_unification: form.enable_fingerprint_unification,
enable_metadata_passthrough: form.enable_metadata_passthrough,
enable_cch_signing: form.enable_cch_signing
enable_cch_signing: form.enable_cch_signing,
// Payment configuration
payment_enabled: form.payment_enabled,
payment_min_amount: Number(form.payment_min_amount) || 0,
payment_max_amount: Number(form.payment_max_amount) || 0,
payment_daily_limit: Number(form.payment_daily_limit) || 0,
payment_max_pending_orders: Number(form.payment_max_pending_orders) || 0,
payment_order_timeout_minutes: Number(form.payment_order_timeout_minutes) || 0,
payment_balance_disabled: form.payment_balance_disabled,
payment_enabled_types: form.payment_enabled_types,
payment_load_balance_strategy: form.payment_load_balance_strategy,
payment_product_name_prefix: form.payment_product_name_prefix,
payment_product_name_suffix: form.payment_product_name_suffix,
payment_help_image_url: form.payment_help_image_url,
payment_help_text: form.payment_help_text,
payment_cancel_rate_limit_enabled: form.payment_cancel_rate_limit_enabled,
payment_cancel_rate_limit_max: Number(form.payment_cancel_rate_limit_max) || 10,
payment_cancel_rate_limit_window: Number(form.payment_cancel_rate_limit_window) || 1,
payment_cancel_rate_limit_unit: form.payment_cancel_rate_limit_unit,
payment_cancel_rate_limit_window_mode: form.payment_cancel_rate_limit_window_mode,
}
const updated = await adminAPI.settings.updateSettings(payload)
Object.assign(form, updated)
for (const [key, value] of Object.entries(updated)) {
if (value !== null && value !== undefined) {
(form as Record<string, unknown>)[key] = value
}
}
registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains(
updated.registration_email_suffix_whitelist
)
@@ -2971,10 +3056,8 @@ async function saveSettings() {
await appStore.fetchPublicSettings(true)
await adminSettingsStore.fetch(true)
appStore.showSuccess(t('admin.settings.settingsSaved'))
} catch (error: any) {
appStore.showError(
t('admin.settings.failedToSave') + ': ' + (error.message || t('common.unknownError'))
)
} catch (error: unknown) {
appStore.showError(extractApiErrorMessage(error, t('admin.settings.failedToSave')))
} finally {
saving.value = false
}
@@ -2993,10 +3076,8 @@ async function testSmtpConnection() {
})
// API returns { message: "..." } on success, errors are thrown as exceptions
appStore.showSuccess(result.message || t('admin.settings.smtpConnectionSuccess'))
} catch (error: any) {
appStore.showError(
t('admin.settings.failedToTestSmtp') + ': ' + (error.message || t('common.unknownError'))
)
} catch (error: unknown) {
appStore.showError(extractApiErrorMessage(error, t('admin.settings.failedToTestSmtp')))
} finally {
testingSmtp.value = false
}
@@ -3023,10 +3104,8 @@ async function sendTestEmail() {
})
// API returns { message: "..." } on success, errors are thrown as exceptions
appStore.showSuccess(result.message || t('admin.settings.testEmailSent'))
} catch (error: any) {
appStore.showError(
t('admin.settings.failedToSendTestEmail') + ': ' + (error.message || t('common.unknownError'))
)
} catch (error: unknown) {
appStore.showError(extractApiErrorMessage(error, t('admin.settings.failedToSendTestEmail')))
} finally {
sendingTestEmail.value = false
}
@@ -3039,8 +3118,8 @@ async function loadAdminApiKey() {
const status = await adminAPI.settings.getAdminApiKey()
adminApiKeyExists.value = status.exists
adminApiKeyMasked.value = status.masked_key
} catch (error: any) {
console.error('Failed to load admin API key status:', error)
} catch (_error: unknown) {
// Silent fail - admin API key status is non-critical
} finally {
adminApiKeyLoading.value = false
}
@@ -3054,8 +3133,8 @@ async function createAdminApiKey() {
adminApiKeyExists.value = true
adminApiKeyMasked.value = result.key.substring(0, 10) + '...' + result.key.slice(-4)
appStore.showSuccess(t('admin.settings.adminApiKey.keyGenerated'))
} catch (error: any) {
appStore.showError(error.message || t('common.error'))
} catch (error: unknown) {
appStore.showError(extractApiErrorMessage(error, t('common.error')))
} finally {
adminApiKeyOperating.value = false
}
@@ -3075,8 +3154,8 @@ async function deleteAdminApiKey() {
adminApiKeyMasked.value = ''
newAdminApiKey.value = ''
appStore.showSuccess(t('admin.settings.adminApiKey.keyDeleted'))
} catch (error: any) {
appStore.showError(error.message || t('common.error'))
} catch (error: unknown) {
appStore.showError(extractApiErrorMessage(error, t('common.error')))
} finally {
adminApiKeyOperating.value = false
}
@@ -3099,8 +3178,8 @@ async function loadOverloadCooldownSettings() {
try {
const settings = await adminAPI.settings.getOverloadCooldownSettings()
Object.assign(overloadCooldownForm, settings)
} catch (error: any) {
console.error('Failed to load overload cooldown settings:', error)
} catch (_error: unknown) {
// Silent fail - settings will use defaults
} finally {
overloadCooldownLoading.value = false
}
@@ -3115,10 +3194,8 @@ async function saveOverloadCooldownSettings() {
})
Object.assign(overloadCooldownForm, updated)
appStore.showSuccess(t('admin.settings.overloadCooldown.saved'))
} catch (error: any) {
appStore.showError(
t('admin.settings.overloadCooldown.saveFailed') + ': ' + (error.message || t('common.unknownError'))
)
} catch (error: unknown) {
appStore.showError(extractApiErrorMessage(error, t('admin.settings.overloadCooldown.saveFailed')))
} finally {
overloadCooldownSaving.value = false
}
@@ -3130,8 +3207,8 @@ async function loadStreamTimeoutSettings() {
try {
const settings = await adminAPI.settings.getStreamTimeoutSettings()
Object.assign(streamTimeoutForm, settings)
} catch (error: any) {
console.error('Failed to load stream timeout settings:', error)
} catch (_error: unknown) {
// Silent fail - settings will use defaults
} finally {
streamTimeoutLoading.value = false
}
@@ -3149,10 +3226,8 @@ async function saveStreamTimeoutSettings() {
})
Object.assign(streamTimeoutForm, updated)
appStore.showSuccess(t('admin.settings.streamTimeout.saved'))
} catch (error: any) {
appStore.showError(
t('admin.settings.streamTimeout.saveFailed') + ': ' + (error.message || t('common.unknownError'))
)
} catch (error: unknown) {
appStore.showError(extractApiErrorMessage(error, t('admin.settings.streamTimeout.saveFailed')))
} finally {
streamTimeoutSaving.value = false
}
@@ -3168,8 +3243,8 @@ async function loadRectifierSettings() {
if (!Array.isArray(rectifierForm.apikey_signature_patterns)) {
rectifierForm.apikey_signature_patterns = []
}
} catch (error: any) {
console.error('Failed to load rectifier settings:', error)
} catch (_error: unknown) {
// Silent fail - settings will use defaults
} finally {
rectifierLoading.value = false
}
@@ -3192,10 +3267,8 @@ async function saveRectifierSettings() {
rectifierForm.apikey_signature_patterns = []
}
appStore.showSuccess(t('admin.settings.rectifier.saved'))
} catch (error: any) {
appStore.showError(
t('admin.settings.rectifier.saveFailed') + ': ' + (error.message || t('common.unknownError'))
)
} catch (error: unknown) {
appStore.showError(extractApiErrorMessage(error, t('admin.settings.rectifier.saveFailed')))
} finally {
rectifierSaving.value = false
}
@@ -3267,8 +3340,8 @@ async function loadBetaPolicySettings() {
try {
const settings = await adminAPI.settings.getBetaPolicySettings()
betaPolicyForm.rules = settings.rules
} catch (error: any) {
console.error('Failed to load beta policy settings:', error)
} catch (_error: unknown) {
// Silent fail - settings will use defaults
} finally {
betaPolicyLoading.value = false
}
@@ -3296,15 +3369,182 @@ async function saveBetaPolicySettings() {
})
betaPolicyForm.rules = updated.rules
appStore.showSuccess(t('admin.settings.betaPolicy.saved'))
} catch (error: any) {
appStore.showError(
t('admin.settings.betaPolicy.saveFailed') + ': ' + (error.message || t('common.unknownError'))
)
} catch (error: unknown) {
appStore.showError(extractApiErrorMessage(error, t('admin.settings.betaPolicy.saveFailed')))
} finally {
betaPolicySaving.value = false
}
}
// ==================== Provider Management ====================
const allPaymentTypes = computed(() => [
{ value: 'easypay', label: t('payment.methods.easypay') },
{ value: 'alipay', label: t('payment.methods.alipay') },
{ value: 'wxpay', label: t('payment.methods.wxpay') },
{ value: 'stripe', label: t('payment.methods.stripe') },
])
function isPaymentTypeEnabled(type: string): boolean {
return form.payment_enabled_types.includes(type)
}
const hasAnyPaymentTypeEnabled = computed(() => form.payment_enabled_types.length > 0)
function togglePaymentType(type: string) {
if (form.payment_enabled_types.includes(type)) {
form.payment_enabled_types = form.payment_enabled_types.filter(t => t !== type)
// Disable all provider instances matching this type
disableProvidersByType(type)
} else {
form.payment_enabled_types = [...form.payment_enabled_types, type]
}
}
async function disableProvidersByType(type: string) {
const matching = providers.value.filter(p => p.provider_key === type && p.enabled)
for (const p of matching) {
try {
await adminAPI.payment.updateProvider(p.id, { enabled: false })
p.enabled = false
} catch (err: unknown) {
slog('disable provider failed', p.id, err)
}
}
}
function slog(...args: unknown[]) { console.warn('[payment]', ...args) }
const providersLoading = ref(false)
const providerSaving = ref(false)
const providers = ref<ProviderInstance[]>([])
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 providerKeyOptions = computed(() => [
{ value: 'easypay', label: t('admin.settings.payment.providerEasypay') },
{ value: 'alipay', label: t('admin.settings.payment.providerAlipay') },
{ value: 'wxpay', label: t('admin.settings.payment.providerWxpay') },
{ value: 'stripe', label: t('admin.settings.payment.providerStripe') },
])
const enabledProviderKeyOptions = computed(() => {
const enabled = form.payment_enabled_types
return providerKeyOptions.value.filter(opt => enabled.includes(opt.value))
})
const loadBalanceOptions = computed(() => [
{ value: 'round-robin', label: t('admin.settings.payment.strategyRoundRobin') },
{ value: 'least-amount', label: t('admin.settings.payment.strategyLeastAmount') },
])
const cancelRateLimitUnitOptions = computed(() => [
{ value: 'minute', label: t('admin.settings.payment.cancelRateLimitUnitMinute') },
{ value: 'hour', label: t('admin.settings.payment.cancelRateLimitUnitHour') },
{ value: 'day', label: t('admin.settings.payment.cancelRateLimitUnitDay') },
])
const cancelRateLimitModeOptions = computed(() => [
{ value: 'rolling', label: t('admin.settings.payment.cancelRateLimitWindowModeRolling') },
{ value: 'fixed', label: t('admin.settings.payment.cancelRateLimitWindowModeFixed') },
])
const paymentErrorMap = computed(() => ({
PENDING_ORDERS: t('payment.errors.PENDING_ORDERS'),
}))
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 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: Partial<ProviderInstance>) {
providerSaving.value = true
try {
if (editingProvider.value) {
await adminAPI.payment.updateProvider(editingProvider.value.id, payload)
} else {
await adminAPI.payment.createProvider(payload)
}
showProviderDialog.value = false
// Reload full list (API returns decrypted/formatted data with correct sort order)
await loadProviders()
// Auto-save settings so provider changes take effect immediately
await saveSettings()
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error'), paymentErrorMap.value))
} finally {
providerSaving.value = false
}
}
async function handleToggleField(provider: ProviderInstance, field: 'enabled' | 'refund_enabled') {
const newValue = field === 'enabled' ? !provider.enabled : !provider.refund_enabled
try {
await adminAPI.payment.updateProvider(provider.id, { [field]: newValue })
if (field === 'enabled') provider.enabled = newValue
else provider.refund_enabled = newValue
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'), paymentErrorMap.value)) }
}
async function handleToggleType(provider: ProviderInstance, type: string) {
const updated = provider.supported_types.includes(type)
? provider.supported_types.filter(t => t !== type)
: [...provider.supported_types, type]
try {
await adminAPI.payment.updateProvider(provider.id, { supported_types: updated } as any)
provider.supported_types = updated
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'), paymentErrorMap.value)) }
}
function confirmDeleteProvider(provider: ProviderInstance) {
deletingProviderId.value = provider.id
showDeleteProviderDialog.value = true
}
async function handleReorderProviders(updates: { id: number; sort_order: number }[]) {
try {
await Promise.all(
updates.map(u => adminAPI.payment.updateProvider(u.id, { sort_order: u.sort_order } as Partial<ProviderInstance>))
)
// Update local state to match new order
for (const u of updates) {
const p = providers.value.find(p => p.id === u.id)
if (p) p.sort_order = u.sort_order
}
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
loadProviders()
}
}
async function handleDeleteProvider() {
if (!deletingProviderId.value) return
try {
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'), paymentErrorMap.value)) }
}
onMounted(() => {
loadSettings()
loadSubscriptionGroups()
@@ -3313,6 +3553,7 @@ onMounted(() => {
loadStreamTimeoutSettings()
loadRectifierSettings()
loadBetaPolicySettings()
loadProviders()
})
</script>

View File

@@ -0,0 +1,239 @@
<template>
<AppLayout>
<div class="space-y-4">
<!-- Filters -->
<div class="card p-4">
<div class="flex flex-wrap items-center gap-3">
<div class="flex-1 sm:max-w-64">
<input v-model="orderSearch" type="text" :placeholder="t('payment.admin.searchOrders')" class="input" @input="debounceLoadOrders" />
</div>
<Select v-model="orderFilters.status" :options="statusFilterOptions" class="w-36" @change="loadOrders" />
<Select v-model="orderFilters.payment_type" :options="paymentTypeFilterOptions" class="w-40" @change="loadOrders" />
<Select v-model="orderFilters.order_type" :options="orderTypeFilterOptions" class="w-36" @change="loadOrders" />
<div class="flex flex-1 flex-wrap items-center justify-end gap-2">
<button @click="loadOrders" :disabled="ordersLoading" class="btn btn-secondary" :title="t('common.refresh')">
<Icon name="refresh" size="md" :class="ordersLoading ? 'animate-spin' : ''" />
</button>
</div>
</div>
</div>
<!-- Table -->
<OrderTable :orders="orders" :loading="ordersLoading" show-user>
<template #actions="{ row }">
<div class="flex items-center gap-1">
<button @click="showOrderDetail(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-dark-600">
<Icon name="eye" size="sm" />
{{ t('common.view') }}
</button>
<button v-if="row.status === 'PENDING'" @click="handleCancelOrder(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-yellow-600 hover:bg-yellow-50 dark:text-yellow-400 dark:hover:bg-yellow-900/20">
<Icon name="x" size="sm" />
{{ t('payment.orders.cancel') }}
</button>
<button v-if="row.status === 'FAILED'" @click="handleRetryOrder(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/20">
<Icon name="refresh" size="sm" />
{{ t('payment.admin.retry') }}
</button>
<template v-if="row.status === 'REFUND_REQUESTED'">
<span v-if="row.refund_amount" class="rounded-full bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">${{ row.refund_amount.toFixed(2) }}</span>
<button @click="openRefundDialog(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-purple-600 hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-purple-900/20">
<Icon name="check" size="sm" />
{{ t('payment.admin.approveRefund') }}
</button>
</template>
<button v-else-if="row.status === 'REFUND_FAILED'" @click="openRefundDialog(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-purple-600 hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-purple-900/20">
<Icon name="refresh" size="sm" />
{{ t('payment.admin.retryRefund') }}
</button>
<button v-else-if="row.status === 'COMPLETED' || row.status === 'PARTIALLY_REFUNDED'" @click="openRefundDialog(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20">
<Icon name="dollar" size="sm" />
{{ t('payment.admin.refund') }}
</button>
</div>
</template>
</OrderTable>
<Pagination v-if="orderPagination.total > 0" :page="orderPagination.page" :total="orderPagination.total" :page-size="orderPagination.page_size" @update:page="handleOrderPageChange" @update:pageSize="handleOrderPageSizeChange" />
</div>
<!-- Order Detail Dialog -->
<BaseDialog :show="showDetailDialog" :title="t('payment.admin.orderDetail')" width="wide" @close="showDetailDialog = false">
<div v-if="selectedOrder" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</p><p class="font-mono text-sm font-medium text-gray-900 dark:text-white">#{{ selectedOrder.id }}</p></div>
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderNo') }}</p><p class="text-sm font-medium text-gray-900 dark:text-white">{{ selectedOrder.out_trade_no }}</p></div>
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.status') }}</p><OrderStatusBadge :status="selectedOrder.status" /></div>
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</p><p class="text-sm font-medium text-gray-900 dark:text-white">${{ selectedOrder.amount.toFixed(2) }}</p></div>
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</p><p class="text-sm font-medium text-gray-900 dark:text-white">${{ selectedOrder.pay_amount.toFixed(2) }}</p></div>
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</p><p class="text-sm text-gray-700 dark:text-gray-300">{{ t('payment.methods.' + selectedOrder.payment_type, selectedOrder.payment_type) }}</p></div>
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.feeRate') }}</p><p class="text-sm text-gray-700 dark:text-gray-300">{{ (selectedOrder.fee_rate * 100).toFixed(1) }}%</p></div>
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.createdAt') }}</p><p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(selectedOrder.created_at) }}</p></div>
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.expiresAt') }}</p><p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(selectedOrder.expires_at) }}</p></div>
<div v-if="selectedOrder.paid_at"><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.paidAt') }}</p><p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(selectedOrder.paid_at) }}</p></div>
<div v-if="selectedOrder.refund_amount"><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.refundAmount') }}</p><p class="text-sm font-medium text-red-600 dark:text-red-400">${{ selectedOrder.refund_amount.toFixed(2) }}</p></div>
<div v-if="selectedOrder.refund_reason" class="col-span-2"><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.refundReason') }}</p><p class="text-sm text-gray-700 dark:text-gray-300">{{ selectedOrder.refund_reason }}</p></div>
<!-- Refund request info -->
<div v-if="selectedOrder.refund_requested_at" class="col-span-2 border-t border-gray-200 pt-3 dark:border-dark-600">
<p class="mb-2 text-xs font-medium text-purple-600 dark:text-purple-400">{{ t('payment.admin.refundRequestInfo') }}</p>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.refundRequestedAt') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(selectedOrder.refund_requested_at) }}</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.refundRequestedBy') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300">#{{ selectedOrder.refund_requested_by }}</p>
</div>
<div class="col-span-2">
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.refundRequestReason') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300">{{ selectedOrder.refund_request_reason }}</p>
</div>
</div>
</div>
</div>
<!-- Audit Logs -->
<div v-if="orderAuditLogs.length > 0" class="border-t border-gray-200 pt-4 dark:border-dark-600">
<p class="mb-2 text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('payment.admin.auditLogs') }}</p>
<div class="max-h-48 space-y-2 overflow-y-auto">
<div v-for="log in orderAuditLogs" :key="log.id" class="rounded-lg border border-gray-100 bg-gray-50 p-2.5 dark:border-dark-600 dark:bg-dark-800">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ log.action }}</span>
<span class="text-xs text-gray-400">{{ formatDateTime(log.created_at) }}</span>
</div>
<div v-if="log.detail" class="mt-1 break-all text-xs text-gray-500 dark:text-gray-400">{{ log.detail }}</div>
<div v-if="log.operator" class="mt-1 text-xs text-gray-400">{{ t('payment.admin.operator') }}: {{ log.operator }}</div>
</div>
</div>
</div>
</div>
</BaseDialog>
<AdminRefundDialog :show="showRefundDialog" :order="selectedOrder" :submitting="refundSubmitting" @confirm="handleRefund" @cancel="showRefundDialog = false" />
</AppLayout>
</template>
<script setup lang="ts">
import { ref, reactive, computed, 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 { PaymentOrder } from '@/types/payment'
import AppLayout from '@/components/layout/AppLayout.vue'
import Pagination from '@/components/common/Pagination.vue'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
import AdminRefundDialog from '@/components/admin/payment/AdminRefundDialog.vue'
import OrderStatusBadge from '@/components/payment/OrderStatusBadge.vue'
import OrderTable from '@/components/payment/OrderTable.vue'
interface AuditLog {
id: number
action: string
detail: string | null
operator: string | null
created_at: string
}
const { t } = useI18n()
const appStore = useAppStore()
const ordersLoading = ref(false)
const orders = ref<PaymentOrder[]>([])
const orderSearch = ref('')
const orderFilters = reactive({ status: '', payment_type: '', order_type: '' })
const orderPagination = reactive({ page: 1, page_size: 20, total: 0 })
const selectedOrder = ref<PaymentOrder | null>(null)
const showDetailDialog = ref(false)
const showRefundDialog = ref(false)
const refundSubmitting = ref(false)
const orderAuditLogs = ref<AuditLog[]>([])
let debounceTimer: ReturnType<typeof setTimeout> | null = null
function debounceLoadOrders() {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => loadOrders(), 300)
}
async function loadOrders() {
ordersLoading.value = true
try {
const res = await adminPaymentAPI.getOrders({
page: orderPagination.page, page_size: orderPagination.page_size,
keyword: orderSearch.value || undefined, status: orderFilters.status || undefined,
payment_type: orderFilters.payment_type || undefined, order_type: orderFilters.order_type || undefined,
})
orders.value = res.data.items || []
orderPagination.total = res.data.total || 0
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally { ordersLoading.value = false }
}
function handleOrderPageChange(page: number) { orderPagination.page = page; loadOrders() }
function handleOrderPageSizeChange(size: number) { orderPagination.page_size = size; orderPagination.page = 1; loadOrders() }
const statusFilterOptions = computed(() => [
{ value: '', label: t('payment.admin.allStatuses') },
{ value: 'PENDING', label: t('payment.status.pending') },
{ value: 'PAID', label: t('payment.status.paid') },
{ value: 'COMPLETED', label: t('payment.status.completed') },
{ value: 'EXPIRED', label: t('payment.status.expired') },
{ value: 'CANCELLED', label: t('payment.status.cancelled') },
{ value: 'FAILED', label: t('payment.status.failed') },
{ value: 'REFUNDED', label: t('payment.status.refunded') },
{ value: 'REFUND_REQUESTED', label: t('payment.status.refund_requested') },
{ value: 'REFUND_FAILED', label: t('payment.status.refund_failed') },
])
const paymentTypeFilterOptions = computed(() => [
{ value: '', label: t('payment.admin.allPaymentTypes') },
{ value: 'alipay', label: t('payment.methods.alipay') },
{ value: 'wxpay', label: t('payment.methods.wxpay') },
{ value: 'stripe', label: t('payment.methods.stripe') },
])
const orderTypeFilterOptions = computed(() => [
{ value: '', label: t('payment.admin.allOrderTypes') },
{ value: 'balance', label: t('payment.admin.balanceOrder') },
{ value: 'subscription', label: t('payment.admin.subscriptionOrder') },
])
async function showOrderDetail(order: PaymentOrder) {
selectedOrder.value = order
orderAuditLogs.value = []
showDetailDialog.value = true
try {
const res = await adminPaymentAPI.getOrder(order.id)
const data = res.data as unknown as Record<string, unknown>
if (data.order) selectedOrder.value = data.order as PaymentOrder
orderAuditLogs.value = ((data.auditLogs || data.audit_logs || []) as unknown) as AuditLog[]
} catch (_err: unknown) { /* keep cached order data */ }
}
async function handleCancelOrder(order: PaymentOrder) {
try { await adminPaymentAPI.cancelOrder(order.id); appStore.showSuccess(t('payment.admin.orderCancelled')); loadOrders() }
catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
}
async function handleRetryOrder(order: PaymentOrder) {
try { await adminPaymentAPI.retryRecharge(order.id); appStore.showSuccess(t('payment.admin.retrySuccess')); loadOrders() }
catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
}
function openRefundDialog(order: PaymentOrder) { selectedOrder.value = order; showRefundDialog.value = true }
async function handleRefund(data: { amount: number; reason: string; deduct_balance: boolean; force: boolean }) {
if (!selectedOrder.value) return
refundSubmitting.value = true
try {
await adminPaymentAPI.refundOrder(selectedOrder.value.id, { amount: data.amount, reason: data.reason, deduct_balance: data.deduct_balance, force: data.force })
appStore.showSuccess(t('payment.admin.refundSuccess')); showRefundDialog.value = false; loadOrders()
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
finally { refundSubmitting.value = false }
}
function formatDateTime(dateStr: string): string { if (!dateStr) return '-'; return new Date(dateStr).toLocaleString() }
onMounted(() => loadOrders())
</script>

View File

@@ -0,0 +1,121 @@
<template>
<AppLayout>
<div class="space-y-6">
<!-- Header with Day Switcher -->
<div class="flex items-center justify-end">
<div class="flex items-center gap-2">
<div class="flex rounded-lg border border-gray-200 dark:border-dark-600">
<button
v-for="d in DAYS_OPTIONS"
:key="d"
type="button"
class="px-3 py-1.5 text-xs font-medium transition-colors first:rounded-l-lg last:rounded-r-lg"
:class="days === d
? 'bg-primary-600 text-white'
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700'"
@click="days = d"
>
{{ d }}{{ t('payment.admin.daySuffix') }}
</button>
</div>
<button @click="loadDashboard" :disabled="loading" class="btn btn-secondary" :title="t('common.refresh')">
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
</div>
</div>
<!-- Dashboard Content -->
<div v-if="loading" class="flex items-center justify-center py-12">
<LoadingSpinner />
</div>
<template v-else-if="stats">
<OrderStatsCards :stats="stats" />
<DailyRevenueChart :data="stats.daily_series || []" :loading="loading" />
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div class="card p-4">
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">{{ t('payment.admin.paymentDistribution') }}</h3>
<div v-if="!stats.payment_methods?.length" class="flex h-32 items-center justify-center text-sm text-gray-500 dark:text-gray-400">{{ t('payment.admin.noData') }}</div>
<div v-else class="space-y-3">
<div v-for="method in stats.payment_methods" :key="method.type" class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span :class="['inline-block h-3 w-3 rounded-full', methodColor(method.type)]"></span>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('payment.methods.' + method.type, method.type) }}</span>
</div>
<div class="text-right">
<span class="text-sm font-medium text-gray-900 dark:text-white">${{ method.amount.toFixed(2) }}</span>
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">({{ method.count }})</span>
</div>
</div>
</div>
</div>
<div class="card p-4">
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">{{ t('payment.admin.topUsers') }}</h3>
<div v-if="!stats.top_users?.length" class="flex h-32 items-center justify-center text-sm text-gray-500 dark:text-gray-400">{{ t('payment.admin.noData') }}</div>
<div v-else class="space-y-2">
<div v-for="(user, idx) in stats.top_users" :key="user.user_id" class="flex items-center justify-between rounded-lg px-3 py-2 hover:bg-gray-50 dark:hover:bg-dark-700">
<div class="flex items-center gap-3">
<span :class="['flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold', rankClass(idx)]">{{ idx + 1 }}</span>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ user.email }}</span>
</div>
<span class="text-sm font-medium text-gray-900 dark:text-white">${{ user.amount.toFixed(2) }}</span>
</div>
</div>
</div>
</div>
</template>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, watch, 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 { DashboardStats } from '@/types/payment'
import AppLayout from '@/components/layout/AppLayout.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import Icon from '@/components/icons/Icon.vue'
import OrderStatsCards from '@/components/admin/payment/OrderStatsCards.vue'
import DailyRevenueChart from '@/components/admin/payment/DailyRevenueChart.vue'
const { t } = useI18n()
const appStore = useAppStore()
const DAYS_OPTIONS = [7, 30, 90] as const
const days = ref<number>(30)
const loading = ref(false)
const stats = ref<DashboardStats | null>(null)
function methodColor(type: string): string {
const c: Record<string, string> = {
alipay: 'bg-blue-500', wxpay: 'bg-green-500',
alipay_direct: 'bg-blue-400', wxpay_direct: 'bg-green-400',
stripe: 'bg-purple-500',
}
return c[type] || 'bg-gray-400'
}
function rankClass(idx: number): string {
if (idx === 0) return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
if (idx === 1) return 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
if (idx === 2) return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
return 'bg-gray-100 text-gray-500 dark:bg-dark-700 dark:text-gray-400'
}
async function loadDashboard() {
loading.value = true
try {
const res = await adminPaymentAPI.getDashboard(days.value)
stats.value = res.data
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
loading.value = false
}
}
watch(days, () => loadDashboard())
onMounted(() => loadDashboard())
</script>

View File

@@ -0,0 +1,317 @@
<template>
<AppLayout>
<div class="space-y-4">
<!-- Actions -->
<div class="flex items-center justify-end gap-2">
<button @click="loadPlans" :disabled="plansLoading" class="btn btn-secondary" :title="t('common.refresh')">
<Icon name="refresh" size="md" :class="plansLoading ? 'animate-spin' : ''" />
</button>
<button @click="openPlanEdit(null)" class="btn btn-primary">{{ t('payment.admin.createPlan') }}</button>
</div>
<!-- Plans Table -->
<DataTable :columns="planColumns" :data="plans" :loading="plansLoading">
<template #cell-name="{ value, row }">
<span class="text-sm font-medium" :class="getPlanNameClass(row.group_id)">{{ value }}</span>
</template>
<template #cell-group_id="{ value }">
<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>
<GroupBadge
v-else-if="getGroup(value)"
:name="getGroup(value)!.name"
:platform="getGroup(value)!.platform"
:rate-multiplier="getGroup(value)!.rate_multiplier"
/>
<span v-else class="text-sm text-gray-400">-</span>
</template>
<template #cell-price="{ value, row }">
<div class="text-sm">
<span class="font-medium text-gray-900 dark:text-white">${{ value.toFixed(2) }}</span>
<span v-if="row.original_price" class="ml-1 text-xs text-gray-400 line-through">${{ row.original_price.toFixed(2) }}</span>
</div>
</template>
<template #cell-validity_days="{ value, row }">
<span class="text-sm">{{ value }} {{ t('payment.admin.' + (row.validity_unit || 'days')) }}</span>
</template>
<template #cell-for_sale="{ value, row }">
<button
type="button"
:class="[
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
value ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]"
@click="toggleForSale(row)"
>
<span :class="[
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
value ? 'translate-x-4' : 'translate-x-0'
]" />
</button>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center gap-2">
<button @click="openPlanEdit(row)" 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 @click="confirmDeletePlan(row)" 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>
</template>
</DataTable>
</div>
<!-- Plan Edit Dialog -->
<BaseDialog :show="showPlanDialog" :title="editingPlan ? t('payment.admin.editPlan') : t('payment.admin.createPlan')" width="wide" @close="showPlanDialog = false">
<form id="plan-form" @submit.prevent="handleSavePlan" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">{{ t('payment.admin.planName') }}</label>
<input v-model="planForm.name" type="text" class="input" required />
</div>
<div>
<label class="input-label">{{ t('payment.admin.group') }}</label>
<Select v-model="planForm.group_id" :options="groupOptions" class="w-full">
<template #selected="{ option }">
<span v-if="option?.platform" :class="platformTextClass(String(option.platform))">{{ option.label }}</span>
<span v-else>{{ option?.label || t('payment.admin.selectGroup') }}</span>
</template>
<template #option="{ option, selected }">
<span class="flex-1 truncate text-left" :class="option.platform ? platformTextClass(String(option.platform)) : ''">{{ option.label }}</span>
<Icon v-if="selected" name="check" size="sm" class="text-primary-500" :stroke-width="2" />
</template>
</Select>
</div>
</div>
<!-- Group Info Preview -->
<div v-if="selectedGroupInfo" class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-dark-600 dark:bg-dark-800">
<div class="mb-2 flex items-center gap-2">
<GroupBadge :name="selectedGroupInfo.name" :platform="selectedGroupInfo.platform" :rate-multiplier="selectedGroupInfo.rate_multiplier" />
</div>
<div class="grid grid-cols-2 gap-2 text-xs">
<div><span class="text-gray-500">{{ t('payment.admin.dailyLimit') }}:</span> <span class="ml-1 font-medium text-gray-700 dark:text-gray-300">{{ selectedGroupInfo.daily_limit_usd != null ? '$' + selectedGroupInfo.daily_limit_usd : t('payment.admin.unlimited') }}</span></div>
<div><span class="text-gray-500">{{ t('payment.admin.weeklyLimit') }}:</span> <span class="ml-1 font-medium text-gray-700 dark:text-gray-300">{{ selectedGroupInfo.weekly_limit_usd != null ? '$' + selectedGroupInfo.weekly_limit_usd : t('payment.admin.unlimited') }}</span></div>
<div><span class="text-gray-500">{{ t('payment.admin.monthlyLimit') }}:</span> <span class="ml-1 font-medium text-gray-700 dark:text-gray-300">{{ selectedGroupInfo.monthly_limit_usd != null ? '$' + selectedGroupInfo.monthly_limit_usd : t('payment.admin.unlimited') }}</span></div>
</div>
</div>
<div><label class="input-label">{{ t('payment.admin.planDescription') }}</label><textarea v-model="planForm.description" rows="2" class="input"></textarea></div>
<div class="grid grid-cols-3 gap-4">
<div><label class="input-label">{{ t('payment.admin.price') }}</label><input v-model.number="planForm.price" type="number" step="0.01" min="0" class="input" required /></div>
<div><label class="input-label">{{ t('payment.admin.originalPrice') }}</label><input v-model.number="planForm.original_price" type="number" step="0.01" min="0" class="input" /></div>
<div><label class="input-label">{{ t('payment.admin.sortOrder') }}</label><input v-model.number="planForm.sort_order" type="number" min="0" class="input" /></div>
</div>
<div class="grid grid-cols-2 gap-4">
<div><label class="input-label">{{ t('payment.admin.validityDays') }}</label><input v-model.number="planForm.validity_days" type="number" min="1" class="input" required /></div>
<div><label class="input-label">{{ t('payment.admin.validityUnit') }}</label><Select v-model="planForm.validity_unit" :options="validityUnitOptions" /></div>
</div>
<div>
<label class="input-label">{{ t('payment.admin.features') }}</label>
<textarea v-model="planFeaturesText" rows="3" class="input" :placeholder="t('payment.admin.featuresPlaceholder')"></textarea>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.featuresHint') }}</p>
</div>
<div class="flex items-center gap-3">
<label class="text-sm text-gray-700 dark:text-gray-300">{{ t('payment.admin.forSale') }}</label>
<button
type="button"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
planForm.for_sale ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]"
@click="planForm.for_sale = !planForm.for_sale"
>
<span :class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
planForm.for_sale ? 'translate-x-5' : 'translate-x-0'
]" />
</button>
</div>
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button type="button" @click="showPlanDialog = false" class="btn btn-secondary">{{ t('common.cancel') }}</button>
<button type="submit" form="plan-form" :disabled="planSaving" class="btn btn-primary">{{ planSaving ? t('common.saving') : t('common.save') }}</button>
</div>
</template>
</BaseDialog>
<ConfirmDialog :show="showDeletePlanDialog" :title="t('payment.admin.deletePlan')" :message="t('payment.admin.deletePlanConfirm')" :confirm-text="t('common.delete')" danger @confirm="handleDeletePlan" @cancel="showDeletePlanDialog = false" />
</AppLayout>
</template>
<script setup lang="ts">
import { ref, reactive, computed, 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 adminAPI from '@/api/admin'
import type { SubscriptionPlan } from '@/types/payment'
import type { AdminGroup } from '@/types'
import type { Column } from '@/components/common/types'
import AppLayout from '@/components/layout/AppLayout.vue'
import DataTable from '@/components/common/DataTable.vue'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
import GroupBadge from '@/components/common/GroupBadge.vue'
import { platformTextClass } from '@/utils/platformColors'
const { t } = useI18n()
const appStore = useAppStore()
// ==================== Groups ====================
const groups = ref<AdminGroup[]>([])
async function loadGroups() {
try {
groups.value = await adminAPI.groups.getAll()
} catch { /* ignore */ }
}
function getGroup(id: number): AdminGroup | undefined {
return groups.value.find(g => g.id === id)
}
function isGroupMissing(id: number): boolean {
return id > 0 && !groups.value.find(g => g.id === id)
}
function getPlanNameClass(groupId: number): string {
const group = getGroup(groupId)
return group ? platformTextClass(group.platform) : 'text-gray-900 dark:text-white'
}
const groupOptions = computed(() => [
{ value: 0, label: t('payment.admin.selectGroup'), platform: '' },
...groups.value
.filter(g => g.subscription_type === 'subscription')
.map(g => ({
value: g.id,
label: `${g.name}${g.platform} (${g.rate_multiplier}x)`,
platform: g.platform,
})),
])
const selectedGroupInfo = computed(() => {
if (!planForm.group_id) return null
return groups.value.find(g => g.id === planForm.group_id) || null
})
// ==================== Plans ====================
const plansLoading = ref(false)
const plans = ref<SubscriptionPlan[]>([])
const showPlanDialog = ref(false)
const showDeletePlanDialog = ref(false)
const planSaving = ref(false)
const editingPlan = ref<SubscriptionPlan | null>(null)
const deletingPlanId = ref<number | null>(null)
const planForm = reactive({ name: '', group_id: 0, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', for_sale: true, sort_order: 0 })
const planFeaturesText = ref('')
const validityUnitOptions = computed(() => [
{ value: 'days', label: t('payment.admin.days') },
{ value: 'weeks', label: t('payment.admin.weeks') },
{ value: 'months', label: t('payment.admin.months') },
])
const planColumns = computed((): Column[] => [
{ key: 'id', label: 'ID' },
{ key: 'name', label: t('payment.admin.planName') },
{ key: 'group_id', label: t('payment.admin.group') },
{ key: 'price', label: t('payment.admin.price') },
{ key: 'validity_days', label: t('payment.admin.validityDays') },
{ key: 'for_sale', label: t('payment.admin.forSale') },
{ key: 'sort_order', label: t('payment.admin.sortOrder') },
{ key: 'actions', label: t('common.actions') },
])
async function loadPlans() {
plansLoading.value = true
try {
const res = await adminPaymentAPI.getPlans()
// Backend returns features as newline-separated string; parse to array
plans.value = (res.data || []).map((p: Omit<SubscriptionPlan, 'features'> & { features: string | string[] }) => ({
...p,
features: typeof p.features === 'string'
? p.features.split('\n').map((f: string) => f.trim()).filter(Boolean)
: (p.features || []),
}))
}
catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
finally { plansLoading.value = false }
}
function openPlanEdit(plan: SubscriptionPlan | null) {
editingPlan.value = plan
if (plan) {
Object.assign(planForm, { name: plan.name, group_id: plan.group_id, description: plan.description, price: plan.price, original_price: plan.original_price || 0, validity_days: plan.validity_days, validity_unit: plan.validity_unit || 'days', for_sale: plan.for_sale, sort_order: plan.sort_order })
planFeaturesText.value = (plan.features || []).join('\n')
} else {
Object.assign(planForm, { name: '', group_id: 0, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', for_sale: true, sort_order: 0 })
planFeaturesText.value = ''
}
showPlanDialog.value = true
}
/** Build request payload with snake_case keys matching backend JSON tags */
function buildPlanPayload() {
const features = planFeaturesText.value.split('\n').map(f => f.trim()).filter(Boolean).join('\n')
return {
name: planForm.name,
group_id: planForm.group_id,
description: planForm.description,
price: planForm.price,
original_price: planForm.original_price || 0,
validity_days: planForm.validity_days,
validity_unit: planForm.validity_unit,
for_sale: planForm.for_sale,
sort_order: planForm.sort_order,
features,
}
}
async function handleSavePlan() {
planSaving.value = true
try {
const data = buildPlanPayload()
if (editingPlan.value) { await adminPaymentAPI.updatePlan(editingPlan.value.id, data) }
else { await adminPaymentAPI.createPlan(data) }
appStore.showSuccess(t('common.saved')); showPlanDialog.value = false; loadPlans()
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
finally { planSaving.value = false }
}
/** Quick toggle for_sale from the list */
async function toggleForSale(plan: SubscriptionPlan) {
try {
await adminPaymentAPI.updatePlan(plan.id, { for_sale: !plan.for_sale })
plan.for_sale = !plan.for_sale
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
}
}
function confirmDeletePlan(plan: SubscriptionPlan) { deletingPlanId.value = plan.id; showDeletePlanDialog.value = true }
async function handleDeletePlan() {
if (!deletingPlanId.value) return
try { await adminPaymentAPI.deletePlan(deletingPlanId.value); appStore.showSuccess(t('common.deleted')); showDeletePlanDialog.value = false; loadPlans() }
catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
}
// ==================== Lifecycle ====================
onMounted(() => {
loadGroups()
loadPlans()
})
</script>