feat(payment): add complete payment system with multi-provider support

Add a full payment and subscription system supporting EasyPay (Alipay/WeChat),
Stripe, and direct Alipay/WeChat Pay providers with multi-instance load balancing.
This commit is contained in:
erio
2026-04-10 21:08:51 +08:00
parent 00c08c574e
commit 63d1860dc0
166 changed files with 42743 additions and 220 deletions

View File

@@ -0,0 +1,161 @@
<template>
<div
:class="[
'group relative flex flex-col overflow-hidden rounded-2xl border transition-all',
'hover:shadow-xl hover:-translate-y-0.5',
borderClass,
'bg-white dark:bg-dark-800',
]"
>
<!-- Colored top accent bar -->
<div :class="['h-1.5', accentClass]" />
<div class="flex flex-1 flex-col p-4">
<!-- Header: name + badge + price -->
<div class="mb-3 flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h3 class="truncate text-base font-bold text-gray-900 dark:text-white">{{ plan.name }}</h3>
<span :class="['shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium', badgeLightClass]">
{{ pLabel }}
</span>
</div>
<p v-if="plan.description" class="mt-0.5 text-xs leading-relaxed text-gray-500 dark:text-dark-400 line-clamp-2">
{{ plan.description }}
</p>
</div>
<div class="shrink-0 text-right">
<div class="flex items-baseline gap-1">
<span class="text-xs text-gray-400 dark:text-dark-500">$</span>
<span :class="['text-2xl font-extrabold tracking-tight', textClass]">{{ plan.price }}</span>
</div>
<span class="text-[11px] text-gray-400 dark:text-dark-500">/ {{ validitySuffix }}</span>
<div v-if="plan.original_price" class="mt-0.5 flex items-center justify-end gap-1.5">
<span class="text-xs text-gray-400 line-through dark:text-dark-500">${{ plan.original_price }}</span>
<span :class="['rounded px-1 py-0.5 text-[10px] font-semibold', discountClass]">{{ discountText }}</span>
</div>
</div>
</div>
<!-- Group quota info (compact) -->
<div class="mb-3 grid grid-cols-2 gap-x-3 gap-y-1 rounded-lg bg-gray-50 px-3 py-2 text-xs dark:bg-dark-700/50">
<div class="flex items-center justify-between">
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.rate') }}</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ rateDisplay }}</span>
</div>
<div v-if="plan.daily_limit_usd != null" class="flex items-center justify-between">
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.dailyLimit') }}</span>
<span class="font-medium text-gray-700 dark:text-gray-300">${{ plan.daily_limit_usd }}</span>
</div>
<div v-if="plan.weekly_limit_usd != null" class="flex items-center justify-between">
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.weeklyLimit') }}</span>
<span class="font-medium text-gray-700 dark:text-gray-300">${{ plan.weekly_limit_usd }}</span>
</div>
<div v-if="plan.monthly_limit_usd != null" class="flex items-center justify-between">
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.monthlyLimit') }}</span>
<span class="font-medium text-gray-700 dark:text-gray-300">${{ plan.monthly_limit_usd }}</span>
</div>
<div v-if="plan.daily_limit_usd == null && plan.weekly_limit_usd == null && plan.monthly_limit_usd == null" class="flex items-center justify-between">
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.quota') }}</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.planCard.unlimited') }}</span>
</div>
<div v-if="modelScopeLabels.length > 0" class="col-span-2 flex items-center justify-between">
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.models') }}</span>
<div class="flex flex-wrap justify-end gap-1">
<span v-for="scope in modelScopeLabels" :key="scope"
class="rounded bg-gray-200/80 px-1.5 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-dark-600 dark:text-gray-300">
{{ scope }}
</span>
</div>
</div>
</div>
<!-- Features list (compact) -->
<div v-if="plan.features.length > 0" class="mb-3 space-y-1">
<div v-for="feature in plan.features" :key="feature" class="flex items-start gap-1.5">
<svg :class="['mt-0.5 h-3.5 w-3.5 flex-shrink-0', iconClass]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
<span class="text-xs text-gray-600 dark:text-gray-300">{{ feature }}</span>
</div>
</div>
<div class="flex-1" />
<!-- Subscribe Button -->
<button
type="button"
:class="['w-full rounded-xl py-2.5 text-sm font-semibold transition-all active:scale-[0.98]', btnClass]"
@click="emit('select', plan)"
>
{{ isRenewal ? t('payment.renewNow') : t('payment.subscribeNow') }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { SubscriptionPlan } from '@/types/payment'
import type { UserSubscription } from '@/types'
import {
platformAccentBarClass,
platformBadgeLightClass,
platformBorderClass,
platformTextClass,
platformIconClass,
platformButtonClass,
platformDiscountClass,
platformLabel,
} from '@/utils/platformColors'
const props = defineProps<{ plan: SubscriptionPlan; activeSubscriptions?: UserSubscription[] }>()
const emit = defineEmits<{ select: [plan: SubscriptionPlan] }>()
const { t } = useI18n()
const platform = computed(() => props.plan.group_platform || '')
const isRenewal = computed(() =>
props.activeSubscriptions?.some(s => s.group_id === props.plan.group_id && s.status === 'active') ?? false
)
// Derived color classes from central config
const accentClass = computed(() => platformAccentBarClass(platform.value))
const borderClass = computed(() => platformBorderClass(platform.value))
const badgeLightClass = computed(() => platformBadgeLightClass(platform.value))
const textClass = computed(() => platformTextClass(platform.value))
const iconClass = computed(() => platformIconClass(platform.value))
const btnClass = computed(() => platformButtonClass(platform.value))
const discountClass = computed(() => platformDiscountClass(platform.value))
const pLabel = computed(() => platformLabel(platform.value))
const discountText = computed(() => {
if (!props.plan.original_price || props.plan.original_price <= 0) return ''
const pct = Math.round((1 - props.plan.price / props.plan.original_price) * 100)
return pct > 0 ? `-${pct}%` : ''
})
const rateDisplay = computed(() => {
const rate = props.plan.rate_multiplier ?? 1
return `×${Number(rate.toPrecision(10))}`
})
const MODEL_SCOPE_LABELS: Record<string, string> = {
claude: 'Claude',
gemini_text: 'Gemini',
gemini_image: 'Imagen',
}
const modelScopeLabels = computed(() => {
const scopes = props.plan.supported_model_scopes
if (!scopes || scopes.length === 0) return []
return scopes.map(s => MODEL_SCOPE_LABELS[s] || s)
})
const validitySuffix = computed(() => {
const u = props.plan.validity_unit || 'day'
if (u === 'month') return t('payment.perMonth')
if (u === 'year') return t('payment.perYear')
return `${props.plan.validity_days}${t('payment.days')}`
})
</script>