Unverified Commit 97f14b7a authored by Wesley Liddick's avatar Wesley Liddick Committed by GitHub
Browse files

Merge pull request #1572 from touwaeriol/feat/payment-system-v2

feat(payment): add complete payment system with multi-provider support
parents 1ef3782d 6793503e
/**
* Detect whether the current device is mobile.
* Uses navigator.userAgentData (modern API) with UA regex fallback.
*/
export function isMobileDevice(): boolean {
const nav = navigator as unknown as Record<string, unknown>
if (nav.userAgentData && typeof (nav.userAgentData as Record<string, unknown>).mobile === 'boolean') {
return (nav.userAgentData as Record<string, unknown>).mobile as boolean
}
return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent)
}
/**
* Centralized platform color definitions.
*
* All components that need platform-specific styling should import from here
* instead of defining their own color mappings.
*/
export type Platform = 'anthropic' | 'openai' | 'antigravity' | 'gemini'
// ── Badge (bg + text + border, for inline badges with border) ───────
const BADGE: Record<Platform, string> = {
anthropic: 'bg-orange-500/10 text-orange-600 border-orange-500/30 dark:text-orange-400',
openai: 'bg-green-500/10 text-green-600 border-green-500/30 dark:text-green-400',
antigravity: 'bg-purple-500/10 text-purple-600 border-purple-500/30 dark:text-purple-400',
gemini: 'bg-blue-500/10 text-blue-600 border-blue-500/30 dark:text-blue-400',
}
const BADGE_DEFAULT = 'bg-slate-500/10 text-slate-600 border-slate-500/30 dark:text-slate-400'
// ── Light badge (softer bg, no border) ──────────────────────────────
const BADGE_LIGHT: Record<Platform, string> = {
anthropic: 'bg-orange-500/10 text-orange-600 dark:bg-orange-500/10 dark:text-orange-300',
openai: 'bg-green-500/10 text-green-600 dark:bg-green-500/10 dark:text-green-300',
antigravity: 'bg-purple-500/10 text-purple-600 dark:bg-purple-500/10 dark:text-purple-300',
gemini: 'bg-blue-500/10 text-blue-600 dark:bg-blue-500/10 dark:text-blue-300',
}
// ── Border ──────────────────────────────────────────────────────────
const BORDER: Record<Platform, string> = {
anthropic: 'border-orange-500/20 dark:border-orange-500/20',
openai: 'border-green-500/20 dark:border-green-500/20',
antigravity: 'border-purple-500/20 dark:border-purple-500/20',
gemini: 'border-blue-500/20 dark:border-blue-500/20',
}
const BORDER_DEFAULT = 'border-gray-200 dark:border-dark-700'
// ── Accent bar (gradient) ───────────────────────────────────────────
const ACCENT_BAR: Record<Platform, string> = {
anthropic: 'bg-gradient-to-r from-orange-400 to-orange-500',
openai: 'bg-gradient-to-r from-emerald-400 to-emerald-500',
antigravity: 'bg-gradient-to-r from-purple-400 to-purple-500',
gemini: 'bg-gradient-to-r from-blue-400 to-blue-500',
}
const ACCENT_BAR_DEFAULT = 'bg-gradient-to-r from-primary-400 to-primary-500'
// ── Text (price, icon) ─────────────────────────────────────────────
const TEXT: Record<Platform, string> = {
anthropic: 'text-orange-600 dark:text-orange-400',
openai: 'text-emerald-600 dark:text-emerald-400',
antigravity: 'text-purple-600 dark:text-purple-400',
gemini: 'text-blue-600 dark:text-blue-400',
}
const TEXT_DEFAULT = 'text-primary-600 dark:text-primary-400'
// ── Icon (check mark etc.) ──────────────────────────────────────────
const ICON: Record<Platform, string> = {
anthropic: 'text-orange-500 dark:text-orange-400',
openai: 'text-emerald-500 dark:text-emerald-400',
antigravity: 'text-purple-500 dark:text-purple-400',
gemini: 'text-blue-500 dark:text-blue-400',
}
const ICON_DEFAULT = 'text-primary-500 dark:text-primary-400'
// ── Button (solid bg) ───────────────────────────────────────────────
const BUTTON: Record<Platform, string> = {
anthropic: 'bg-orange-500 text-white hover:bg-orange-600 active:bg-orange-700 dark:bg-orange-500/80 dark:hover:bg-orange-500',
openai: 'bg-green-600 text-white hover:bg-green-700 active:bg-green-800 dark:bg-green-600/80 dark:hover:bg-green-600',
antigravity: 'bg-purple-500 text-white hover:bg-purple-600 active:bg-purple-700 dark:bg-purple-500/80 dark:hover:bg-purple-500',
gemini: 'bg-blue-500 text-white hover:bg-blue-600 active:bg-blue-700 dark:bg-blue-500/80 dark:hover:bg-blue-500',
}
const BUTTON_DEFAULT = 'bg-primary-500 text-white hover:bg-primary-600 dark:bg-primary-600 dark:hover:bg-primary-500'
// ── Discount badge ──────────────────────────────────────────────────
const DISCOUNT: Record<Platform, string> = {
anthropic: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
openai: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
antigravity: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
gemini: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
}
const DISCOUNT_DEFAULT = 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
// ── Header gradient (subscription confirm) ─────────────────────────
const GRADIENT: Record<Platform, string> = {
anthropic: 'from-orange-500 to-orange-600',
openai: 'from-emerald-500 to-emerald-600',
antigravity: 'from-purple-500 to-purple-600',
gemini: 'from-blue-500 to-blue-600',
}
const GRADIENT_DEFAULT = 'from-primary-500 to-primary-600'
// ── Header text (light text on gradient bg) ────────────────────────
const GRADIENT_TEXT: Record<Platform, string> = {
anthropic: 'text-orange-100',
openai: 'text-emerald-100',
antigravity: 'text-purple-100',
gemini: 'text-blue-100',
}
const GRADIENT_TEXT_DEFAULT = 'text-primary-100'
const GRADIENT_SUBTEXT: Record<Platform, string> = {
anthropic: 'text-orange-200',
openai: 'text-emerald-200',
antigravity: 'text-purple-200',
gemini: 'text-blue-200',
}
const GRADIENT_SUBTEXT_DEFAULT = 'text-primary-200'
// ── Public API ──────────────────────────────────────────────────────
function isPlatform(p: string): p is Platform {
return p === 'anthropic' || p === 'openai' || p === 'antigravity' || p === 'gemini'
}
export function platformBadgeClass(p: string): string {
return isPlatform(p) ? BADGE[p] : BADGE_DEFAULT
}
export function platformBadgeLightClass(p: string): string {
return isPlatform(p) ? BADGE_LIGHT[p] : BADGE_DEFAULT
}
export function platformBorderClass(p: string): string {
return isPlatform(p) ? BORDER[p] : BORDER_DEFAULT
}
export function platformAccentBarClass(p: string): string {
return isPlatform(p) ? ACCENT_BAR[p] : ACCENT_BAR_DEFAULT
}
export function platformTextClass(p: string): string {
return isPlatform(p) ? TEXT[p] : TEXT_DEFAULT
}
export function platformIconClass(p: string): string {
return isPlatform(p) ? ICON[p] : ICON_DEFAULT
}
export function platformButtonClass(p: string): string {
return isPlatform(p) ? BUTTON[p] : BUTTON_DEFAULT
}
export function platformDiscountClass(p: string): string {
return isPlatform(p) ? DISCOUNT[p] : DISCOUNT_DEFAULT
}
export function platformGradientClass(p: string): string {
return isPlatform(p) ? GRADIENT[p] : GRADIENT_DEFAULT
}
export function platformGradientTextClass(p: string): string {
return isPlatform(p) ? GRADIENT_TEXT[p] : GRADIENT_TEXT_DEFAULT
}
export function platformGradientSubtextClass(p: string): string {
return isPlatform(p) ? GRADIENT_SUBTEXT[p] : GRADIENT_SUBTEXT_DEFAULT
}
export function platformLabel(p: string): string {
switch (p) {
case 'anthropic': return 'Anthropic'
case 'openai': return 'OpenAI'
case 'antigravity': return 'Antigravity'
case 'gemini': return 'Gemini'
default: return p || 'API'
}
}
......@@ -1989,70 +1989,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">
......@@ -2178,6 +2114,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">
......@@ -2430,6 +2484,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>
......@@ -2444,15 +2513,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 {
......@@ -2466,13 +2540,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'
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 },
]
......@@ -2584,8 +2659,7 @@ 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',
table_default_page_size: tablePageSizeDefault,
table_page_size_options: [10, 20, 50, 100],
custom_menu_items: [] as Array<{id: string; label: string; icon_svg: string; url: string; visibility: 'user' | 'admin'; sort_order: number}>,
......@@ -2845,7 +2919,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
......@@ -2867,11 +2947,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
}
......@@ -2883,8 +2961,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 = []
}
}
......@@ -2975,21 +3052,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,
......@@ -3013,8 +3075,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,
table_default_page_size: form.table_default_page_size,
table_page_size_options: form.table_page_size_options,
custom_menu_items: form.custom_menu_items,
......@@ -3068,10 +3128,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
)
......@@ -3088,10 +3172,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
}
......@@ -3110,10 +3192,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
}
......@@ -3140,10 +3220,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
}
......@@ -3156,8 +3234,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
}
......@@ -3171,8 +3249,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
}
......@@ -3192,8 +3270,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
}
......@@ -3216,8 +3294,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
}
......@@ -3232,10 +3310,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
}
......@@ -3247,8 +3323,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
}
......@@ -3266,10 +3342,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
}
......@@ -3285,8 +3359,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
}
......@@ -3309,10 +3383,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
}
......@@ -3384,8 +3456,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
}
......@@ -3413,15 +3485,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()
......@@ -3430,6 +3669,7 @@ onMounted(() => {
loadStreamTimeoutSettings()
loadRectifierSettings()
loadBetaPolicySettings()
loadProviders()
})
</script>
......
<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 { formatOrderDateTime } from '@/components/payment/orderUtils'
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 { return formatOrderDateTime(dateStr) }
onMounted(() => loadOrders())
</script>
<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>
<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>
<template>
<AppLayout>
<div class="mx-auto flex max-w-md flex-col items-center space-y-6 py-8">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
{{ qrUrl ? scanTitle : t('payment.qr.payInNewWindow') }}
</h2>
<div v-if="qrUrl" class="rounded-2xl bg-white p-6 shadow-lg dark:bg-dark-800">
<canvas ref="qrCanvas" class="mx-auto"></canvas>
</div>
<!-- Scan prompt for QR code -->
<p v-if="qrUrl && !expired && scanHint" class="text-center text-sm text-gray-500 dark:text-gray-400">
{{ scanHint }}
</p>
<div v-if="expired" class="text-center">
<p class="text-lg font-medium text-red-500">{{ t('payment.qr.expired') }}</p>
<button class="btn btn-primary mt-4" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
</div>
<div v-else class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">{{ qrUrl ? t('payment.qr.expiresIn') : t('payment.qr.payInNewWindowHint') }}</p>
<p class="mt-1 text-2xl font-bold tabular-nums text-gray-900 dark:text-white">{{ countdownDisplay }}</p>
<p class="mt-2 text-sm text-gray-400 dark:text-gray-500">{{ t('payment.qr.waitingPayment') }}</p>
</div>
<a v-if="payUrl && !qrUrl && !expired" :href="payUrl" target="_blank" rel="noopener noreferrer"
class="btn btn-primary w-full py-3">
{{ t('payment.qr.openPayWindow') }}
</a>
<!-- Cancel button -->
<button v-if="!expired && orderId" class="btn btn-secondary w-full" :disabled="cancelling" @click="handleCancel">
{{ cancelling ? t('common.processing') : t('payment.qr.cancelOrder') }}
</button>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import AppLayout from '@/components/layout/AppLayout.vue'
import { usePaymentStore } from '@/stores/payment'
import { paymentAPI } from '@/api/payment'
import { extractApiErrorMessage } from '@/utils/apiError'
import { useAppStore } from '@/stores'
import QRCode from 'qrcode'
import alipayIcon from '@/assets/icons/alipay.svg'
import wxpayIcon from '@/assets/icons/wxpay.svg'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const paymentStore = usePaymentStore()
const appStore = useAppStore()
const qrCanvas = ref<HTMLCanvasElement | null>(null)
const qrUrl = ref('')
const payUrl = ref('')
const orderId = ref(0)
const remainingSeconds = ref(0)
const expired = ref(false)
const cancelling = ref(false)
const paymentType = ref('')
let pollTimer: ReturnType<typeof setInterval> | null = null
let countdownTimer: ReturnType<typeof setInterval> | null = null
const countdownDisplay = computed(() => {
const m = Math.floor(remainingSeconds.value / 60)
const s = remainingSeconds.value % 60
return m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0')
})
const isAlipay = computed(() => paymentType.value.includes('alipay'))
const isWxpay = computed(() => paymentType.value.includes('wxpay'))
const scanTitle = computed(() => {
if (isAlipay.value) return t('payment.qr.scanAlipay')
if (isWxpay.value) return t('payment.qr.scanWxpay')
return t('payment.qr.scanToPay')
})
const scanHint = computed(() => {
if (isAlipay.value) return t('payment.qr.scanAlipayHint')
if (isWxpay.value) return t('payment.qr.scanWxpayHint')
return ''
})
function getLogoForType(): string | null {
if (isAlipay.value) return alipayIcon
if (isWxpay.value) return wxpayIcon
return null
}
async function renderQR() {
await nextTick()
if (!qrCanvas.value || !qrUrl.value) return
// Use high error correction to support logo overlay
const logoSrc = getLogoForType()
await QRCode.toCanvas(qrCanvas.value, qrUrl.value, {
width: 256,
margin: 2,
errorCorrectionLevel: logoSrc ? 'H' : 'M',
})
if (!logoSrc) return
// Draw logo in center of QR code
const canvas = qrCanvas.value
const ctx = canvas.getContext('2d')
if (!ctx) return
const img = new Image()
img.src = logoSrc
img.onload = () => {
const logoSize = 48
const x = (canvas.width - logoSize) / 2
const y = (canvas.height - logoSize) / 2
// White background with rounded corners
const pad = 5
ctx.fillStyle = '#FFFFFF'
ctx.beginPath()
const r = 6
ctx.moveTo(x - pad + r, y - pad)
ctx.arcTo(x + logoSize + pad, y - pad, x + logoSize + pad, y + logoSize + pad, r)
ctx.arcTo(x + logoSize + pad, y + logoSize + pad, x - pad, y + logoSize + pad, r)
ctx.arcTo(x - pad, y + logoSize + pad, x - pad, y - pad, r)
ctx.arcTo(x - pad, y - pad, x + logoSize + pad, y - pad, r)
ctx.fill()
// Draw logo
ctx.drawImage(img, x, y, logoSize, logoSize)
}
}
async function pollStatus() {
if (!orderId.value) return
const order = await paymentStore.pollOrderStatus(orderId.value)
if (!order) return
if (order.status === 'COMPLETED' || order.status === 'PAID') {
cleanup()
router.push({ path: '/payment/result', query: { order_id: String(orderId.value), status: 'success' } })
} else if (order.status === 'EXPIRED' || order.status === 'CANCELLED' || order.status === 'FAILED') {
cleanup()
expired.value = true
}
}
function startCountdown(seconds: number) {
remainingSeconds.value = Math.max(0, seconds)
if (remainingSeconds.value <= 0) {
expired.value = true
return
}
countdownTimer = setInterval(() => {
remainingSeconds.value--
if (remainingSeconds.value <= 0) {
expired.value = true
cleanup()
}
}, 1000)
}
async function handleCancel() {
if (!orderId.value || cancelling.value) return
cancelling.value = true
try {
await paymentAPI.cancelOrder(orderId.value)
cleanup()
router.push('/purchase')
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
cancelling.value = false
}
}
function cleanup() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null }
}
watch(qrUrl, () => renderQR())
onMounted(() => {
orderId.value = Number(route.query.order_id) || 0
qrUrl.value = String(route.query.qr || '')
payUrl.value = String(route.query.pay_url || '')
paymentType.value = String(route.query.payment_type || '')
// Calculate countdown from expiresAt
const expiresAtStr = String(route.query.expires_at || '')
let seconds = 30 * 60 // fallback: 30 minutes
if (expiresAtStr) {
const expiresAt = new Date(expiresAtStr)
const now = new Date()
seconds = Math.floor((expiresAt.getTime() - now.getTime()) / 1000)
}
startCountdown(seconds)
pollTimer = setInterval(pollStatus, 3000)
renderQR()
})
onUnmounted(() => cleanup())
</script>
<template>
<div class="flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-dark-900">
<div class="w-full max-w-md space-y-6">
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-20">
<div class="h-8 w-8 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"></div>
</div>
<template v-else>
<!-- Status Icon -->
<div class="text-center">
<div v-if="isSuccess"
class="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
<svg class="h-10 w-10 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"
stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<div v-else
class="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
<svg class="h-10 w-10 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h2 class="mt-4 text-2xl font-bold text-gray-900 dark:text-white">
{{ isSuccess ? t('payment.result.success') : t('payment.result.failed') }}
</h2>
</div>
<!-- Order Info -->
<div v-if="order" class="rounded-xl bg-white p-5 shadow-sm dark:bg-dark-800">
<div class="space-y-3 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</span>
<span class="font-medium text-gray-900 dark:text-white">#{{ order.id }}</span>
</div>
<div v-if="order.out_trade_no" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderNo') }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ order.out_trade_no }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">&#165;{{ order.pay_amount.toFixed(2) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ t('payment.methods.' + order.payment_type, order.payment_type) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.status') }}</span>
<OrderStatusBadge :status="order.status" />
</div>
</div>
</div>
<!-- EasyPay return info (when no order loaded) -->
<div v-else-if="returnInfo" class="rounded-xl bg-white p-5 shadow-sm dark:bg-dark-800">
<div class="space-y-3 text-sm">
<div v-if="returnInfo.outTradeNo" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ returnInfo.outTradeNo }}</span>
</div>
<div v-if="returnInfo.money" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">&#165;{{ returnInfo.money }}</span>
</div>
<div v-if="returnInfo.type" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ t('payment.methods.' + returnInfo.type, returnInfo.type) }}</span>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex gap-3">
<button class="btn btn-secondary flex-1" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
<button class="btn btn-primary flex-1" @click="router.push('/orders')">{{ t('payment.result.viewOrders') }}</button>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import OrderStatusBadge from '@/components/payment/OrderStatusBadge.vue'
import { usePaymentStore } from '@/stores/payment'
import { paymentAPI } from '@/api/payment'
import type { PaymentOrder } from '@/types/payment'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const paymentStore = usePaymentStore()
const order = ref<PaymentOrder | null>(null)
const loading = ref(true)
interface ReturnInfo {
outTradeNo: string
money: string
type: string
tradeStatus: string
}
const returnInfo = ref<ReturnInfo | null>(null)
const SUCCESS_STATUSES = new Set(['COMPLETED', 'PAID', 'RECHARGING'])
const isSuccess = computed(() => {
// Always prioritize actual order status from backend
if (order.value) {
return SUCCESS_STATUSES.has(order.value.status)
}
// Fallback only when order not loaded
if (route.query.status === 'success') return true
if (route.query.trade_status === 'TRADE_SUCCESS') return true
return false
})
/** Extract numeric order ID from out_trade_no like "sub2_46" → 46 */
function parseOutTradeNo(outTradeNo: string): number {
const match = outTradeNo.match(/_(\d+)$/)
return match ? Number(match[1]) : 0
}
onMounted(async () => {
// Try order_id first (internal navigation from QRCode/Stripe pages)
let orderId = Number(route.query.order_id) || 0
const outTradeNo = String(route.query.out_trade_no || '')
// Fallback: EasyPay return URL with out_trade_no
if (!orderId && outTradeNo) {
orderId = parseOutTradeNo(outTradeNo)
// Store return info for display when order lookup fails
returnInfo.value = {
outTradeNo,
money: String(route.query.money || ''),
type: String(route.query.type || ''),
tradeStatus: String(route.query.trade_status || ''),
}
}
// Verify payment via public endpoint (works without login)
if (outTradeNo) {
try {
const result = await paymentAPI.verifyOrderPublic(outTradeNo)
order.value = result.data
} catch (_err: unknown) {
// Public verify failed, try authenticated endpoint if logged in
try {
const result = await paymentAPI.verifyOrder(outTradeNo)
order.value = result.data
} catch (_e: unknown) { /* fall through */ }
}
}
// Normal order lookup by ID (if verify didn't load the order)
if (!order.value && orderId) {
try {
order.value = await paymentStore.pollOrderStatus(orderId)
} catch (_err: unknown) {
// Order lookup failed, will show returnInfo fallback
}
}
loading.value = false
})
</script>
<template>
<AppLayout>
<div class="mx-auto max-w-4xl space-y-6">
<div v-if="loading" class="flex items-center justify-center py-20">
<div class="h-8 w-8 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"></div>
</div>
<template v-else>
<!-- Tab Switcher (hide during payment and subscription confirm) -->
<div v-if="tabs.length > 1 && paymentPhase === 'select' && !selectedPlan" class="flex space-x-1 rounded-xl bg-gray-100 p-1 dark:bg-dark-800">
<button v-for="tab in tabs" :key="tab.key"
class="flex-1 rounded-lg px-4 py-2.5 text-sm font-medium transition-all"
:class="activeTab === tab.key ? 'bg-white text-gray-900 shadow dark:bg-dark-700 dark:text-white' : 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'"
@click="activeTab = tab.key">{{ tab.label }}</button>
</div>
<!-- Payment in progress (shared by recharge and subscription) -->
<template v-if="paymentPhase === 'paying'">
<PaymentStatusPanel
:order-id="paymentState.orderId"
:qr-code="paymentState.qrCode"
:expires-at="paymentState.expiresAt"
:payment-type="paymentState.paymentType"
:pay-url="paymentState.payUrl"
:order-type="paymentState.orderType"
@done="onPaymentDone"
@success="onPaymentSuccess"
/>
</template>
<template v-else-if="paymentPhase === 'stripe'">
<StripePaymentInline
:order-id="paymentState.orderId"
:client-secret="paymentState.clientSecret"
:publishable-key="checkout.stripe_publishable_key"
:pay-amount="paymentState.payAmount"
@success="onPaymentSuccess"
@done="onStripeDone"
@back="resetPayment"
@redirect="onStripeRedirect"
/>
</template>
<!-- Tab content (select phase) -->
<template v-else>
<!-- Top-up Tab -->
<template v-if="activeTab === 'recharge'">
<!-- Recharge Account Card -->
<div class="card p-5">
<p class="text-xs font-medium text-gray-400 dark:text-gray-500">{{ t('payment.rechargeAccount') }}</p>
<p class="mt-1 text-base font-semibold text-gray-900 dark:text-white">{{ user?.username || '' }}</p>
<p class="mt-0.5 text-sm font-medium text-green-600 dark:text-green-400">{{ t('payment.currentBalance') }}: {{ user?.balance?.toFixed(2) || '0.00' }}</p>
</div>
<div v-if="enabledMethods.length === 0" class="card py-16 text-center">
<p class="text-gray-500 dark:text-gray-400">{{ t('payment.notAvailable') }}</p>
</div>
<template v-else>
<div class="card p-6">
<AmountInput
v-model="amount"
:amounts="[10, 20, 50, 100, 200, 500, 1000, 2000, 5000]"
:min="globalMinAmount"
:max="globalMaxAmount"
/>
<p v-if="amountError" class="mt-2 text-xs text-amber-600 dark:text-amber-300">{{ amountError }}</p>
</div>
<div v-if="enabledMethods.length >= 1" class="card p-6">
<PaymentMethodSelector
:methods="methodOptions"
:selected="selectedMethod"
@select="selectedMethod = $event"
/>
</div>
<div v-if="feeRate > 0 && validAmount > 0" class="card p-6">
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.amountLabel') }}</span>
<span class="text-gray-900 dark:text-white">${{ validAmount.toFixed(2) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.fee') }} ({{ feeRate }}%)</span>
<span class="text-gray-900 dark:text-white">${{ feeAmount.toFixed(2) }}</span>
</div>
<div class="flex justify-between border-t border-gray-200 pt-2 dark:border-dark-600">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.actualPay') }}</span>
<span class="text-lg font-bold text-primary-600 dark:text-primary-400">${{ totalAmount.toFixed(2) }}</span>
</div>
</div>
</div>
<button :class="['btn w-full py-3 text-base font-medium', paymentButtonClass]" :disabled="!canSubmit || submitting" @click="handleSubmitRecharge">
<span v-if="submitting" class="flex items-center justify-center gap-2">
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
{{ t('common.processing') }}
</span>
<span v-else>{{ t('payment.createOrder') }} ${{ (feeRate > 0 && validAmount > 0 ? totalAmount : validAmount).toFixed(2) }}</span>
</button>
<div v-if="errorMessage" class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20">
<p class="text-sm text-red-700 dark:text-red-400">{{ errorMessage }}</p>
</div>
</template>
</template>
<!-- Subscribe Tab -->
<template v-else-if="activeTab === 'subscription'">
<!-- Subscription confirm (inline, replaces plan list) -->
<template v-if="selectedPlan">
<div class="card p-5">
<!-- Header: platform badge + plan name -->
<div class="mb-3 flex flex-wrap items-center gap-2">
<span :class="['rounded-md border px-2 py-0.5 text-xs font-medium', planBadgeClass]">
{{ platformLabel(selectedPlan.group_platform || '') }}
</span>
<h3 class="text-lg font-bold text-gray-900 dark:text-white">{{ selectedPlan.name }}</h3>
</div>
<!-- Price -->
<div class="flex items-baseline gap-2">
<span v-if="selectedPlan.original_price" class="text-sm text-gray-400 line-through dark:text-gray-500">
${{ selectedPlan.original_price }}
</span>
<span :class="['text-3xl font-bold', planTextClass]">${{ selectedPlan.price }}</span>
<span class="text-sm text-gray-500 dark:text-gray-400">/ {{ planValiditySuffix }}</span>
</div>
<!-- Description -->
<p v-if="selectedPlan.description" class="mt-2 text-sm leading-relaxed text-gray-500 dark:text-gray-400">
{{ selectedPlan.description }}
</p>
<!-- Rate + Limits grid -->
<div class="mt-3 grid grid-cols-2 gap-3">
<div>
<span class="text-xs text-gray-400 dark:text-gray-500">{{ t('payment.planCard.rate') }}</span>
<div class="flex items-baseline">
<span :class="['text-lg font-bold', planTextClass]">×{{ selectedPlan.rate_multiplier ?? 1 }}</span>
</div>
</div>
<div v-if="selectedPlan.daily_limit_usd != null">
<span class="text-xs text-gray-400 dark:text-gray-500">{{ t('payment.planCard.dailyLimit') }}</span>
<div class="text-lg font-semibold text-gray-800 dark:text-gray-200">${{ selectedPlan.daily_limit_usd }}</div>
</div>
<div v-if="selectedPlan.weekly_limit_usd != null">
<span class="text-xs text-gray-400 dark:text-gray-500">{{ t('payment.planCard.weeklyLimit') }}</span>
<div class="text-lg font-semibold text-gray-800 dark:text-gray-200">${{ selectedPlan.weekly_limit_usd }}</div>
</div>
<div v-if="selectedPlan.monthly_limit_usd != null">
<span class="text-xs text-gray-400 dark:text-gray-500">{{ t('payment.planCard.monthlyLimit') }}</span>
<div class="text-lg font-semibold text-gray-800 dark:text-gray-200">${{ selectedPlan.monthly_limit_usd }}</div>
</div>
<div v-if="selectedPlan.daily_limit_usd == null && selectedPlan.weekly_limit_usd == null && selectedPlan.monthly_limit_usd == null">
<span class="text-xs text-gray-400 dark:text-gray-500">{{ t('payment.planCard.quota') }}</span>
<div class="text-lg font-semibold text-gray-800 dark:text-gray-200">{{ t('payment.planCard.unlimited') }}</div>
</div>
</div>
</div>
<div v-if="enabledMethods.length >= 1" class="card p-6">
<PaymentMethodSelector
:methods="subMethodOptions"
:selected="selectedMethod"
@select="selectedMethod = $event"
/>
</div>
<div v-if="feeRate > 0 && selectedPlan.price > 0" class="card p-6">
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.amountLabel') }}</span>
<span class="text-gray-900 dark:text-white">${{ selectedPlan.price.toFixed(2) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.fee') }} ({{ feeRate }}%)</span>
<span class="text-gray-900 dark:text-white">${{ subFeeAmount.toFixed(2) }}</span>
</div>
<div class="flex justify-between border-t border-gray-200 pt-2 dark:border-dark-600">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.actualPay') }}</span>
<span class="text-lg font-bold text-primary-600 dark:text-primary-400">${{ subTotalAmount.toFixed(2) }}</span>
</div>
</div>
</div>
<button :class="['btn w-full py-3 text-base font-medium', paymentButtonClass]" :disabled="!canSubmitSubscription || submitting" @click="confirmSubscribe">
<span v-if="submitting" class="flex items-center justify-center gap-2">
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
{{ t('common.processing') }}
</span>
<span v-else>{{ t('payment.createOrder') }} ${{ (feeRate > 0 ? subTotalAmount : selectedPlan.price).toFixed(2) }}</span>
</button>
<button class="btn btn-secondary w-full" @click="selectedPlan = null">{{ t('common.cancel') }}</button>
<div v-if="errorMessage" class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20">
<p class="text-sm text-red-700 dark:text-red-400">{{ errorMessage }}</p>
</div>
</template>
<!-- Plan list -->
<template v-else>
<div v-if="checkout.plans.length === 0" class="card py-16 text-center">
<Icon name="gift" size="xl" class="mx-auto mb-3 text-gray-300 dark:text-dark-600" />
<p class="text-gray-500 dark:text-gray-400">{{ t('payment.noPlans') }}</p>
</div>
<div v-else :class="planGridClass">
<SubscriptionPlanCard v-for="plan in checkout.plans" :key="plan.id" :plan="plan" :active-subscriptions="activeSubscriptions" @select="selectPlan" />
</div>
<!-- Active subscriptions (compact, below plan list) -->
<div v-if="activeSubscriptions.length > 0">
<p class="mb-2 text-xs font-medium text-gray-400 dark:text-gray-500">{{ t('payment.activeSubscription') }}</p>
<div class="space-y-2">
<div v-for="sub in activeSubscriptions" :key="sub.id"
class="flex items-center gap-3 rounded-xl border border-gray-100 bg-white px-3 py-2 dark:border-dark-700 dark:bg-dark-800">
<div :class="['h-6 w-1 shrink-0 rounded-full', platformAccentBarClass(sub.group?.platform || '')]" />
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="truncate text-xs font-semibold text-gray-900 dark:text-white">{{ sub.group?.name || `Group #${sub.group_id}` }}</span>
<span :class="['shrink-0 rounded-full px-1.5 py-0.5 text-[9px] font-medium', platformBadgeLightClass(sub.group?.platform || '')]">{{ platformLabel(sub.group?.platform || '') }}</span>
</div>
<div class="flex flex-wrap gap-x-3 text-[11px] text-gray-400 dark:text-gray-500">
<span>{{ t('payment.planCard.rate') }}: ×{{ sub.group?.rate_multiplier ?? 1 }}</span>
<span v-if="sub.group?.daily_limit_usd == null && sub.group?.weekly_limit_usd == null && sub.group?.monthly_limit_usd == null">{{ t('payment.planCard.quota') }}: {{ t('payment.planCard.unlimited') }}</span>
<span v-if="sub.expires_at">{{ t('userSubscriptions.daysRemaining', { days: getDaysRemaining(sub.expires_at) }) }}</span>
<span v-else>{{ t('userSubscriptions.noExpiration') }}</span>
</div>
</div>
<span class="badge badge-success shrink-0 text-[10px]">{{ t('userSubscriptions.status.active') }}</span>
</div>
</div>
</div>
</template>
</template>
</template>
<div v-if="(checkout.help_text || checkout.help_image_url) && paymentPhase === 'select' && !selectedPlan" class="card p-4">
<div class="flex flex-col items-center gap-3">
<img v-if="checkout.help_image_url" :src="checkout.help_image_url" alt=""
class="h-40 max-w-full cursor-pointer rounded-lg object-contain transition-opacity hover:opacity-80"
@click="previewImage = checkout.help_image_url" />
<p v-if="checkout.help_text" class="text-center text-sm text-gray-500 dark:text-gray-400">{{ checkout.help_text }}</p>
</div>
</div>
</template>
</div>
<!-- Renewal Plan Selection Modal -->
<Teleport to="body">
<Transition name="modal">
<div v-if="showRenewalModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" @click.self="closeRenewalModal">
<div class="relative w-full max-w-lg rounded-2xl border border-gray-200 bg-white p-6 shadow-2xl dark:border-dark-700 dark:bg-dark-900">
<!-- Close button -->
<button class="absolute right-4 top-4 rounded-lg p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-dark-700 dark:hover:text-gray-200" @click="closeRenewalModal">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">{{ t('payment.selectPlan') }}</h3>
<div class="space-y-4">
<SubscriptionPlanCard v-for="plan in renewalPlans" :key="plan.id" :plan="plan" :active-subscriptions="activeSubscriptions" @select="selectPlanFromModal" />
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- Image Preview Overlay -->
<Teleport to="body">
<Transition name="modal">
<div v-if="previewImage" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/70 backdrop-blur-sm" @click="previewImage = ''">
<img :src="previewImage" alt="" class="max-h-[85vh] max-w-[90vw] rounded-xl object-contain shadow-2xl" />
</div>
</Transition>
</Teleport>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { usePaymentStore } from '@/stores/payment'
import { useSubscriptionStore } from '@/stores/subscriptions'
import { useAppStore } from '@/stores'
import { paymentAPI } from '@/api/payment'
import { extractApiErrorMessage } from '@/utils/apiError'
import { isMobileDevice } from '@/utils/device'
import type { SubscriptionPlan, CheckoutInfoResponse } from '@/types/payment'
import AppLayout from '@/components/layout/AppLayout.vue'
import AmountInput from '@/components/payment/AmountInput.vue'
import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue'
import { METHOD_ORDER, POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
import { platformAccentBarClass, platformBadgeLightClass, platformBadgeClass, platformTextClass, platformLabel } from '@/utils/platformColors'
import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue'
import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue'
import StripePaymentInline from '@/components/payment/StripePaymentInline.vue'
import Icon from '@/components/icons/Icon.vue'
import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue'
const { t } = useI18n()
const route = useRoute()
const authStore = useAuthStore()
const paymentStore = usePaymentStore()
const subscriptionStore = useSubscriptionStore()
const appStore = useAppStore()
const user = computed(() => authStore.user)
const activeSubscriptions = computed(() => subscriptionStore.activeSubscriptions)
function getDaysRemaining(expiresAt: string): number {
const diff = new Date(expiresAt).getTime() - Date.now()
return Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24)))
}
const loading = ref(true)
const submitting = ref(false)
const errorMessage = ref('')
const activeTab = ref<'recharge' | 'subscription'>('recharge')
const amount = ref<number | null>(null)
const selectedMethod = ref('')
const selectedPlan = ref<SubscriptionPlan | null>(null)
const previewImage = ref('')
// Payment phase: 'select' → 'paying' (QR/redirect) or 'stripe' (inline Stripe)
const paymentPhase = ref<'select' | 'paying' | 'stripe'>('select')
const paymentState = ref({ orderId: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '', clientSecret: '', payAmount: 0, orderType: '' })
function resetPayment() {
paymentPhase.value = 'select'
paymentState.value = { orderId: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '', clientSecret: '', payAmount: 0, orderType: '' }
}
function onPaymentDone() {
const wasSubscription = paymentState.value.orderType === 'subscription'
resetPayment()
selectedPlan.value = null
if (wasSubscription) {
subscriptionStore.fetchActiveSubscriptions(true).catch(() => {})
}
}
function onPaymentSuccess() {
authStore.refreshUser()
if (paymentState.value.orderType === 'subscription') {
subscriptionStore.fetchActiveSubscriptions(true).catch(() => {})
}
}
function onStripeDone() {
const wasSubscription = paymentState.value.orderType === 'subscription'
resetPayment()
selectedPlan.value = null
if (wasSubscription) {
subscriptionStore.fetchActiveSubscriptions(true).catch(() => {})
}
}
function onStripeRedirect(orderId: number, payUrl: string) {
paymentState.value = { ...paymentState.value, orderId, payUrl, qrCode: '' }
paymentPhase.value = 'paying'
}
// All checkout data from single API call
const checkout = ref<CheckoutInfoResponse>({
methods: {}, global_min: 0, global_max: 0,
plans: [], balance_disabled: false, help_text: '', help_image_url: '', stripe_publishable_key: '',
})
const tabs = computed(() => {
const result: { key: 'recharge' | 'subscription'; label: string }[] = []
if (!checkout.value.balance_disabled) result.push({ key: 'recharge', label: t('payment.tabTopUp') })
result.push({ key: 'subscription', label: t('payment.tabSubscribe') })
return result
})
const enabledMethods = computed(() => Object.keys(checkout.value.methods))
const validAmount = computed(() => amount.value ?? 0)
// Adaptive grid: center single card, 2-col for 2 plans, 3-col for 3+
const planGridClass = computed(() => {
const n = checkout.value.plans.length
if (n <= 2) return 'grid grid-cols-1 gap-5 sm:grid-cols-2'
return 'grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3'
})
// Check if an amount fits a method's [min, max]. 0 = no limit.
function amountFitsMethod(amt: number, methodType: string): boolean {
if (amt <= 0) return true
const ml = checkout.value.methods[methodType]
if (!ml) return false
if (ml.single_min > 0 && amt < ml.single_min) return false
if (ml.single_max > 0 && amt > ml.single_max) return false
return true
}
// Global range for AmountInput (union of all methods, precomputed by backend)
const globalMinAmount = computed(() => checkout.value.global_min)
const globalMaxAmount = computed(() => checkout.value.global_max)
// Selected method's limits (for validation and error messages)
const selectedLimit = computed(() => checkout.value.methods[selectedMethod.value])
const methodOptions = computed<PaymentMethodOption[]>(() =>
enabledMethods.value.map((type) => {
const ml = checkout.value.methods[type]
return {
type,
fee_rate: ml?.fee_rate ?? 0,
available: ml?.available !== false && amountFitsMethod(validAmount.value, type),
}
})
)
const feeRate = computed(() => selectedLimit.value?.fee_rate ?? 0)
const feeAmount = computed(() =>
feeRate.value > 0 && validAmount.value > 0
? Math.ceil(((validAmount.value * feeRate.value) / 100) * 100) / 100
: 0
)
const totalAmount = computed(() =>
feeRate.value > 0 && validAmount.value > 0
? Math.round((validAmount.value + feeAmount.value) * 100) / 100
: validAmount.value
)
const amountError = computed(() => {
if (validAmount.value <= 0) return ''
// No method can handle this amount
if (!enabledMethods.value.some((m) => amountFitsMethod(validAmount.value, m))) {
return t('payment.amountNoMethod')
}
// Selected method can't handle this amount (but others can)
const ml = selectedLimit.value
if (ml) {
if (ml.single_min > 0 && validAmount.value < ml.single_min) return t('payment.amountTooLow', { min: ml.single_min })
if (ml.single_max > 0 && validAmount.value > ml.single_max) return t('payment.amountTooHigh', { max: ml.single_max })
}
return ''
})
const canSubmit = computed(() =>
validAmount.value > 0
&& amountFitsMethod(validAmount.value, selectedMethod.value)
&& selectedLimit.value?.available !== false
)
// Subscription-specific: method options based on plan price
const subMethodOptions = computed<PaymentMethodOption[]>(() => {
const planPrice = selectedPlan.value?.price ?? 0
return enabledMethods.value.map((type) => {
const ml = checkout.value.methods[type]
return {
type,
fee_rate: ml?.fee_rate ?? 0,
available: ml?.available !== false && amountFitsMethod(planPrice, type),
}
})
})
const subFeeAmount = computed(() => {
const price = selectedPlan.value?.price ?? 0
if (feeRate.value <= 0 || price <= 0) return 0
return Math.ceil(((price * feeRate.value) / 100) * 100) / 100
})
const subTotalAmount = computed(() => {
const price = selectedPlan.value?.price ?? 0
if (feeRate.value <= 0 || price <= 0) return price
return Math.round((price + subFeeAmount.value) * 100) / 100
})
const canSubmitSubscription = computed(() =>
selectedPlan.value !== null
&& amountFitsMethod(selectedPlan.value.price, selectedMethod.value)
&& selectedLimit.value?.available !== false
)
// Auto-switch to first available method when current selection can't handle the amount
watch(() => [validAmount.value, selectedMethod.value] as const, ([amt, method]) => {
if (amt <= 0 || amountFitsMethod(amt, method)) return
const available = enabledMethods.value.find((m) => amountFitsMethod(amt, m))
if (available) selectedMethod.value = available
})
// Payment button class: follows selected payment method color
const paymentButtonClass = computed(() => {
const m = selectedMethod.value
if (!m) return 'btn-primary'
if (m.includes('alipay')) return 'btn-alipay'
if (m.includes('wxpay')) return 'btn-wxpay'
if (m === 'stripe') return 'btn-stripe'
return 'btn-primary'
})
// Subscription confirm: platform accent colors (clean card, no gradient)
const planBadgeClass = computed(() => platformBadgeClass(selectedPlan.value?.group_platform || ''))
const planTextClass = computed(() => platformTextClass(selectedPlan.value?.group_platform || ''))
// Renewal modal state
const showRenewalModal = ref(false)
const renewGroupId = ref<number | null>(null)
const renewalPlans = computed(() => {
if (renewGroupId.value == null) return []
return checkout.value.plans.filter(p => p.group_id === renewGroupId.value)
})
const planValiditySuffix = computed(() => {
if (!selectedPlan.value) return ''
const u = selectedPlan.value.validity_unit || 'day'
if (u === 'month') return t('payment.perMonth')
if (u === 'year') return t('payment.perYear')
return `${selectedPlan.value.validity_days}${t('payment.days')}`
})
function selectPlan(plan: SubscriptionPlan) {
selectedPlan.value = plan
errorMessage.value = ''
}
function selectPlanFromModal(plan: SubscriptionPlan) {
showRenewalModal.value = false
renewGroupId.value = null
selectedPlan.value = plan
errorMessage.value = ''
}
function closeRenewalModal() {
showRenewalModal.value = false
renewGroupId.value = null
}
async function handleSubmitRecharge() {
if (!canSubmit.value || submitting.value) return
await createOrder(validAmount.value, 'balance')
}
async function confirmSubscribe() {
if (!selectedPlan.value || submitting.value) return
await createOrder(selectedPlan.value.price, 'subscription', selectedPlan.value.id)
}
async function createOrder(orderAmount: number, orderType: string, planId?: number) {
submitting.value = true
errorMessage.value = ''
try {
const result = await paymentStore.createOrder({
amount: orderAmount,
payment_type: selectedMethod.value,
order_type: orderType,
plan_id: planId,
})
const openWindow = (url: string) => {
const win = window.open(url, 'paymentPopup', POPUP_WINDOW_FEATURES)
if (!win || win.closed) {
window.location.href = url
}
}
if (result.client_secret) {
// Stripe: show Payment Element inline (user picks method → confirms → redirect if needed)
paymentState.value = {
orderId: result.order_id, qrCode: '', expiresAt: result.expires_at || '',
paymentType: selectedMethod.value, payUrl: '',
clientSecret: result.client_secret, payAmount: result.pay_amount,
orderType,
}
paymentPhase.value = 'stripe'
} else if (isMobileDevice() && result.pay_url) {
// Mobile + pay_url: redirect directly instead of QR/popup (mobile browsers block popups)
paymentState.value = {
orderId: result.order_id, qrCode: '', expiresAt: result.expires_at || '',
paymentType: selectedMethod.value, payUrl: result.pay_url,
clientSecret: '', payAmount: 0,
orderType,
}
paymentPhase.value = 'paying'
window.location.href = result.pay_url
return
} else if (result.qr_code) {
// QR mode: show QR code inline
paymentState.value = {
orderId: result.order_id, qrCode: result.qr_code,
expiresAt: result.expires_at || '', paymentType: selectedMethod.value, payUrl: '',
clientSecret: '', payAmount: 0,
orderType,
}
paymentPhase.value = 'paying'
} else if (result.pay_url) {
// Redirect/popup mode: open payment URL, show waiting state inline
openWindow(result.pay_url)
paymentState.value = {
orderId: result.order_id, qrCode: '', expiresAt: result.expires_at || '',
paymentType: selectedMethod.value, payUrl: result.pay_url,
clientSecret: '', payAmount: 0,
orderType,
}
paymentPhase.value = 'paying'
} else {
errorMessage.value = t('payment.result.failed')
appStore.showError(errorMessage.value)
}
} catch (err: unknown) {
const apiErr = err as Record<string, unknown>
if (apiErr.reason === 'TOO_MANY_PENDING') {
const metadata = apiErr.metadata as Record<string, unknown> | undefined
errorMessage.value = t('payment.errors.tooManyPending', { max: metadata?.max || '' })
} else if (apiErr.reason === 'CANCEL_RATE_LIMITED') {
errorMessage.value = t('payment.errors.cancelRateLimited')
} else {
errorMessage.value = extractApiErrorMessage(err, t('payment.result.failed'))
}
appStore.showError(errorMessage.value)
} finally {
submitting.value = false
}
}
onMounted(async () => {
try {
const res = await paymentAPI.getCheckoutInfo()
checkout.value = res.data
if (enabledMethods.value.length) {
const order: readonly string[] = METHOD_ORDER
const sorted = [...enabledMethods.value].sort((a, b) => {
const ai = order.indexOf(a)
const bi = order.indexOf(b)
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi)
})
selectedMethod.value = sorted[0]
}
if (checkout.value.balance_disabled) {
activeTab.value = 'subscription'
}
// Handle renewal navigation: ?tab=subscription&group=123
if (route.query.tab === 'subscription') {
activeTab.value = 'subscription'
if (route.query.group) {
const groupId = Number(route.query.group)
const groupPlans = checkout.value.plans.filter(p => p.group_id === groupId)
if (groupPlans.length === 1) {
selectedPlan.value = groupPlans[0]
} else if (groupPlans.length > 1) {
renewGroupId.value = groupId
showRenewalModal.value = true
}
}
}
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
finally { loading.value = false }
// Fetch active subscriptions (uses cache, non-blocking)
subscriptionStore.fetchActiveSubscriptions().catch(() => {})
})
</script>
<template>
<AppLayout>
<div class="purchase-page-layout">
<div class="card flex-1 min-h-0 overflow-hidden">
<div v-if="loading" class="flex h-full items-center justify-center py-12">
<div
class="h-8 w-8 animate-spin rounded-full border-2 border-primary-500 border-t-transparent"
></div>
</div>
<div
v-else-if="!purchaseEnabled"
class="flex h-full items-center justify-center p-10 text-center"
>
<div class="max-w-md">
<div
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700"
>
<Icon name="creditCard" size="lg" class="text-gray-400" />
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('purchase.notEnabledTitle') }}
</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
{{ t('purchase.notEnabledDesc') }}
</p>
</div>
</div>
<div
v-else-if="!isValidUrl"
class="flex h-full items-center justify-center p-10 text-center"
>
<div class="max-w-md">
<div
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700"
>
<Icon name="link" size="lg" class="text-gray-400" />
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('purchase.notConfiguredTitle') }}
</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
{{ t('purchase.notConfiguredDesc') }}
</p>
</div>
</div>
<div v-else class="purchase-embed-shell">
<a
:href="purchaseUrl"
target="_blank"
rel="noopener noreferrer"
class="btn btn-secondary btn-sm purchase-open-fab"
>
<Icon name="externalLink" size="sm" class="mr-1.5" :stroke-width="2" />
{{ t('purchase.openInNewTab') }}
</a>
<iframe
:src="purchaseUrl"
class="purchase-embed-frame"
allowfullscreen
></iframe>
</div>
</div>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores'
import { useAuthStore } from '@/stores/auth'
import AppLayout from '@/components/layout/AppLayout.vue'
import Icon from '@/components/icons/Icon.vue'
import { buildEmbeddedUrl, detectTheme } from '@/utils/embedded-url'
const { t, locale } = useI18n()
const appStore = useAppStore()
const authStore = useAuthStore()
const loading = ref(false)
const purchaseTheme = ref<'light' | 'dark'>('light')
let themeObserver: MutationObserver | null = null
const purchaseEnabled = computed(() => {
return appStore.cachedPublicSettings?.purchase_subscription_enabled ?? false
})
const purchaseUrl = computed(() => {
const baseUrl = (appStore.cachedPublicSettings?.purchase_subscription_url || '').trim()
return buildEmbeddedUrl(baseUrl, authStore.user?.id, authStore.token, purchaseTheme.value, locale.value)
})
const isValidUrl = computed(() => {
const url = purchaseUrl.value
return url.startsWith('http://') || url.startsWith('https://')
})
onMounted(async () => {
purchaseTheme.value = detectTheme()
if (typeof document !== 'undefined') {
themeObserver = new MutationObserver(() => {
purchaseTheme.value = detectTheme()
})
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
})
}
if (appStore.publicSettingsLoaded) return
loading.value = true
try {
await appStore.fetchPublicSettings()
} finally {
loading.value = false
}
})
onUnmounted(() => {
if (themeObserver) {
themeObserver.disconnect()
themeObserver = null
}
})
</script>
<style scoped>
.purchase-page-layout {
@apply flex flex-col;
height: calc(100vh - 64px - 4rem);
}
.purchase-embed-shell {
@apply relative;
@apply h-full w-full overflow-hidden rounded-2xl;
@apply bg-gradient-to-b from-gray-50 to-white dark:from-dark-900 dark:to-dark-950;
@apply p-0;
}
.purchase-open-fab {
@apply absolute right-3 top-3 z-10;
@apply shadow-sm backdrop-blur supports-[backdrop-filter]:bg-white/80 dark:supports-[backdrop-filter]:bg-dark-800/80;
}
.purchase-embed-frame {
display: block;
margin: 0;
width: 100%;
height: 100%;
border: 0;
border-radius: 0;
box-shadow: none;
background: transparent;
}
</style>
<template>
<component :is="isPopup ? 'div' : AppLayout" :class="isPopup ? 'min-h-screen bg-gray-50 dark:bg-dark-900' : ''">
<div class="mx-auto max-w-lg space-y-6 py-8" :class="isPopup ? 'px-4' : ''">
<div v-if="loading" class="flex items-center justify-center py-20">
<div class="h-8 w-8 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"></div>
</div>
<div v-else-if="initError" class="card p-8 text-center">
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
<Icon name="exclamationCircle" size="xl" class="text-red-500" />
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('payment.stripeLoadFailed') }}</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ initError }}</p>
<button class="btn btn-primary mt-6" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
</div>
<template v-else>
<!-- Amount header -->
<div v-if="order" class="card overflow-hidden">
<div class="bg-gradient-to-br from-[#635bff] to-[#4f46e5] px-6 py-6 text-center">
<p class="text-sm font-medium text-indigo-200">{{ t('payment.actualPay') }}</p>
<p class="mt-1 text-3xl font-bold text-white">&#165;{{ order.pay_amount.toFixed(2) }}</p>
</div>
</div>
<!-- WeChat QR Code display -->
<template v-if="wechatQrUrl">
<div class="card p-6">
<div class="flex flex-col items-center space-y-4">
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('payment.qr.scanWxpay') }}</p>
<div class="relative rounded-lg border-2 border-[#2BB741] bg-green-50 p-4 dark:border-[#2BB741]/70 dark:bg-green-950/20">
<img :src="wechatQrUrl" alt="WeChat Pay QR" class="h-56 w-56 rounded" />
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<span class="rounded-full bg-[#2BB741] p-2 shadow ring-2 ring-white">
<svg class="h-5 w-5 text-white" viewBox="0 0 24 24" fill="currentColor"><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm3.636 4.35c-2.084 0-3.993.672-5.363 1.844-1.188.982-2.004 2.308-2.004 3.862 0 1.207.546 2.355 1.483 3.285.114.113.238.213.358.321l-.105.42c-.021.084-.042.17-.042.253 0 .168.126.258.282.258.065 0 .126-.025.18-.058l1.27-.765a.69.69 0 0 1 .58-.086c.96.282 1.99.437 3.043.437 2.633 0 5.03-.972 6.4-2.5.782-.87 1.258-1.901 1.258-3.006 0-3.328-3.325-6.006-7.34-6.006zm-3.21 3.09c.52 0 .94.429.94.957a.949.949 0 0 1-.94.955.949.949 0 0 1-.94-.955c0-.528.42-.957.94-.957zm4.739 0c.52 0 .94.429.94.957a.949.949 0 0 1-.94.955.949.949 0 0 1-.94-.955c0-.528.42-.957.94-.957z"/></svg>
</span>
</div>
</div>
<p class="text-center text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.scanWxpayHint') }}</p>
</div>
</div>
<div class="card p-4 text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.waitingPayment') }}</p>
</div>
</template>
<!-- Alipay redirecting state -->
<template v-else-if="redirecting">
<div class="card p-6">
<div class="flex flex-col items-center space-y-4 py-4">
<div class="h-10 w-10 animate-spin rounded-full border-4 border-[#00AEEF] border-t-transparent"></div>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.payInNewWindowHint') }}</p>
</div>
</div>
</template>
<!-- Success state -->
<template v-else-if="stripeSuccess">
<div class="card p-6 text-center">
<div class="flex flex-col items-center gap-3 py-4">
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
<Icon name="check" size="lg" class="text-green-500" />
</div>
<p class="text-lg font-bold text-gray-900 dark:text-white">{{ t('payment.result.success') }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.stripeSuccessProcessing') }}</p>
</div>
</div>
</template>
<!-- Fallback: full Payment Element (no method param or unknown method) -->
<template v-else-if="showPaymentElement">
<div class="card p-6">
<div id="stripe-payment-element" class="min-h-[200px]"></div>
<p v-if="stripeError" class="mt-4 text-sm text-red-600 dark:text-red-400">{{ stripeError }}</p>
<button class="btn btn-stripe mt-6 w-full py-3 text-base" :disabled="stripeSubmitting || !stripeReady" @click="handleGenericPay">
<span v-if="stripeSubmitting" class="flex items-center justify-center gap-2">
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
{{ t('common.processing') }}
</span>
<span v-else>{{ t('payment.stripePay') }}</span>
</button>
</div>
<div class="text-center">
<button class="btn btn-secondary" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
</div>
</template>
<!-- Error -->
<div v-if="stripeError && !showPaymentElement" class="card p-4">
<p class="text-sm text-red-600 dark:text-red-400">{{ stripeError }}</p>
<button class="btn btn-secondary mt-3 w-full" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
</div>
</template>
</div>
</component>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { usePaymentStore } from '@/stores/payment'
import { paymentAPI } from '@/api/payment'
import { extractApiErrorMessage } from '@/utils/apiError'
import { isMobileDevice } from '@/utils/device'
import type { PaymentOrder } from '@/types/payment'
import type { Stripe, StripeElements } from '@stripe/stripe-js'
import AppLayout from '@/components/layout/AppLayout.vue'
import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const paymentStore = usePaymentStore()
// Popup mode: skip AppLayout when opened with a specific method (alipay/wechat_pay)
const isPopup = computed(() => !!route.query.method)
const loading = ref(true)
const initError = ref('')
const stripeError = ref('')
const stripeSubmitting = ref(false)
const stripeSuccess = ref(false)
const stripeReady = ref(false)
const order = ref<PaymentOrder | null>(null)
const wechatQrUrl = ref('')
const redirecting = ref(false)
const showPaymentElement = ref(false)
let stripeInstance: Stripe | null = null
let elementsInstance: StripeElements | null = null
let redirectTimer: ReturnType<typeof setTimeout> | null = null
onMounted(async () => {
const orderId = Number(route.query.order_id)
const clientSecret = String(route.query.client_secret || '')
const method = String(route.query.method || '')
if (!orderId || !clientSecret) {
loading.value = false
initError.value = t('payment.stripeMissingParams')
return
}
try {
const res = await paymentAPI.getOrder(orderId)
order.value = res.data
await paymentStore.fetchConfig()
const publishableKey = paymentStore.config?.stripe_publishable_key
if (!publishableKey) { initError.value = t('payment.stripeNotConfigured'); return }
const { loadStripe } = await import('@stripe/stripe-js')
const stripe = await loadStripe(publishableKey)
if (!stripe) { initError.value = t('payment.stripeLoadFailed'); return }
stripeInstance = stripe
loading.value = false
// Direct confirm for specific methods (no Payment Element needed)
if (method === 'alipay') {
await confirmAlipay(stripe, clientSecret, orderId)
} else if (method === 'wechat_pay') {
await confirmWechatPay(stripe, clientSecret)
} else {
// Fallback: render full Payment Element
showPaymentElement.value = true
await nextTick()
mountPaymentElement(stripe, clientSecret)
}
} catch (err: unknown) {
initError.value = extractApiErrorMessage(err, t('payment.stripeLoadFailed'))
} finally {
loading.value = false
}
})
onUnmounted(() => {
if (redirectTimer) clearTimeout(redirectTimer)
})
async function confirmAlipay(stripe: Stripe, clientSecret: string, orderId: number) {
redirecting.value = true
const returnUrl = window.location.origin + '/payment/result?order_id=' + orderId + '&status=success'
const { error } = await stripe.confirmAlipayPayment(clientSecret, { return_url: returnUrl })
if (error) {
redirecting.value = false
stripeError.value = error.message || t('payment.result.failed')
}
// If no error, Stripe redirects automatically — nothing else to do
}
async function confirmWechatPay(stripe: Stripe, clientSecret: string) {
const { paymentIntent, error } = await (stripe as Stripe & {
confirmWechatPayPayment: (cs: string, opts: Record<string, unknown>) => Promise<{ paymentIntent?: { status: string; next_action?: { wechat_pay_display_qr_code?: { image_data_url?: string } } }; error?: { message?: string } }>
}).confirmWechatPayPayment(clientSecret, {
payment_method_options: { wechat_pay: { client: isMobileDevice() ? 'mobile_web' : 'web' } },
})
if (error) {
stripeError.value = error.message || t('payment.result.failed')
return
}
// Extract QR code image from next_action
const qrData = paymentIntent?.next_action?.wechat_pay_display_qr_code?.image_data_url
if (qrData) {
wechatQrUrl.value = qrData
// Poll for completion
startPolling()
} else if (paymentIntent?.status === 'succeeded') {
stripeSuccess.value = true
scheduleClose()
} else {
stripeError.value = t('payment.result.failed')
}
}
function mountPaymentElement(stripe: Stripe, clientSecret: string) {
const isDark = document.documentElement.classList.contains('dark')
const elements = stripe.elements({
clientSecret,
appearance: { theme: isDark ? 'night' : 'stripe', variables: { borderRadius: '8px' } },
})
elementsInstance = elements
const paymentElement = elements.create('payment', {
layout: 'tabs',
paymentMethodOrder: ['alipay', 'wechat_pay', 'card', 'link'],
} as Record<string, unknown>)
paymentElement.mount('#stripe-payment-element')
paymentElement.on('ready', () => { stripeReady.value = true })
}
async function handleGenericPay() {
if (!stripeInstance || !elementsInstance || stripeSubmitting.value) return
stripeSubmitting.value = true
stripeError.value = ''
try {
const { error } = await stripeInstance.confirmPayment({
elements: elementsInstance,
confirmParams: {
return_url: window.location.origin + '/payment/result?order_id=' + route.query.order_id + '&status=success',
},
redirect: 'if_required',
})
if (error) {
stripeError.value = error.message || t('payment.result.failed')
} else {
stripeSuccess.value = true
scheduleClose()
}
} catch (err: unknown) {
stripeError.value = extractApiErrorMessage(err, t('payment.result.failed'))
} finally {
stripeSubmitting.value = false
}
}
let pollTimer: ReturnType<typeof setInterval> | null = null
function startPolling() {
const orderId = Number(route.query.order_id)
if (!orderId) return
pollTimer = setInterval(async () => {
const o = await paymentStore.pollOrderStatus(orderId)
if (!o) return
if (o.status === 'COMPLETED' || o.status === 'PAID') {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
stripeSuccess.value = true
wechatQrUrl.value = ''
scheduleClose()
}
}, 3000)
}
function scheduleClose() {
if (window.opener) {
redirectTimer = setTimeout(() => { window.close() }, 2000)
} else {
redirectTimer = setTimeout(() => {
router.push({ path: '/payment/result', query: { order_id: String(route.query.order_id || ''), status: 'success' } })
}, 2000)
}
}
onUnmounted(() => {
if (redirectTimer) clearTimeout(redirectTimer)
if (pollTimer) clearInterval(pollTimer)
})
</script>
<template>
<div class="flex min-h-screen items-center justify-center bg-slate-50 p-4 dark:bg-slate-950">
<div
class="w-full max-w-md space-y-4 rounded-2xl border border-slate-200 bg-white p-6 shadow-lg dark:border-slate-700 dark:bg-slate-900"
>
<!-- Amount + Order ID -->
<div v-if="amount" class="text-center">
<p class="text-3xl font-bold" :style="{ color: methodColor }">¥{{ amount }}</p>
<p v-if="orderId" class="mt-1 text-sm text-gray-500 dark:text-slate-400">
{{ t('payment.orders.orderId') }}: {{ orderId }}
</p>
</div>
<!-- Error -->
<div v-if="error" class="space-y-3">
<div
class="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600 dark:border-red-700 dark:bg-red-900/30 dark:text-red-400"
>
{{ error }}
</div>
<button
class="w-full text-sm underline dark:text-blue-400 dark:hover:text-blue-300"
:style="{ color: methodColor }"
@click="closeWindow"
>
{{ t('common.close') }}
</button>
</div>
<!-- Success -->
<div v-else-if="success" class="space-y-3 py-4 text-center">
<div class="text-5xl text-green-600 dark:text-green-400"></div>
<p class="text-sm text-gray-500 dark:text-slate-400">{{ t('payment.result.success') }}</p>
<button
class="text-sm underline dark:text-blue-400 dark:hover:text-blue-300"
:style="{ color: methodColor }"
@click="closeWindow"
>
{{ t('common.close') }}
</button>
</div>
<!-- Loading / Redirecting -->
<div v-else class="flex items-center justify-center py-8">
<div
class="h-8 w-8 animate-spin rounded-full border-2 border-t-transparent"
:style="{ borderColor: methodColor, borderTopColor: 'transparent' }"
/>
<span class="ml-3 text-sm text-gray-500 dark:text-slate-400">{{ hint }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { extractApiErrorMessage } from '@/utils/apiError'
import { isMobileDevice } from '@/utils/device'
interface StripeWithWechatPay {
confirmWechatPayPayment(clientSecret: string, options: Record<string, unknown>): Promise<{ error?: { message?: string }; paymentIntent?: { status: string } }>
}
const METHOD_COLORS: Record<string, string> = {
alipay: '#00AEEF',
wechat_pay: '#07C160',
}
const DEFAULT_METHOD_COLOR = '#635bff'
const { t } = useI18n()
const route = useRoute()
const orderId = String(route.query.order_id || '')
const method = String(route.query.method || 'alipay')
const amount = String(route.query.amount || '')
const methodColor = computed(() => METHOD_COLORS[method] || DEFAULT_METHOD_COLOR)
const error = ref('')
const success = ref(false)
const hint = ref(t('payment.stripePopup.redirecting'))
let pollTimer: ReturnType<typeof setInterval> | null = null
function closeWindow() { window.close() }
onMounted(() => {
const handler = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return
if (event.data?.type !== 'STRIPE_POPUP_INIT') return
window.removeEventListener('message', handler)
initStripe(event.data.clientSecret, event.data.publishableKey)
}
window.addEventListener('message', handler)
if (window.opener) {
window.opener.postMessage({ type: 'STRIPE_POPUP_READY' }, window.location.origin)
}
setTimeout(() => {
if (!error.value && !success.value) {
error.value = t('payment.stripePopup.timeout')
}
}, 15000)
})
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer)
})
async function initStripe(clientSecret: string, publishableKey: string) {
if (!clientSecret || !publishableKey) {
error.value = t('payment.stripeMissingParams')
return
}
try {
const { loadStripe } = await import('@stripe/stripe-js')
const stripe = await loadStripe(publishableKey)
if (!stripe) { error.value = t('payment.stripeLoadFailed'); return }
const returnUrl = window.location.origin + '/payment/result?order_id=' + orderId + '&status=success'
if (method === 'alipay') {
// Alipay: redirect this popup to Alipay payment page
const { error: err } = await stripe.confirmAlipayPayment(clientSecret, { return_url: returnUrl })
if (err) error.value = err.message || t('payment.result.failed')
} else if (method === 'wechat_pay') {
// WeChat: Stripe shows its built-in QR dialog, user scans, promise resolves
hint.value = t('payment.stripePopup.loadingQr')
const result = await (stripe as unknown as StripeWithWechatPay).confirmWechatPayPayment(clientSecret, {
payment_method_options: { wechat_pay: { client: isMobileDevice() ? 'mobile_web' : 'web' } },
})
if (result.error) {
error.value = result.error.message || t('payment.result.failed')
} else if (result.paymentIntent?.status === 'succeeded') {
success.value = true
setTimeout(closeWindow, 2000)
} else {
// Payment not completed (user closed QR dialog)
startPolling()
}
}
} catch (err: unknown) {
error.value = extractApiErrorMessage(err, t('payment.stripeLoadFailed'))
}
}
function startPolling() {
pollTimer = setInterval(async () => {
try {
const token = document.cookie.split('; ').find(c => c.startsWith('token='))?.split('=')[1]
|| localStorage.getItem('token') || ''
const res = await fetch('/api/v1/payment/orders/' + orderId, {
headers: token ? { Authorization: 'Bearer ' + token } : {},
credentials: 'include',
})
if (!res.ok) return
const data = await res.json()
const status = data?.data?.status
if (status === 'COMPLETED' || status === 'PAID') {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
success.value = true
setTimeout(closeWindow, 2000)
}
} catch { /* ignore */ }
}, 3000)
}
</script>
......@@ -28,39 +28,50 @@
<div
v-for="subscription in subscriptions"
:key="subscription.id"
class="card overflow-hidden"
class="overflow-hidden rounded-2xl border bg-white dark:bg-dark-800"
:class="platformBorderClass(subscription.group?.platform || '')"
>
<!-- Header -->
<div
class="flex items-center justify-between border-b border-gray-100 p-4 dark:border-dark-700"
>
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-xl bg-purple-100 dark:bg-purple-900/30"
>
<Icon name="creditCard" size="md" class="text-purple-600 dark:text-purple-400" />
</div>
<div :class="['h-1.5 w-1.5 shrink-0 rounded-full', platformAccentDotClass(subscription.group?.platform || '')]" />
<div>
<h3 class="font-semibold text-gray-900 dark:text-white">
{{ subscription.group?.name || `Group #${subscription.group_id}` }}
</h3>
<p class="text-xs text-gray-500 dark:text-dark-400">
{{ subscription.group?.description || '' }}
<div class="flex items-center gap-2">
<h3 class="font-semibold text-gray-900 dark:text-white">
{{ subscription.group?.name || `Group #${subscription.group_id}` }}
</h3>
<span :class="['rounded-md border px-2 py-0.5 text-[11px] font-medium', platformBadgeClass(subscription.group?.platform || '')]">
{{ platformLabel(subscription.group?.platform || '') }}
</span>
</div>
<p v-if="subscription.group?.description" class="mt-0.5 text-xs text-gray-500 dark:text-dark-400">
{{ subscription.group.description }}
</p>
</div>
</div>
<span
:class="[
'badge',
subscription.status === 'active'
? 'badge-success'
: subscription.status === 'expired'
? 'badge-warning'
: 'badge-danger'
]"
>
{{ t(`userSubscriptions.status.${subscription.status}`) }}
</span>
<div class="flex items-center gap-2">
<span
:class="[
'rounded-full px-2 py-0.5 text-xs font-medium',
subscription.status === 'active'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300'
: subscription.status === 'expired'
? 'bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
]"
>
{{ t(`userSubscriptions.status.${subscription.status}`) }}
</span>
<button
v-if="subscription.status === 'active'"
:class="['rounded-lg px-3 py-1.5 text-xs font-semibold text-white transition-colors', platformButtonClass(subscription.group?.platform || '')]"
@click="router.push({ path: '/purchase', query: { tab: 'subscription', group: String(subscription.group_id) } })"
>
{{ t('payment.renewNow') }}
</button>
</div>
</div>
<!-- Usage Progress -->
......@@ -237,14 +248,27 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
import subscriptionsAPI from '@/api/subscriptions'
import type { UserSubscription } from '@/types'
import AppLayout from '@/components/layout/AppLayout.vue'
import Icon from '@/components/icons/Icon.vue'
import { formatDateOnly } from '@/utils/format'
import { platformBorderClass, platformBadgeClass, platformButtonClass, platformLabel } from '@/utils/platformColors'
function platformAccentDotClass(p: string): string {
switch (p) {
case 'anthropic': return 'bg-orange-500'
case 'openai': return 'bg-emerald-500'
case 'antigravity': return 'bg-purple-500'
case 'gemini': return 'bg-blue-500'
default: return 'bg-gray-400'
}
}
const { t } = useI18n()
const router = useRouter()
const appStore = useAppStore()
const subscriptions = ref<UserSubscription[]>([])
......@@ -289,10 +313,10 @@ function formatExpirationDate(expiresAt: string): string {
const dateStr = formatDateOnly(expires)
if (days === 0) {
return `${dateStr} (Today)`
return `${dateStr} (${t('common.today')})`
}
if (days === 1) {
return `${dateStr} (Tomorrow)`
return `${dateStr} (${t('common.tomorrow')})`
}
return t('userSubscriptions.daysRemaining', { days }) + ` (${dateStr})`
......
<template>
<AppLayout>
<div class="space-y-4">
<!-- Filters -->
<div class="card p-4">
<div class="flex flex-wrap items-center gap-3">
<Select v-model="currentFilter" :options="statusFilters" class="w-36" @change="fetchOrders" />
<div class="flex flex-1 items-center justify-end gap-2">
<button @click="fetchOrders" :disabled="loading" class="btn btn-secondary" :title="t('common.refresh')">
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
<button class="btn btn-primary" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
</div>
</div>
</div>
<!-- Table -->
<OrderTable :orders="orders" :loading="loading">
<template #actions="{ row }">
<div class="flex items-center gap-2">
<button v-if="row.status === 'PENDING'" @click="handleCancel(row.id)" 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" />
<span>{{ t('payment.orders.cancel') }}</span>
</button>
<button v-if="row.status === 'COMPLETED'" @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="dollar" size="sm" />
<span>{{ t('payment.orders.requestRefund') }}</span>
</button>
</div>
</template>
</OrderTable>
<!-- Pagination -->
<Pagination
v-if="pagination.total > 0"
:page="pagination.page"
:total="pagination.total"
:page-size="pagination.page_size"
@update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/>
</div>
<!-- Cancel Confirm Dialog -->
<BaseDialog :show="!!cancelTargetId" :title="t('payment.orders.cancel')" width="narrow" @close="cancelTargetId = null">
<p class="text-sm text-gray-600 dark:text-gray-300">{{ t('payment.confirmCancel') }}</p>
<template #footer>
<div class="flex justify-end gap-3">
<button class="btn btn-secondary" @click="cancelTargetId = null">{{ t('common.cancel') }}</button>
<button class="btn btn-danger" :disabled="actionLoading" @click="confirmCancel">{{ actionLoading ? t('common.processing') : t('payment.orders.cancel') }}</button>
</div>
</template>
</BaseDialog>
<!-- Refund Dialog -->
<BaseDialog :show="!!refundTarget" :title="t('payment.orders.requestRefund')" @close="refundTarget = null">
<div v-if="refundTarget" class="space-y-4">
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-800">
<div class="flex justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</span>
<span class="font-mono text-gray-900 dark:text-white">#{{ refundTarget.id }}</span>
</div>
<div class="mt-2 flex justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
<span class="text-gray-900 dark:text-white">${{ refundTarget.amount.toFixed(2) }}</span>
</div>
</div>
<div>
<label class="input-label">{{ t('payment.refundReason') }}</label>
<textarea v-model="refundReason" rows="3" class="input mt-1 w-full" :placeholder="t('payment.refundReasonPlaceholder')" />
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<button class="btn btn-secondary" @click="refundTarget = null">{{ t('common.cancel') }}</button>
<button class="btn btn-primary" :disabled="actionLoading || !refundReason.trim()" @click="confirmRefund">{{ actionLoading ? t('common.processing') : t('payment.orders.requestRefund') }}</button>
</div>
</template>
</BaseDialog>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores'
import { paymentAPI } from '@/api/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 OrderTable from '@/components/payment/OrderTable.vue'
const { t } = useI18n()
const router = useRouter()
const appStore = useAppStore()
const loading = ref(false)
const actionLoading = ref(false)
const orders = ref<PaymentOrder[]>([])
const currentFilter = ref('')
const cancelTargetId = ref<number | null>(null)
const refundTarget = ref<PaymentOrder | null>(null)
const refundReason = ref('')
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
const statusFilters = computed(() => [
{ value: '', label: t('common.all') },
{ value: 'PENDING', label: t('payment.status.pending') },
{ value: 'COMPLETED', label: t('payment.status.completed') },
{ value: 'FAILED', label: t('payment.status.failed') },
{ value: 'REFUNDED', label: t('payment.status.refunded') },
])
async function fetchOrders() {
loading.value = true
try {
const res = await paymentAPI.getMyOrders({
page: pagination.page,
page_size: pagination.page_size,
status: currentFilter.value || undefined,
})
orders.value = res.data.items || []
pagination.total = res.data.total || 0
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
loading.value = false
}
}
function handlePageChange(page: number) { pagination.page = page; fetchOrders() }
function handlePageSizeChange(size: number) { pagination.page_size = size; pagination.page = 1; fetchOrders() }
function handleCancel(orderId: number) { cancelTargetId.value = orderId }
async function confirmCancel() {
if (!cancelTargetId.value) return
actionLoading.value = true
try {
await paymentAPI.cancelOrder(cancelTargetId.value)
appStore.showSuccess(t('common.success'))
cancelTargetId.value = null
await fetchOrders()
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
actionLoading.value = false
}
}
function openRefundDialog(order: PaymentOrder) { refundTarget.value = order; refundReason.value = '' }
async function confirmRefund() {
if (!refundTarget.value || !refundReason.value.trim()) return
actionLoading.value = true
try {
await paymentAPI.requestRefund(refundTarget.value.id, { reason: refundReason.value.trim() })
appStore.showSuccess(t('common.success'))
refundTarget.value = null
refundReason.value = ''
await fetchOrders()
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
actionLoading.value = false
}
}
onMounted(() => fetchOrders())
</script>
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment