2026-05-13 01:18:38 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<PageContainer>
|
|
|
|
|
|
<PageHeader
|
|
|
|
|
|
title="套餐中心"
|
|
|
|
|
|
description="购买每日额度或会员权益"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="mt-6 space-y-6">
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="loading"
|
|
|
|
|
|
class="py-16"
|
|
|
|
|
|
>
|
|
|
|
|
|
<LoadingState message="正在加载套餐..." />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
<CardSection
|
|
|
|
|
|
title="当前权益"
|
|
|
|
|
|
description="只展示仍在有效期内的套餐权益"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="activeEntitlements.length"
|
|
|
|
|
|
class="grid grid-cols-1 gap-3 lg:grid-cols-2"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="item in activeEntitlements"
|
|
|
|
|
|
:key="item.id"
|
|
|
|
|
|
class="rounded-lg border border-border/60 bg-muted/20 p-4"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="flex items-start justify-between gap-3">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div class="font-medium">
|
|
|
|
|
|
{{ planTitle(item.plan_id) }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="mt-1 text-xs text-muted-foreground">
|
|
|
|
|
|
{{ formatDate(item.starts_at) }} - {{ formatDate(item.expires_at) }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Badge variant="success">
|
|
|
|
|
|
生效中
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="mt-3 flex flex-wrap gap-1.5">
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
v-for="label in entitlementLabels(item.entitlements)"
|
|
|
|
|
|
:key="label"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ label }}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<EmptyState
|
|
|
|
|
|
v-else
|
|
|
|
|
|
title="暂无有效套餐"
|
|
|
|
|
|
description="购买套餐后,有效权益会显示在这里"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</CardSection>
|
|
|
|
|
|
|
|
|
|
|
|
<CardSection
|
|
|
|
|
|
title="可购买套餐"
|
|
|
|
|
|
description="支付成功后由回调自动发放权益"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
|
|
|
|
|
<Card
|
|
|
|
|
|
v-for="plan in purchaseablePlans"
|
|
|
|
|
|
:key="plan.id"
|
|
|
|
|
|
class="flex flex-col p-5"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="flex items-start justify-between gap-3">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h3 class="text-base font-semibold">
|
|
|
|
|
|
{{ plan.title }}
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<p class="mt-1 min-h-[32px] text-xs text-muted-foreground">
|
|
|
|
|
|
{{ plan.description || '标准套餐' }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Badge variant="outline">
|
|
|
|
|
|
{{ formatDuration(plan.duration_unit, plan.duration_value) }}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="mt-5">
|
|
|
|
|
|
<span class="text-3xl font-semibold tabular-nums">
|
|
|
|
|
|
{{ Number(plan.price_amount || 0).toFixed(2) }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span class="ml-1 text-sm text-muted-foreground">
|
|
|
|
|
|
{{ plan.price_currency }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="mt-5 flex flex-wrap gap-1.5">
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
v-for="label in entitlementLabels(plan.entitlements)"
|
|
|
|
|
|
:key="label"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ label }}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="replacementNotice(plan)"
|
|
|
|
|
|
class="mt-4 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs leading-5 text-amber-200"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ replacementNotice(plan) }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="mt-5 flex-1" />
|
|
|
|
|
|
|
|
|
|
|
|
<div class="mt-5 space-y-3">
|
|
|
|
|
|
<Select v-model="selectedChannel">
|
|
|
|
|
|
<SelectTrigger>
|
|
|
|
|
|
<SelectValue
|
2026-05-19 19:01:10 +08:00
|
|
|
|
:placeholder="checkoutOptions.length ? '选择支付通道' : '暂无可用支付通道'"
|
2026-05-13 01:18:38 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem
|
2026-05-19 19:01:10 +08:00
|
|
|
|
v-for="option in checkoutOptions"
|
|
|
|
|
|
:key="option.key"
|
|
|
|
|
|
:value="option.key"
|
2026-05-13 01:18:38 +08:00
|
|
|
|
>
|
|
|
|
|
|
{{ option.display_name }}
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
class="w-full"
|
|
|
|
|
|
:disabled="
|
|
|
|
|
|
checkoutLoadingPlanId === plan.id
|
2026-05-19 19:01:10 +08:00
|
|
|
|
|| checkoutOptions.length === 0
|
2026-05-13 01:18:38 +08:00
|
|
|
|
|| !selectedChannel
|
|
|
|
|
|
"
|
|
|
|
|
|
@click="checkoutPlan(plan)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<CreditCard class="mr-2 h-4 w-4" />
|
|
|
|
|
|
{{ checkoutLoadingPlanId === plan.id ? '创建订单中...' : '购买套餐' }}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="purchaseablePlans.length === 0"
|
|
|
|
|
|
class="xl:col-span-3"
|
|
|
|
|
|
>
|
|
|
|
|
|
<EmptyState
|
|
|
|
|
|
title="暂无可购买套餐"
|
|
|
|
|
|
description="管理员上架套餐后会显示在这里"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardSection>
|
|
|
|
|
|
|
|
|
|
|
|
<Card
|
|
|
|
|
|
v-if="latestCheckout"
|
|
|
|
|
|
class="p-4"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div class="text-sm font-medium">
|
|
|
|
|
|
最新订单:<span class="font-mono">{{ latestCheckout.order.order_no }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="mt-1 text-xs text-muted-foreground">
|
|
|
|
|
|
应付 {{ latestCheckout.order.pay_amount ?? '-' }} {{ latestCheckout.order.pay_currency || '' }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button
|
2026-05-19 19:01:10 +08:00
|
|
|
|
v-if="latestCheckoutActionLabel"
|
2026-05-13 01:18:38 +08:00
|
|
|
|
variant="outline"
|
2026-05-19 19:01:10 +08:00
|
|
|
|
@click="openLatestPayment"
|
2026-05-13 01:18:38 +08:00
|
|
|
|
>
|
2026-05-19 19:01:10 +08:00
|
|
|
|
{{ latestCheckoutActionLabel }}
|
2026-05-13 01:18:38 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
2026-05-19 19:01:10 +08:00
|
|
|
|
|
|
|
|
|
|
<StripePaymentDialog
|
|
|
|
|
|
v-model:open="stripeDialogOpen"
|
|
|
|
|
|
:instructions="stripePaymentInstructions"
|
|
|
|
|
|
title="套餐 Stripe 支付"
|
|
|
|
|
|
description="完成支付后,套餐权益会由 Stripe Webhook 自动发放。"
|
|
|
|
|
|
confirm-text="支付套餐"
|
|
|
|
|
|
@success="handleStripePaymentSuccess"
|
|
|
|
|
|
/>
|
2026-05-13 01:18:38 +08:00
|
|
|
|
</PageContainer>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { computed, onMounted, ref, watch } from 'vue'
|
|
|
|
|
|
import { CreditCard } from 'lucide-vue-next'
|
|
|
|
|
|
import {
|
|
|
|
|
|
billingApi,
|
|
|
|
|
|
type BillingDurationUnit,
|
|
|
|
|
|
type BillingEntitlement,
|
|
|
|
|
|
type BillingCheckoutResponse,
|
|
|
|
|
|
type BillingPlan,
|
|
|
|
|
|
type UserPlanEntitlement,
|
|
|
|
|
|
} from '@/api/billing'
|
|
|
|
|
|
import { walletApi, type WalletRechargeOption } from '@/api/wallet'
|
|
|
|
|
|
import {
|
|
|
|
|
|
Badge,
|
|
|
|
|
|
Button,
|
|
|
|
|
|
Card,
|
|
|
|
|
|
Select,
|
|
|
|
|
|
SelectContent,
|
|
|
|
|
|
SelectItem,
|
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
|
SelectValue,
|
|
|
|
|
|
} from '@/components/ui'
|
2026-05-19 19:01:10 +08:00
|
|
|
|
import { EmptyState, LoadingState, StripePaymentDialog } from '@/components/common'
|
2026-05-13 01:18:38 +08:00
|
|
|
|
import { CardSection, PageContainer, PageHeader } from '@/components/layout'
|
|
|
|
|
|
import { useToast } from '@/composables/useToast'
|
|
|
|
|
|
import { parseApiError } from '@/utils/errorParser'
|
|
|
|
|
|
import { log } from '@/utils/logger'
|
2026-05-19 19:01:10 +08:00
|
|
|
|
import {
|
|
|
|
|
|
getPaymentInstructionString,
|
|
|
|
|
|
getStripePaymentInstructions,
|
|
|
|
|
|
type PaymentInstructionMap,
|
|
|
|
|
|
} from '@/utils/paymentInstructions'
|
2026-05-13 01:18:38 +08:00
|
|
|
|
|
|
|
|
|
|
const { success, error: showError } = useToast()
|
|
|
|
|
|
|
|
|
|
|
|
const loading = ref(true)
|
|
|
|
|
|
const plans = ref<BillingPlan[]>([])
|
|
|
|
|
|
const entitlements = ref<UserPlanEntitlement[]>([])
|
|
|
|
|
|
const rechargeOptions = ref<WalletRechargeOption[]>([])
|
|
|
|
|
|
const selectedChannel = ref('')
|
|
|
|
|
|
const checkoutLoadingPlanId = ref<string | null>(null)
|
|
|
|
|
|
const latestCheckout = ref<BillingCheckoutResponse | null>(null)
|
2026-05-19 19:01:10 +08:00
|
|
|
|
const stripeDialogOpen = ref(false)
|
|
|
|
|
|
const stripePaymentInstructions = ref<PaymentInstructionMap | null>(null)
|
2026-05-13 01:18:38 +08:00
|
|
|
|
|
2026-05-19 19:01:10 +08:00
|
|
|
|
const checkoutOptions = computed(() =>
|
|
|
|
|
|
rechargeOptions.value
|
|
|
|
|
|
.filter((option) => Boolean(paymentOptionProvider(option)) && Boolean(option.payment_channel))
|
|
|
|
|
|
.map((option, index) => ({
|
|
|
|
|
|
...option,
|
|
|
|
|
|
key: [
|
|
|
|
|
|
paymentOptionProvider(option),
|
|
|
|
|
|
option.payment_method,
|
|
|
|
|
|
option.payment_channel || '',
|
|
|
|
|
|
index,
|
|
|
|
|
|
].join(':'),
|
|
|
|
|
|
}))
|
2026-05-13 01:18:38 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-19 19:01:10 +08:00
|
|
|
|
const selectedCheckoutOption = computed(() => {
|
|
|
|
|
|
if (checkoutOptions.value.length === 0) return null
|
|
|
|
|
|
return checkoutOptions.value.find(option => option.key === selectedChannel.value)
|
|
|
|
|
|
|| checkoutOptions.value[0]
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-13 01:18:38 +08:00
|
|
|
|
const activeEntitlements = computed(() =>
|
|
|
|
|
|
entitlements.value.filter((item) =>
|
|
|
|
|
|
item.active !== false
|
|
|
|
|
|
&& item.status === 'active'
|
|
|
|
|
|
&& hasPackageEntitlement(item.entitlements)
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const purchaseablePlans = computed(() =>
|
|
|
|
|
|
plans.value.filter((plan) => hasPackageEntitlement(plan.entitlements))
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const latestPaymentUrl = computed(() => {
|
|
|
|
|
|
const value = latestCheckout.value?.payment_instructions?.payment_url
|
|
|
|
|
|
return typeof value === 'string' && value ? value : ''
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-19 19:01:10 +08:00
|
|
|
|
const latestCheckoutActionLabel = computed(() => {
|
|
|
|
|
|
const instructions = latestCheckout.value?.payment_instructions
|
|
|
|
|
|
if (getStripePaymentInstructions(instructions)) return '打开 Stripe 支付'
|
|
|
|
|
|
if (latestPaymentUrl.value) return '打开支付链接'
|
|
|
|
|
|
return ''
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
watch(checkoutOptions, (options) => {
|
|
|
|
|
|
const keys = options.map(option => option.key)
|
|
|
|
|
|
if (!keys.includes(selectedChannel.value)) {
|
|
|
|
|
|
selectedChannel.value = keys[0] || ''
|
2026-05-13 01:18:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
}, { immediate: true })
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(async () => {
|
|
|
|
|
|
await Promise.all([
|
|
|
|
|
|
loadPlans(),
|
|
|
|
|
|
loadEntitlements(),
|
|
|
|
|
|
loadRechargeOptions(),
|
|
|
|
|
|
])
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
async function loadPlans() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await billingApi.listPlans()
|
|
|
|
|
|
plans.value = response.items
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
log.error('加载套餐失败:', err)
|
|
|
|
|
|
showError(parseApiError(err, '加载套餐失败'))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadEntitlements() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await billingApi.listEntitlements()
|
|
|
|
|
|
entitlements.value = response.items
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
log.error('加载套餐权益失败:', err)
|
|
|
|
|
|
showError(parseApiError(err, '加载套餐权益失败'))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadRechargeOptions() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await walletApi.listRechargeOptions()
|
|
|
|
|
|
rechargeOptions.value = response.items
|
2026-05-19 19:01:10 +08:00
|
|
|
|
if (!selectedChannel.value && checkoutOptions.value.length > 0) {
|
|
|
|
|
|
selectedChannel.value = checkoutOptions.value[0].key
|
2026-05-13 01:18:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
log.error('加载支付通道失败:', err)
|
|
|
|
|
|
showError(parseApiError(err, '加载支付通道失败'))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function checkoutPlan(plan: BillingPlan) {
|
|
|
|
|
|
if (hasMatchingActivePlan(plan)) {
|
|
|
|
|
|
const confirmed = window.confirm('购买成功后,同类旧套餐会自动失效。确定继续购买吗?')
|
|
|
|
|
|
if (!confirmed) return
|
|
|
|
|
|
}
|
2026-05-19 19:01:10 +08:00
|
|
|
|
const option = selectedCheckoutOption.value
|
|
|
|
|
|
if (!option) {
|
|
|
|
|
|
showError('请选择支付方式')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const provider = paymentOptionProvider(option)
|
|
|
|
|
|
if (!provider) {
|
|
|
|
|
|
showError('支付方式配置无效')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-05-13 01:18:38 +08:00
|
|
|
|
checkoutLoadingPlanId.value = plan.id
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await billingApi.checkout(plan.id, {
|
2026-05-19 19:01:10 +08:00
|
|
|
|
payment_method: option.payment_method || provider,
|
|
|
|
|
|
payment_provider: provider,
|
|
|
|
|
|
payment_channel: option.payment_channel,
|
2026-05-13 01:18:38 +08:00
|
|
|
|
})
|
|
|
|
|
|
latestCheckout.value = response
|
|
|
|
|
|
success('套餐订单已创建')
|
|
|
|
|
|
submitPaymentInstructions(response.payment_instructions)
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
log.error('创建套餐订单失败:', err)
|
|
|
|
|
|
showError(parseApiError(err, '创建套餐订单失败'))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
checkoutLoadingPlanId.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 19:01:10 +08:00
|
|
|
|
function openLatestPayment() {
|
|
|
|
|
|
submitPaymentInstructions(latestCheckout.value?.payment_instructions || { payment_url: latestPaymentUrl.value })
|
2026-05-13 01:18:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function submitPaymentInstructions(instructions: Record<string, unknown> | null | undefined) {
|
|
|
|
|
|
if (!instructions) return
|
2026-05-19 19:01:10 +08:00
|
|
|
|
const stripeInstructions = getStripePaymentInstructions(instructions)
|
|
|
|
|
|
if (stripeInstructions) {
|
|
|
|
|
|
stripePaymentInstructions.value = instructions
|
|
|
|
|
|
stripeDialogOpen.value = true
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const paymentUrl = getPaymentInstructionString(instructions, 'payment_url')
|
|
|
|
|
|
if (!paymentUrl) return
|
2026-05-13 01:18:38 +08:00
|
|
|
|
const paymentParams = instructions.payment_params
|
|
|
|
|
|
if (paymentParams && typeof paymentParams === 'object' && !Array.isArray(paymentParams)) {
|
|
|
|
|
|
submitPaymentForm(paymentUrl, paymentParams as Record<string, unknown>)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const opened = window.open(paymentUrl, '_blank', 'noopener,noreferrer')
|
|
|
|
|
|
if (!opened) {
|
|
|
|
|
|
window.location.href = paymentUrl
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function submitPaymentForm(url: string, params: Record<string, unknown>) {
|
|
|
|
|
|
const form = document.createElement('form')
|
|
|
|
|
|
form.action = url
|
|
|
|
|
|
form.method = 'POST'
|
|
|
|
|
|
if (!isSafariBrowser()) {
|
|
|
|
|
|
form.target = '_blank'
|
|
|
|
|
|
}
|
|
|
|
|
|
Object.entries(params).forEach(([key, value]) => {
|
|
|
|
|
|
if (value === null || value === undefined) return
|
|
|
|
|
|
const input = document.createElement('input')
|
|
|
|
|
|
input.type = 'hidden'
|
|
|
|
|
|
input.name = key
|
|
|
|
|
|
input.value = String(value)
|
|
|
|
|
|
form.appendChild(input)
|
|
|
|
|
|
})
|
|
|
|
|
|
document.body.appendChild(form)
|
|
|
|
|
|
form.submit()
|
|
|
|
|
|
document.body.removeChild(form)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isSafariBrowser(): boolean {
|
|
|
|
|
|
return navigator.userAgent.includes('Safari') && !navigator.userAgent.includes('Chrome')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 19:01:10 +08:00
|
|
|
|
async function handleStripePaymentSuccess() {
|
|
|
|
|
|
success('支付已完成,正在刷新套餐状态')
|
|
|
|
|
|
await Promise.all([loadEntitlements(), loadPlans()])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function paymentOptionProvider(option: WalletRechargeOption): string {
|
|
|
|
|
|
return (option.payment_provider || option.provider || option.payment_method || '').trim()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 01:18:38 +08:00
|
|
|
|
function planTitle(planId: string): string {
|
|
|
|
|
|
return plans.value.find((plan) => plan.id === planId)?.title || planId
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function hasMatchingActivePlan(plan: BillingPlan): boolean {
|
|
|
|
|
|
const replacesDailyQuota = hasDailyQuotaEntitlement(plan.entitlements)
|
|
|
|
|
|
const replacesMembership = hasMembershipEntitlement(plan.entitlements)
|
|
|
|
|
|
if (!replacesDailyQuota && !replacesMembership) return false
|
|
|
|
|
|
return activeEntitlements.value.some((item) =>
|
|
|
|
|
|
(replacesDailyQuota && hasDailyQuotaEntitlement(item.entitlements))
|
|
|
|
|
|
|| (replacesMembership && hasMembershipEntitlement(item.entitlements))
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function replacementNotice(plan: BillingPlan): string {
|
|
|
|
|
|
const labels = replacementClassLabels(plan.entitlements)
|
|
|
|
|
|
if (labels.length === 0) return ''
|
|
|
|
|
|
if (hasMatchingActivePlan(plan)) {
|
|
|
|
|
|
return `你已有有效${labels.join('和')},购买成功后旧同类套餐会自动失效。`
|
|
|
|
|
|
}
|
|
|
|
|
|
return `若已有有效${labels.join('和')},购买成功后旧同类套餐会自动失效。`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function entitlementLabels(items: BillingEntitlement[]): string[] {
|
|
|
|
|
|
return (items || []).map((item) => {
|
|
|
|
|
|
if (item.type === 'wallet_credit') {
|
|
|
|
|
|
return `附赠余额 $${Number(item.amount_usd || 0).toFixed(2)}`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (item.type === 'daily_quota') {
|
|
|
|
|
|
return `每日 $${Number(item.daily_quota_usd || 0).toFixed(2)}`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (item.type === 'membership_group') {
|
|
|
|
|
|
return `会员组 ${item.grant_user_groups.join(', ')}`
|
|
|
|
|
|
}
|
|
|
|
|
|
return item.type
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function hasPackageEntitlement(items: BillingEntitlement[] | undefined): boolean {
|
|
|
|
|
|
return (items || []).some((item) =>
|
|
|
|
|
|
item.type === 'daily_quota' || item.type === 'membership_group'
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function hasDailyQuotaEntitlement(items: BillingEntitlement[] | undefined): boolean {
|
|
|
|
|
|
return (items || []).some((item) => item.type === 'daily_quota')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function hasMembershipEntitlement(items: BillingEntitlement[] | undefined): boolean {
|
|
|
|
|
|
return (items || []).some((item) => item.type === 'membership_group')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function replacementClassLabels(items: BillingEntitlement[] | undefined): string[] {
|
|
|
|
|
|
const labels: string[] = []
|
|
|
|
|
|
if (hasDailyQuotaEntitlement(items)) labels.push('每日额度套餐')
|
|
|
|
|
|
if (hasMembershipEntitlement(items)) labels.push('会员权益包')
|
|
|
|
|
|
return labels
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatDuration(unit: BillingDurationUnit, value: number): string {
|
|
|
|
|
|
const labels: Record<BillingDurationUnit, string> = {
|
|
|
|
|
|
day: '天',
|
|
|
|
|
|
month: '个月',
|
|
|
|
|
|
year: '年',
|
|
|
|
|
|
custom: '自定义周期',
|
|
|
|
|
|
}
|
|
|
|
|
|
return unit === 'custom' ? `${value} ${labels[unit]}` : `${value}${labels[unit]}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatDate(value: string | null | undefined): string {
|
|
|
|
|
|
if (!value) return '-'
|
|
|
|
|
|
return new Date(value).toLocaleDateString('zh-CN')
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|