Files
sub2api/frontend/src/components/payment/SubscriptionPlanCard.vue
erio 63d1860dc0 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.
2026-04-11 13:16:35 +08:00

162 lines
7.3 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>