mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 16:26:30 +00:00
管理端表单保存的有效期单位是复数(days/weeks/months),数据库默认值 与历史数据是单数(day)。用户侧套餐卡片与订单确认面板此前只匹配单数 'month'(以及计费根本不支持的 'year'),管理端存的 'months' 永远落进 默认分支:「1 个月」的套餐被显示成「1天」(#4607),'weeks' 则会把 周数错标成天数。 后端计费 psComputeValidityDays 对 week/weeks、month/months 单复数均 按 ×7/×30 天换算,实扣是对的——这是纯显示缺陷,但直接造成「显示 1 天、 实付 1 个月」的定价误解。 修复:抽出与计费语义一一对应的 planValiditySuffix 辅助函数(单复数 归一化;month→「月/N个月」、week→「N周」、其余含未知单位一律按天, 与后端兜底一致),SubscriptionPlanCard 与 PaymentView 两处共用; 补充 zh/en 的 weeks 文案与单元/组件测试。
176 lines
8.3 KiB
Vue
176 lines
8.3 KiB
Vue
<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">{{ planCurrencySymbol }}</span>
|
||
<span :class="['text-2xl font-extrabold tracking-tight', textClass]">{{ plan.price }}</span>
|
||
<span v-if="plan.currency" class="text-xs font-medium text-gray-400 dark:text-dark-500">{{ plan.currency }}</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">{{ planCurrencySymbol }}{{ plan.original_price }}<template v-if="plan.currency"> {{ plan.currency }}</template></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="hasPeakRate" class="col-span-2 flex items-center justify-between gap-2">
|
||
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.peakRate') }}</span>
|
||
<span class="text-right font-medium text-amber-700 dark:text-amber-300">{{ peakRateDisplay }}</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 { useAppStore } from '@/stores/app'
|
||
import { hasPeakRate as groupHasPeakRate, formatPeakRateWindow, serverTimezoneLabel } from '@/utils/peak-rate'
|
||
import { planValiditySuffix } from './validity'
|
||
import { currencySymbol } from '@/components/payment/currency'
|
||
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 appStore = useAppStore()
|
||
const planCurrencySymbol = computed(() => currencySymbol(props.plan.currency || 'USD'))
|
||
|
||
const hasPeakRate = computed(() => groupHasPeakRate(props.plan))
|
||
|
||
const peakRateDisplay = computed(() => {
|
||
return formatPeakRateWindow(props.plan, serverTimezoneLabel(appStore.cachedPublicSettings?.server_utc_offset))
|
||
})
|
||
|
||
const MODEL_SCOPE_LABELS: Record<string, string> = {
|
||
claude: 'Claude',
|
||
gemini_text: 'Gemini',
|
||
gemini_image: 'Imagen',
|
||
}
|
||
|
||
const modelScopeLabels = computed(() => {
|
||
if (platform.value !== 'antigravity') return []
|
||
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(() => planValiditySuffix(props.plan, t))
|
||
</script>
|