2026-05-13 01:18:38 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<PageContainer padding="lg">
|
|
|
|
|
|
<PageHeader
|
|
|
|
|
|
title="套餐管理"
|
|
|
|
|
|
description="配置每日额度、会员权益和混合套餐"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template #actions>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
@click="openCreateDialog"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Plus class="mr-2 h-4 w-4" />
|
|
|
|
|
|
新建套餐
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</PageHeader>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="mt-6 space-y-6">
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="loading"
|
|
|
|
|
|
class="py-16"
|
|
|
|
|
|
>
|
|
|
|
|
|
<LoadingState message="正在加载套餐..." />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<CardSection
|
|
|
|
|
|
v-else
|
|
|
|
|
|
title="套餐列表"
|
|
|
|
|
|
description="启用后的套餐会出现在用户套餐中心"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="plans.length === 0"
|
|
|
|
|
|
class="py-12"
|
|
|
|
|
|
>
|
|
|
|
|
|
<EmptyState
|
|
|
|
|
|
title="暂无套餐"
|
|
|
|
|
|
description="创建第一个套餐后,用户可以在套餐中心购买"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="overflow-x-auto"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Table>
|
|
|
|
|
|
<TableHeader>
|
|
|
|
|
|
<TableRow>
|
|
|
|
|
|
<TableHead class="w-[24%]">
|
|
|
|
|
|
套餐
|
|
|
|
|
|
</TableHead>
|
|
|
|
|
|
<TableHead class="w-[10%] whitespace-nowrap">
|
|
|
|
|
|
价格
|
|
|
|
|
|
</TableHead>
|
|
|
|
|
|
<TableHead class="w-[18%] whitespace-nowrap">
|
|
|
|
|
|
周期
|
|
|
|
|
|
</TableHead>
|
|
|
|
|
|
<TableHead class="w-[20%]">
|
|
|
|
|
|
权益
|
|
|
|
|
|
</TableHead>
|
|
|
|
|
|
<TableHead class="w-[6%] whitespace-nowrap text-center">
|
|
|
|
|
|
排序
|
|
|
|
|
|
</TableHead>
|
|
|
|
|
|
<TableHead class="w-[7%] whitespace-nowrap text-center">
|
|
|
|
|
|
状态
|
|
|
|
|
|
</TableHead>
|
|
|
|
|
|
<TableHead class="w-[15%] whitespace-nowrap text-right">
|
|
|
|
|
|
操作
|
|
|
|
|
|
</TableHead>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
</TableHeader>
|
|
|
|
|
|
<TableBody>
|
|
|
|
|
|
<TableRow
|
|
|
|
|
|
v-for="plan in plans"
|
|
|
|
|
|
:key="plan.id"
|
|
|
|
|
|
>
|
|
|
|
|
|
<TableCell>
|
|
|
|
|
|
<div class="text-sm font-medium">
|
|
|
|
|
|
{{ plan.title }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="plan.description"
|
|
|
|
|
|
class="mt-0.5 max-w-[280px] truncate text-xs text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ plan.description }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell class="whitespace-nowrap text-sm tabular-nums">
|
|
|
|
|
|
{{ formatPlanPriceAmount(plan) }} {{ plan.price_currency }}
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell>
|
|
|
|
|
|
<div class="whitespace-nowrap text-sm">
|
|
|
|
|
|
{{ formatPlanPeriod(plan) }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="mt-0.5 text-xs text-muted-foreground">
|
|
|
|
|
|
{{ planDurationHint(plan) }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell>
|
|
|
|
|
|
<div class="flex flex-wrap items-center gap-1">
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-for="item in entitlementBadges(plan)"
|
|
|
|
|
|
:key="item"
|
|
|
|
|
|
class="inline-flex whitespace-nowrap rounded-md border border-border/60 bg-muted/40 px-2 py-0.5 text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ item }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell class="text-center text-sm tabular-nums text-muted-foreground">
|
|
|
|
|
|
{{ plan.sort_order }}
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell class="text-center">
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="inline-flex items-center gap-1.5 text-xs"
|
|
|
|
|
|
:class="plan.enabled ? 'text-emerald-500' : 'text-muted-foreground'"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="h-1.5 w-1.5 rounded-full"
|
|
|
|
|
|
:class="plan.enabled ? 'bg-emerald-500' : 'bg-muted-foreground/40'"
|
|
|
|
|
|
/>
|
|
|
|
|
|
{{ plan.enabled ? '已启用' : '已停用' }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell class="whitespace-nowrap text-right">
|
|
|
|
|
|
<div class="inline-flex items-center">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
:disabled="deletingPlanId === plan.id"
|
|
|
|
|
|
@click="togglePlanStatus(plan)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ plan.enabled ? '停用' : '启用' }}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
:disabled="deletingPlanId === plan.id"
|
|
|
|
|
|
@click="openEditDialog(plan)"
|
|
|
|
|
|
>
|
|
|
|
|
|
编辑
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<DropdownMenu>
|
|
|
|
|
|
<DropdownMenuTrigger as-child>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-9 w-9 p-0"
|
|
|
|
|
|
:disabled="deletingPlanId === plan.id"
|
|
|
|
|
|
>
|
|
|
|
|
|
<MoreHorizontal class="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
|
<DropdownMenuContent align="end">
|
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
|
class="text-destructive focus:text-destructive"
|
|
|
|
|
|
:disabled="deletingPlanId === plan.id"
|
|
|
|
|
|
@select="deletePlan(plan)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 class="mr-2 h-4 w-4" />
|
|
|
|
|
|
{{ deletingPlanId === plan.id ? '删除中...' : '删除套餐' }}
|
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
</TableBody>
|
|
|
|
|
|
</Table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardSection>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<Dialog
|
|
|
|
|
|
v-model:open="dialogOpen"
|
|
|
|
|
|
size="4xl"
|
|
|
|
|
|
:title="editingPlan ? '编辑套餐' : '新建套餐'"
|
|
|
|
|
|
description="通过固定权益控件生成套餐配置"
|
|
|
|
|
|
no-padding
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="max-h-[calc(100vh-193px)] space-y-4 overflow-y-auto px-6 py-4">
|
|
|
|
|
|
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-12 justify-start rounded-xl px-3 text-left"
|
|
|
|
|
|
@click="applyTemplate('daily')"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>
|
|
|
|
|
|
<span class="block text-sm font-medium leading-5">每日额度月卡</span>
|
|
|
|
|
|
<span class="block text-xs font-normal leading-4 text-muted-foreground">周期内每天重置</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-12 justify-start rounded-xl px-3 text-left"
|
|
|
|
|
|
@click="applyTemplate('membership')"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>
|
|
|
|
|
|
<span class="block text-sm font-medium leading-5">会员权益包</span>
|
|
|
|
|
|
<span class="block text-xs font-normal leading-4 text-muted-foreground">动态授予分组</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-12 justify-start rounded-xl px-3 text-left"
|
|
|
|
|
|
@click="applyTemplate('mixed')"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>
|
|
|
|
|
|
<span class="block text-sm font-medium leading-5">混合套餐</span>
|
|
|
|
|
|
<span class="block text-xs font-normal leading-4 text-muted-foreground">周期权益组合</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="rounded-xl border border-border/60 bg-muted/20 px-3 py-2">
|
|
|
|
|
|
<div class="grid grid-cols-1 gap-2 lg:grid-cols-[1fr_auto]">
|
|
|
|
|
|
<div class="min-w-0 space-y-1.5">
|
|
|
|
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
|
|
|
|
<div class="text-sm font-medium">
|
|
|
|
|
|
{{ planModeGuide.title }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Badge variant="outline">
|
|
|
|
|
|
{{ planModeGuide.badge }}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p class="text-xs leading-5 text-muted-foreground">
|
|
|
|
|
|
{{ planModeGuide.description }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="flex flex-wrap items-center gap-1.5 lg:justify-end">
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-for="note in planModeGuide.notes"
|
|
|
|
|
|
:key="note"
|
|
|
|
|
|
class="rounded-full border border-border/60 bg-card/60 px-2.5 py-1 text-xs leading-4 text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ note }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="planMode !== 'empty'"
|
|
|
|
|
|
class="mx-auto w-full max-w-[880px] rounded-2xl border border-border/60 bg-muted/10 p-6"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="grid grid-cols-1 gap-x-4 gap-y-3 xl:grid-cols-12">
|
|
|
|
|
|
<div class="border-b border-border/70 pb-2 xl:col-span-12">
|
|
|
|
|
|
<h3 class="text-sm font-semibold leading-5">
|
|
|
|
|
|
基础信息
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="space-y-1.5 xl:col-span-8">
|
|
|
|
|
|
<Label
|
|
|
|
|
|
for="plan-title"
|
|
|
|
|
|
class="inline-flex items-center gap-1.5 text-sm font-medium"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>套餐名称</span>
|
|
|
|
|
|
<span class="text-destructive">*</span>
|
|
|
|
|
|
<TooltipProvider>
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger as-child>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="text-muted-foreground/60 hover:text-muted-foreground"
|
|
|
|
|
|
aria-label="套餐名称说明"
|
|
|
|
|
|
>
|
|
|
|
|
|
<CircleHelp class="h-3.5 w-3.5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent
|
|
|
|
|
|
side="top"
|
|
|
|
|
|
class="max-w-64 text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
用户端购买页和订单快照里显示的套餐名称。
|
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</TooltipProvider>
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="plan-title"
|
|
|
|
|
|
v-model="form.title"
|
|
|
|
|
|
class="h-9 rounded-xl bg-muted/70"
|
|
|
|
|
|
placeholder="Pro 月卡"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="space-y-1.5 xl:col-span-4">
|
|
|
|
|
|
<Label
|
|
|
|
|
|
for="plan-price"
|
|
|
|
|
|
class="inline-flex items-center gap-1.5 text-sm font-medium"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>价格</span>
|
|
|
|
|
|
<span class="text-destructive">*</span>
|
|
|
|
|
|
<TooltipProvider>
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger as-child>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="text-muted-foreground/60 hover:text-muted-foreground"
|
|
|
|
|
|
aria-label="价格字段说明"
|
|
|
|
|
|
>
|
|
|
|
|
|
<CircleHelp class="h-3.5 w-3.5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent
|
|
|
|
|
|
side="top"
|
|
|
|
|
|
class="max-w-72 text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
设置用户实际支付的套餐价格。
|
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</TooltipProvider>
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<div class="grid grid-cols-[minmax(0,1fr)_88px]">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="plan-price"
|
|
|
|
|
|
v-model.number="form.price_amount"
|
|
|
|
|
|
class="h-9 rounded-l-xl rounded-r-none border-r-0 bg-muted/70 focus-visible:z-10"
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
inputmode="decimal"
|
|
|
|
|
|
min="0.01"
|
|
|
|
|
|
step="0.01"
|
|
|
|
|
|
@blur="normalizePriceAmount"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Select v-model="form.price_currency">
|
|
|
|
|
|
<SelectTrigger class="h-9 rounded-l-none rounded-r-xl border-l-0 bg-muted/70 px-3 focus:z-10">
|
|
|
|
|
|
<SelectValue />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem
|
|
|
|
|
|
v-for="currency in priceCurrencyOptions"
|
|
|
|
|
|
:key="currency"
|
|
|
|
|
|
:value="currency"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ currency }}
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="space-y-1.5 xl:col-span-12">
|
|
|
|
|
|
<Label
|
|
|
|
|
|
for="plan-description"
|
|
|
|
|
|
class="inline-flex items-center gap-1.5 text-sm font-medium"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>说明</span>
|
|
|
|
|
|
<TooltipProvider>
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger as-child>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="text-muted-foreground/60 hover:text-muted-foreground"
|
|
|
|
|
|
aria-label="套餐说明字段说明"
|
|
|
|
|
|
>
|
|
|
|
|
|
<CircleHelp class="h-3.5 w-3.5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent
|
|
|
|
|
|
side="top"
|
|
|
|
|
|
class="max-w-72 text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
简短描述套餐包含的权益,建议控制在一两句话内。
|
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</TooltipProvider>
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
id="plan-description"
|
|
|
|
|
|
v-model="form.description"
|
|
|
|
|
|
class="min-h-[72px] resize-y rounded-2xl bg-muted/70"
|
|
|
|
|
|
rows="2"
|
|
|
|
|
|
placeholder="简短说明套餐权益"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<section class="mt-5 space-y-3">
|
|
|
|
|
|
<div class="border-b border-border/70 pb-2">
|
|
|
|
|
|
<h3 class="text-sm font-semibold leading-5">
|
|
|
|
|
|
购买限制
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="grid grid-cols-1 gap-x-4 gap-y-3 xl:grid-cols-12">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="space-y-1.5"
|
|
|
|
|
|
:class="purchaseLimitFieldSpanClass"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Label
|
|
|
|
|
|
for="plan-purchase-limit-scope"
|
|
|
|
|
|
class="inline-flex items-center gap-1.5 text-sm font-medium"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>重复购买限制</span>
|
|
|
|
|
|
<TooltipProvider>
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger as-child>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="text-muted-foreground/60 hover:text-muted-foreground"
|
|
|
|
|
|
aria-label="重复购买限制说明"
|
|
|
|
|
|
>
|
|
|
|
|
|
<CircleHelp class="h-3.5 w-3.5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent
|
|
|
|
|
|
side="top"
|
|
|
|
|
|
class="max-w-72 text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
控制同一用户能否重复购买本套餐;不决定余额、每日额度或会员分组怎么发放。
|
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</TooltipProvider>
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<Select v-model="form.purchase_limit_scope">
|
|
|
|
|
|
<SelectTrigger
|
|
|
|
|
|
id="plan-purchase-limit-scope"
|
|
|
|
|
|
class="h-9 rounded-xl bg-muted/70 px-3"
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectValue />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value="active_period">
|
|
|
|
|
|
按周期限制
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
<SelectItem value="lifetime">
|
|
|
|
|
|
永久限制
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
<SelectItem value="unlimited">
|
|
|
|
|
|
不限购
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="showPurchaseLimitPeriod"
|
|
|
|
|
|
class="space-y-1.5 xl:col-span-4"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Label
|
|
|
|
|
|
for="plan-duration"
|
|
|
|
|
|
class="inline-flex items-center gap-1.5 text-sm font-medium"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>{{ durationFieldLabel }}</span>
|
|
|
|
|
|
<span class="text-destructive">*</span>
|
|
|
|
|
|
<TooltipProvider>
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger as-child>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="text-muted-foreground/60 hover:text-muted-foreground"
|
|
|
|
|
|
aria-label="周期窗口说明"
|
|
|
|
|
|
>
|
|
|
|
|
|
<CircleHelp class="h-3.5 w-3.5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent
|
|
|
|
|
|
side="top"
|
|
|
|
|
|
class="max-w-72 text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ durationTooltipText }}
|
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</TooltipProvider>
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<div class="grid grid-cols-[minmax(0,1fr)_88px]">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="plan-duration"
|
|
|
|
|
|
v-model.number="form.duration_value"
|
|
|
|
|
|
class="h-9 rounded-l-xl rounded-r-none border-r-0 bg-muted/70 focus-visible:z-10"
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
inputmode="numeric"
|
|
|
|
|
|
min="1"
|
|
|
|
|
|
step="1"
|
|
|
|
|
|
@blur="normalizeDurationValue"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Select v-model="form.duration_unit">
|
|
|
|
|
|
<SelectTrigger class="h-9 rounded-l-none rounded-r-xl border-l-0 bg-muted/70 px-3 focus:z-10">
|
|
|
|
|
|
<SelectValue />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value="day">
|
|
|
|
|
|
日
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
<SelectItem value="month">
|
|
|
|
|
|
月
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
<SelectItem value="year">
|
|
|
|
|
|
年
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="showPurchaseLimitCount"
|
|
|
|
|
|
class="space-y-1.5"
|
|
|
|
|
|
:class="purchaseLimitFieldSpanClass"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Label
|
|
|
|
|
|
for="plan-max-active"
|
|
|
|
|
|
class="inline-flex items-center gap-1.5 text-sm font-medium"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>{{ activeLimitFieldLabel }}</span>
|
|
|
|
|
|
<TooltipProvider>
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger as-child>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="text-muted-foreground/60 hover:text-muted-foreground"
|
|
|
|
|
|
aria-label="最多持有份数字段说明"
|
|
|
|
|
|
>
|
|
|
|
|
|
<CircleHelp class="h-3.5 w-3.5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent
|
|
|
|
|
|
side="top"
|
|
|
|
|
|
class="max-w-72 text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ activeLimitTooltipText }}
|
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</TooltipProvider>
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="plan-max-active"
|
|
|
|
|
|
v-model.number="form.max_active_per_user"
|
|
|
|
|
|
class="h-9 rounded-xl bg-muted/70"
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
inputmode="numeric"
|
|
|
|
|
|
min="1"
|
|
|
|
|
|
step="1"
|
|
|
|
|
|
@blur="normalizeActiveLimit"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="xl:col-span-12 rounded-xl border border-border/60 bg-muted/20 px-3 py-2 text-xs leading-5 text-muted-foreground">
|
|
|
|
|
|
<span class="font-medium text-foreground/80">当前逻辑:</span>
|
|
|
|
|
|
{{ purchaseLimitSummaryText }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="xl:col-span-12 rounded-xl border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs leading-5 text-amber-200">
|
|
|
|
|
|
同一用户只保留一个有效每日额度套餐、一个有效会员权益包。购买新的同类套餐后,旧同类套餐会自动失效;混合套餐会同时替换这两类旧权益。
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section class="mt-5 space-y-3">
|
|
|
|
|
|
<div class="border-b border-border/70 pb-2">
|
|
|
|
|
|
<h3 class="text-sm font-semibold leading-5">
|
|
|
|
|
|
展示与上架
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="grid grid-cols-1 gap-x-4 gap-y-3 xl:grid-cols-12">
|
|
|
|
|
|
<div class="space-y-1.5 xl:col-span-6">
|
|
|
|
|
|
<Label
|
|
|
|
|
|
for="plan-sort"
|
|
|
|
|
|
class="inline-flex items-center gap-1.5 text-sm font-medium"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>展示排序</span>
|
|
|
|
|
|
<TooltipProvider>
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger as-child>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="text-muted-foreground/60 hover:text-muted-foreground"
|
|
|
|
|
|
aria-label="展示排序说明"
|
|
|
|
|
|
>
|
|
|
|
|
|
<CircleHelp class="h-3.5 w-3.5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent
|
|
|
|
|
|
side="top"
|
|
|
|
|
|
class="max-w-64 text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
数值越小越靠前,用户端套餐列表按排序值升序展示。
|
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</TooltipProvider>
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="plan-sort"
|
|
|
|
|
|
v-model.number="form.sort_order"
|
|
|
|
|
|
class="h-9 rounded-xl bg-muted/70"
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
step="1"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="space-y-1.5 xl:col-span-6">
|
|
|
|
|
|
<Label class="inline-flex items-center gap-1.5 text-sm font-medium">
|
|
|
|
|
|
<span>上架状态</span>
|
|
|
|
|
|
<TooltipProvider>
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger as-child>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="text-muted-foreground/60 hover:text-muted-foreground"
|
|
|
|
|
|
aria-label="上架状态说明"
|
|
|
|
|
|
>
|
|
|
|
|
|
<CircleHelp class="h-3.5 w-3.5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent
|
|
|
|
|
|
side="top"
|
|
|
|
|
|
class="max-w-64 text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
停用后保留配置,但用户端套餐中心不再展示。
|
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</TooltipProvider>
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<div class="flex h-9 items-center justify-between rounded-xl border border-border/60 bg-muted/70 px-3">
|
|
|
|
|
|
<span class="text-sm text-muted-foreground">
|
|
|
|
|
|
{{ form.enabled ? '已上架' : '未上架' }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<Switch v-model="form.enabled" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<section
|
|
|
|
|
|
v-if="planMode !== 'empty'"
|
|
|
|
|
|
class="space-y-4"
|
|
|
|
|
|
>
|
|
|
|
|
|
<h3 class="text-sm font-semibold">
|
|
|
|
|
|
权益配置
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="showWalletCreditConfig"
|
|
|
|
|
|
class="space-y-3 rounded-2xl border border-border/60 bg-muted/20 p-4"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="flex items-center justify-between gap-3">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label class="text-sm font-medium">附赠余额</Label>
|
|
|
|
|
|
<p class="mt-1 text-xs text-muted-foreground">
|
|
|
|
|
|
{{ walletCreditSummaryText }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Switch v-model="form.wallet_credit_enabled" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="form.wallet_credit_enabled"
|
|
|
|
|
|
class="grid grid-cols-1 gap-3 md:grid-cols-2"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="space-y-1.5">
|
|
|
|
|
|
<Label>发放金额 (USD)</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
v-model.number="form.wallet_credit_amount_usd"
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min="0.01"
|
|
|
|
|
|
step="0.01"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="space-y-1.5">
|
|
|
|
|
|
<Label>余额类型</Label>
|
|
|
|
|
|
<Select v-model="form.wallet_credit_balance_bucket">
|
|
|
|
|
|
<SelectTrigger>
|
|
|
|
|
|
<SelectValue />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value="recharge">
|
|
|
|
|
|
充值余额
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
<SelectItem value="gift">
|
|
|
|
|
|
赠款余额
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p class="rounded-xl border border-border/50 bg-card/60 px-3 py-2 text-xs leading-5 text-muted-foreground md:col-span-2">
|
|
|
|
|
|
{{ walletCreditDetailText }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="showDailyQuotaConfig"
|
|
|
|
|
|
class="space-y-3 rounded-2xl border border-border/60 bg-muted/20 p-4"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="flex items-center justify-between gap-3">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label class="text-sm font-medium">每日额度</Label>
|
|
|
|
|
|
<p class="mt-1 text-xs text-muted-foreground">
|
|
|
|
|
|
{{ dailyQuotaSummaryText }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Switch v-model="form.daily_quota_enabled" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="form.daily_quota_enabled"
|
|
|
|
|
|
class="grid grid-cols-1 gap-3 md:grid-cols-2"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="space-y-1.5">
|
|
|
|
|
|
<Label>每日额度 (USD)</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
v-model.number="form.daily_quota_usd"
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min="0.01"
|
|
|
|
|
|
step="0.01"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="space-y-1.5">
|
|
|
|
|
|
<Label>重置时区</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
v-model="form.reset_timezone"
|
|
|
|
|
|
placeholder="Asia/Shanghai"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="flex items-center justify-between rounded-xl border border-border/60 bg-card/50 p-3">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label>允许超额扣钱包</Label>
|
|
|
|
|
|
<p class="mt-1 text-xs text-muted-foreground">
|
|
|
|
|
|
额度不足时继续使用钱包余额
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Switch v-model="form.allow_wallet_overage" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="flex items-center justify-between rounded-xl border border-border/60 bg-card/50 p-3 opacity-70">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label>额度结转</Label>
|
|
|
|
|
|
<p class="mt-1 text-xs text-muted-foreground">
|
|
|
|
|
|
当前后端固定不支持结转
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Switch
|
|
|
|
|
|
v-model="form.carry_over"
|
|
|
|
|
|
disabled
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p class="rounded-xl border border-border/50 bg-card/60 px-3 py-2 text-xs leading-5 text-muted-foreground md:col-span-2">
|
|
|
|
|
|
{{ dailyQuotaDetailText }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="showMembershipGroupConfig"
|
|
|
|
|
|
class="space-y-3 rounded-2xl border border-border/60 bg-muted/20 p-4"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="flex items-center justify-between gap-3">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Label class="text-sm font-medium">会员分组</Label>
|
|
|
|
|
|
<p class="mt-1 text-xs text-muted-foreground">
|
|
|
|
|
|
{{ membershipSummaryText }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Switch v-model="form.membership_group_enabled" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="form.membership_group_enabled"
|
|
|
|
|
|
class="space-y-3"
|
|
|
|
|
|
>
|
|
|
|
|
|
<p class="rounded-xl border border-border/50 bg-card/60 px-3 py-2 text-xs leading-5 text-muted-foreground">
|
|
|
|
|
|
{{ membershipDetailText }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<MultiSelect
|
|
|
|
|
|
v-model="form.grant_user_groups"
|
|
|
|
|
|
:options="userGroupOptions"
|
|
|
|
|
|
placeholder="选择要授予的用户分组"
|
|
|
|
|
|
empty-text="暂无用户分组"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div class="grid grid-cols-1 gap-2 md:grid-cols-[1fr_auto]">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
v-model="manualGroupId"
|
|
|
|
|
|
placeholder="手动输入分组 ID"
|
|
|
|
|
|
@keyup.enter="addManualGroup"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
@click="addManualGroup"
|
|
|
|
|
|
>
|
|
|
|
|
|
添加
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="flex h-14 items-center justify-end gap-3 border-t border-border bg-muted/10 px-6">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
class="h-9"
|
|
|
|
|
|
:disabled="saving"
|
|
|
|
|
|
@click="dialogOpen = false"
|
|
|
|
|
|
>
|
|
|
|
|
|
取消
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="default"
|
|
|
|
|
|
class="h-9"
|
|
|
|
|
|
:disabled="saving || isSaveDisabled"
|
|
|
|
|
|
@click="savePlan"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ saving ? '保存中...' : '保存套餐' }}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
</PageContainer>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { computed, onMounted, reactive, ref } from 'vue'
|
|
|
|
|
|
import { CircleHelp, MoreHorizontal, Plus, Trash2 } from 'lucide-vue-next'
|
|
|
|
|
|
import {
|
|
|
|
|
|
adminBillingPlansApi,
|
|
|
|
|
|
type BillingDurationUnit,
|
|
|
|
|
|
type BillingEntitlement,
|
|
|
|
|
|
type BillingPlan,
|
|
|
|
|
|
type BillingPurchaseLimitScope,
|
|
|
|
|
|
type BillingPlanWriteRequest,
|
|
|
|
|
|
type DailyQuotaEntitlement,
|
|
|
|
|
|
type MembershipGroupEntitlement,
|
|
|
|
|
|
type WalletCreditBucket,
|
|
|
|
|
|
type WalletCreditEntitlement,
|
|
|
|
|
|
} from '@/api/billing'
|
|
|
|
|
|
import { usersApi, type UserGroup } from '@/api/users'
|
|
|
|
|
|
import {
|
|
|
|
|
|
Badge,
|
|
|
|
|
|
Button,
|
|
|
|
|
|
Dialog,
|
|
|
|
|
|
DropdownMenu,
|
|
|
|
|
|
DropdownMenuContent,
|
|
|
|
|
|
DropdownMenuItem,
|
|
|
|
|
|
DropdownMenuTrigger,
|
|
|
|
|
|
Input,
|
|
|
|
|
|
Label,
|
|
|
|
|
|
Select,
|
|
|
|
|
|
SelectContent,
|
|
|
|
|
|
SelectItem,
|
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
|
SelectValue,
|
|
|
|
|
|
Switch,
|
|
|
|
|
|
Table,
|
|
|
|
|
|
TableBody,
|
|
|
|
|
|
TableCell,
|
|
|
|
|
|
TableHead,
|
|
|
|
|
|
TableHeader,
|
|
|
|
|
|
TableRow,
|
|
|
|
|
|
Textarea,
|
|
|
|
|
|
Tooltip,
|
|
|
|
|
|
TooltipContent,
|
|
|
|
|
|
TooltipProvider,
|
|
|
|
|
|
TooltipTrigger,
|
|
|
|
|
|
} from '@/components/ui'
|
|
|
|
|
|
import { EmptyState, LoadingState, MultiSelect } from '@/components/common'
|
|
|
|
|
|
import { CardSection, PageContainer, PageHeader } from '@/components/layout'
|
|
|
|
|
|
import { useToast } from '@/composables/useToast'
|
2026-06-30 14:14:12 +08:00
|
|
|
|
import { useI18n } from '@/i18n'
|
2026-05-13 01:18:38 +08:00
|
|
|
|
import { parseApiError } from '@/utils/errorParser'
|
|
|
|
|
|
import { log } from '@/utils/logger'
|
|
|
|
|
|
|
|
|
|
|
|
type TemplateKey = 'daily' | 'membership' | 'mixed'
|
|
|
|
|
|
type PlanMode = 'empty' | 'wallet' | 'daily' | 'membership' | 'mixed'
|
|
|
|
|
|
|
|
|
|
|
|
interface PlanModeGuide {
|
|
|
|
|
|
badge: string
|
|
|
|
|
|
title: string
|
|
|
|
|
|
description: string
|
|
|
|
|
|
notes: string[]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface PlanFormState {
|
|
|
|
|
|
title: string
|
|
|
|
|
|
description: string
|
|
|
|
|
|
price_amount: number
|
|
|
|
|
|
price_currency: string
|
|
|
|
|
|
duration_unit: BillingDurationUnit
|
|
|
|
|
|
duration_value: number
|
|
|
|
|
|
enabled: boolean
|
|
|
|
|
|
sort_order: number
|
|
|
|
|
|
max_active_per_user: number
|
|
|
|
|
|
purchase_limit_scope: BillingPurchaseLimitScope
|
|
|
|
|
|
wallet_credit_enabled: boolean
|
|
|
|
|
|
wallet_credit_amount_usd: number
|
|
|
|
|
|
wallet_credit_balance_bucket: WalletCreditBucket
|
|
|
|
|
|
daily_quota_enabled: boolean
|
|
|
|
|
|
daily_quota_usd: number
|
|
|
|
|
|
reset_timezone: string
|
|
|
|
|
|
carry_over: boolean
|
|
|
|
|
|
allow_wallet_overage: boolean
|
|
|
|
|
|
membership_group_enabled: boolean
|
|
|
|
|
|
grant_user_groups: string[]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const { success, error: showError } = useToast()
|
2026-06-30 14:14:12 +08:00
|
|
|
|
const { legacyT } = useI18n()
|
2026-05-13 01:18:38 +08:00
|
|
|
|
|
|
|
|
|
|
const loading = ref(true)
|
|
|
|
|
|
const saving = ref(false)
|
|
|
|
|
|
const deletingPlanId = ref<string | null>(null)
|
|
|
|
|
|
const dialogOpen = ref(false)
|
|
|
|
|
|
const plans = ref<BillingPlan[]>([])
|
|
|
|
|
|
const editingPlan = ref<BillingPlan | null>(null)
|
|
|
|
|
|
const userGroups = ref<UserGroup[]>([])
|
|
|
|
|
|
const manualGroupId = ref('')
|
|
|
|
|
|
|
|
|
|
|
|
const form = reactive<PlanFormState>(buildDefaultForm())
|
|
|
|
|
|
|
|
|
|
|
|
const userGroupOptions = computed(() =>
|
|
|
|
|
|
userGroups.value.map((group) => ({
|
|
|
|
|
|
value: group.id,
|
|
|
|
|
|
label: group.name,
|
|
|
|
|
|
}))
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const priceCurrencyOptions = computed(() => {
|
|
|
|
|
|
const normalized = form.price_currency.trim().toUpperCase()
|
|
|
|
|
|
const options = ['CNY', 'USD']
|
|
|
|
|
|
return normalized && !options.includes(normalized) ? [...options, normalized] : options
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const hasValidPriceAmount = computed(() => {
|
|
|
|
|
|
const value = Number(form.price_amount)
|
|
|
|
|
|
if (!Number.isFinite(value) || value <= 0) return false
|
|
|
|
|
|
return /^\d+(\.\d{1,2})?$/.test(String(form.price_amount))
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const hasValidDuration = computed(() =>
|
|
|
|
|
|
Number.isInteger(Number(form.duration_value)) && Number(form.duration_value) > 0
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const hasValidDurationUnit = computed(() =>
|
|
|
|
|
|
['day', 'month', 'year'].includes(form.duration_unit)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const hasValidActiveLimit = computed(() =>
|
|
|
|
|
|
Number.isInteger(Number(form.max_active_per_user)) && Number(form.max_active_per_user) > 0
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const hasValidPurchaseLimitScope = computed(() =>
|
|
|
|
|
|
['active_period', 'lifetime', 'unlimited'].includes(form.purchase_limit_scope)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const hasSelectedPackageEntitlement = computed(() =>
|
|
|
|
|
|
form.daily_quota_enabled || form.membership_group_enabled
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const showPurchaseLimitPeriod = computed(() =>
|
|
|
|
|
|
form.purchase_limit_scope === 'active_period'
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const showPurchaseLimitCount = computed(() =>
|
|
|
|
|
|
form.purchase_limit_scope !== 'unlimited'
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const purchaseLimitFieldSpanClass = computed(() =>
|
|
|
|
|
|
showPurchaseLimitPeriod.value ? 'xl:col-span-3' : showPurchaseLimitCount.value ? 'xl:col-span-4' : 'xl:col-span-6'
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const isSaveDisabled = computed(() =>
|
|
|
|
|
|
!form.title.trim()
|
|
|
|
|
|
|| !form.price_currency.trim()
|
|
|
|
|
|
|| !hasValidPriceAmount.value
|
|
|
|
|
|
|| (showPurchaseLimitPeriod.value && !hasValidDuration.value)
|
|
|
|
|
|
|| (showPurchaseLimitPeriod.value && !hasValidDurationUnit.value)
|
|
|
|
|
|
|| (showPurchaseLimitCount.value && !hasValidActiveLimit.value)
|
|
|
|
|
|
|| !hasValidPurchaseLimitScope.value
|
|
|
|
|
|
|| !hasSelectedPackageEntitlement.value
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const planMode = computed<PlanMode>(() => {
|
|
|
|
|
|
const enabledCount = [
|
|
|
|
|
|
form.wallet_credit_enabled,
|
|
|
|
|
|
form.daily_quota_enabled,
|
|
|
|
|
|
form.membership_group_enabled,
|
|
|
|
|
|
].filter(Boolean).length
|
|
|
|
|
|
|
|
|
|
|
|
if (enabledCount === 0) return 'empty'
|
|
|
|
|
|
if (enabledCount > 1) return 'mixed'
|
|
|
|
|
|
if (form.wallet_credit_enabled) return 'wallet'
|
|
|
|
|
|
if (form.daily_quota_enabled) return 'daily'
|
|
|
|
|
|
return 'membership'
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const planModeGuide = computed<PlanModeGuide>(() => {
|
|
|
|
|
|
switch (planMode.value) {
|
|
|
|
|
|
case 'wallet':
|
|
|
|
|
|
return {
|
|
|
|
|
|
badge: '旧余额权益',
|
|
|
|
|
|
title: '旧余额套餐',
|
|
|
|
|
|
description: '纯余额套餐已由钱包充值功能承接。建议停用该套餐,或补充每日额度/会员分组后作为混合套餐。',
|
|
|
|
|
|
notes: [
|
|
|
|
|
|
'新建套餐不再提供余额包模板',
|
|
|
|
|
|
'钱包入账请使用充值功能',
|
|
|
|
|
|
'附赠余额仍可放在混合套餐中',
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
case 'daily':
|
|
|
|
|
|
return {
|
|
|
|
|
|
badge: '周期额度',
|
|
|
|
|
|
title: '每日额度套餐',
|
|
|
|
|
|
description: '适合月卡、季卡、年卡。周期内每天给用户独立 USD 额度,到期后不再生效。',
|
|
|
|
|
|
notes: [
|
|
|
|
|
|
'默认每天按重置时区刷新',
|
|
|
|
|
|
'默认用完后拒绝继续消费',
|
|
|
|
|
|
'同一用户只保留一个有效每日额度套餐',
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
case 'membership':
|
|
|
|
|
|
return {
|
|
|
|
|
|
badge: '会员权限',
|
|
|
|
|
|
title: '会员权益包',
|
|
|
|
|
|
description: '适合 Pro、Plus、团队会员。购买后动态合并用户分组权限,到期自然失效。',
|
|
|
|
|
|
notes: [
|
|
|
|
|
|
'不会永久修改用户基础分组',
|
|
|
|
|
|
'同一用户只保留一个有效会员权益包',
|
|
|
|
|
|
'适合解锁模型组或高级功能',
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
case 'mixed':
|
|
|
|
|
|
return {
|
|
|
|
|
|
badge: '混合套餐',
|
|
|
|
|
|
title: '组合权益套餐',
|
|
|
|
|
|
description: '适合同时包含每日额度和会员权限的产品,也可以按需附赠少量钱包余额。',
|
|
|
|
|
|
notes: [
|
|
|
|
|
|
'会替换旧每日额度套餐和旧会员权益包',
|
|
|
|
|
|
'附赠余额发放后不随周期结束扣回',
|
|
|
|
|
|
'限购会同时影响整套组合权益',
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
default:
|
|
|
|
|
|
return {
|
|
|
|
|
|
badge: '待配置',
|
|
|
|
|
|
title: '选择一种套餐模板',
|
|
|
|
|
|
description: '先选择每日额度、会员分组或混合套餐,再配置价格、购买限制和权益配置。',
|
|
|
|
|
|
notes: [
|
|
|
|
|
|
'每日额度和会员权益是周期权益',
|
|
|
|
|
|
'钱包充值已从套餐中拆出',
|
|
|
|
|
|
'混合套餐可以附赠余额',
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const durationFieldLabel = computed(() => '周期窗口')
|
|
|
|
|
|
|
|
|
|
|
|
const durationTooltipText = computed(() => {
|
|
|
|
|
|
if (planMode.value === 'wallet') {
|
|
|
|
|
|
return '旧余额套餐仅按这个周期统计购买限制;建议改用钱包充值功能。'
|
|
|
|
|
|
}
|
|
|
|
|
|
return '购买后周期权益生效这么久;同一用户在这个窗口内最多持有下方份数,窗口结束后释放名额。'
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const activeLimitFieldLabel = computed(() =>
|
|
|
|
|
|
form.purchase_limit_scope === 'lifetime' ? '每人最多购买次数' : '每人最多同时生效'
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const activeLimitTooltipText = computed(() =>
|
|
|
|
|
|
form.purchase_limit_scope === 'lifetime'
|
|
|
|
|
|
? '按同一用户历史成功购买次数累计,达到该值后不能再次购买,适合首购特惠包。'
|
|
|
|
|
|
: '只统计仍在周期内的已生效权益,过期后释放名额,用于防止周期权益无限叠加。'
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const purchaseLimitSummaryText = computed(() => {
|
|
|
|
|
|
if (form.purchase_limit_scope === 'unlimited') {
|
|
|
|
|
|
return '不检查同一用户的重复购买次数;每次支付成功都会按下方权益配置发放。'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (form.purchase_limit_scope === 'lifetime') {
|
|
|
|
|
|
return `同一用户历史成功购买本套餐达到 ${form.max_active_per_user || 1} 次后不能再买;适合首购特惠或一次性礼包。`
|
|
|
|
|
|
}
|
|
|
|
|
|
return `同一用户在 ${form.duration_value || 1}${durationUnitLabel(form.duration_unit)} 周期内最多同时生效 ${form.max_active_per_user || 1} 份;周期结束后可再次购买。`
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const showWalletCreditConfig = computed(() =>
|
|
|
|
|
|
planMode.value === 'mixed' || form.wallet_credit_enabled
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const showDailyQuotaConfig = computed(() =>
|
|
|
|
|
|
planMode.value === 'mixed' || form.daily_quota_enabled
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const showMembershipGroupConfig = computed(() =>
|
|
|
|
|
|
planMode.value === 'mixed' || form.membership_group_enabled
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const walletCreditSummaryText = computed(() =>
|
|
|
|
|
|
planMode.value === 'mixed'
|
|
|
|
|
|
? '作为套餐附赠余额一次性发放'
|
|
|
|
|
|
: '旧余额套餐会一次性发放到充值余额或赠款余额'
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const walletCreditDetailText = computed(() => {
|
|
|
|
|
|
const bucket = form.wallet_credit_balance_bucket === 'recharge' ? '充值余额' : '赠款余额'
|
|
|
|
|
|
return `当前会发放到${bucket}。附赠余额发放后进入钱包,不会因为套餐周期结束而自动扣回。`
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const dailyQuotaSummaryText = computed(() =>
|
|
|
|
|
|
planMode.value === 'mixed'
|
|
|
|
|
|
? '组合套餐内的周期性每日 USD 消费用量'
|
|
|
|
|
|
: '每天独立 USD 消费用量,默认不结转'
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const dailyQuotaDetailText = computed(() =>
|
|
|
|
|
|
form.allow_wallet_overage
|
|
|
|
|
|
? '每日额度不足时会继续使用钱包余额,适合希望用户不中断请求的套餐。'
|
|
|
|
|
|
: '每日额度不足时不再继续扣钱包,适合严格封顶的月卡或体验卡。'
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const membershipSummaryText = computed(() =>
|
|
|
|
|
|
planMode.value === 'mixed'
|
|
|
|
|
|
? '组合套餐内的动态会员权限'
|
|
|
|
|
|
: '购买后动态合并分组权限,到期自动失效'
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const membershipDetailText = computed(() =>
|
|
|
|
|
|
'这里授予的是动态分组权限,不会永久改写用户基础分组;权益到期后权限解析会自动移除。'
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
void Promise.all([loadPlans(), loadUserGroups()]).finally(() => {
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
function buildDefaultForm(): PlanFormState {
|
|
|
|
|
|
return {
|
|
|
|
|
|
title: '',
|
|
|
|
|
|
description: '',
|
|
|
|
|
|
price_amount: 100,
|
|
|
|
|
|
price_currency: 'CNY',
|
|
|
|
|
|
duration_unit: 'month',
|
|
|
|
|
|
duration_value: 1,
|
|
|
|
|
|
enabled: true,
|
|
|
|
|
|
sort_order: 0,
|
|
|
|
|
|
max_active_per_user: 1,
|
|
|
|
|
|
purchase_limit_scope: 'active_period',
|
|
|
|
|
|
wallet_credit_enabled: false,
|
|
|
|
|
|
wallet_credit_amount_usd: 10,
|
|
|
|
|
|
wallet_credit_balance_bucket: 'recharge',
|
|
|
|
|
|
daily_quota_enabled: false,
|
|
|
|
|
|
daily_quota_usd: 50,
|
|
|
|
|
|
reset_timezone: 'Asia/Shanghai',
|
|
|
|
|
|
carry_over: false,
|
|
|
|
|
|
allow_wallet_overage: false,
|
|
|
|
|
|
membership_group_enabled: false,
|
|
|
|
|
|
grant_user_groups: [],
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function assignForm(next: PlanFormState) {
|
|
|
|
|
|
Object.assign(form, next)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadPlans() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await adminBillingPlansApi.list()
|
|
|
|
|
|
plans.value = [...response.items].sort((left, right) =>
|
|
|
|
|
|
left.sort_order === right.sort_order
|
|
|
|
|
|
? left.price_amount - right.price_amount
|
|
|
|
|
|
: left.sort_order - right.sort_order
|
|
|
|
|
|
)
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
log.error('加载套餐失败:', err)
|
|
|
|
|
|
showError(parseApiError(err, '加载套餐失败'))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadUserGroups() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await usersApi.listUserGroups()
|
|
|
|
|
|
userGroups.value = response.items
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
log.error('加载用户分组失败:', err)
|
|
|
|
|
|
showError(parseApiError(err, '加载用户分组失败'))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openCreateDialog() {
|
|
|
|
|
|
editingPlan.value = null
|
|
|
|
|
|
assignForm(buildDefaultForm())
|
|
|
|
|
|
manualGroupId.value = ''
|
|
|
|
|
|
dialogOpen.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openEditDialog(plan: BillingPlan) {
|
|
|
|
|
|
editingPlan.value = plan
|
|
|
|
|
|
assignForm(formFromPlan(plan))
|
|
|
|
|
|
manualGroupId.value = ''
|
|
|
|
|
|
dialogOpen.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formFromPlan(plan: BillingPlan): PlanFormState {
|
|
|
|
|
|
const next = buildDefaultForm()
|
|
|
|
|
|
next.title = plan.title
|
|
|
|
|
|
next.description = plan.description || ''
|
|
|
|
|
|
next.price_amount = plan.price_amount
|
|
|
|
|
|
next.price_currency = plan.price_currency.toUpperCase()
|
|
|
|
|
|
next.duration_unit = plan.duration_unit
|
|
|
|
|
|
next.duration_value = plan.duration_value
|
|
|
|
|
|
next.enabled = plan.enabled
|
|
|
|
|
|
next.sort_order = plan.sort_order
|
|
|
|
|
|
next.max_active_per_user = plan.max_active_per_user
|
|
|
|
|
|
next.purchase_limit_scope = plan.purchase_limit_scope || 'active_period'
|
|
|
|
|
|
|
|
|
|
|
|
for (const entitlement of plan.entitlements || []) {
|
|
|
|
|
|
if (entitlement.type === 'wallet_credit') {
|
|
|
|
|
|
const wallet = entitlement as WalletCreditEntitlement
|
|
|
|
|
|
next.wallet_credit_enabled = true
|
|
|
|
|
|
next.wallet_credit_amount_usd = Number(wallet.amount_usd || next.wallet_credit_amount_usd)
|
|
|
|
|
|
next.wallet_credit_balance_bucket = wallet.balance_bucket || 'recharge'
|
|
|
|
|
|
} else if (entitlement.type === 'daily_quota') {
|
|
|
|
|
|
const quota = entitlement as DailyQuotaEntitlement
|
|
|
|
|
|
next.daily_quota_enabled = true
|
|
|
|
|
|
next.daily_quota_usd = Number(quota.daily_quota_usd || next.daily_quota_usd)
|
|
|
|
|
|
next.reset_timezone = quota.reset_timezone || 'Asia/Shanghai'
|
|
|
|
|
|
next.carry_over = Boolean(quota.carry_over)
|
|
|
|
|
|
next.allow_wallet_overage = Boolean(quota.allow_wallet_overage)
|
|
|
|
|
|
} else if (entitlement.type === 'membership_group') {
|
|
|
|
|
|
const membership = entitlement as MembershipGroupEntitlement
|
|
|
|
|
|
next.membership_group_enabled = true
|
|
|
|
|
|
next.grant_user_groups = Array.isArray(membership.grant_user_groups)
|
|
|
|
|
|
? [...membership.grant_user_groups]
|
|
|
|
|
|
: []
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return next
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function applyTemplate(template: TemplateKey) {
|
|
|
|
|
|
const next = buildDefaultForm()
|
|
|
|
|
|
if (template === 'daily') {
|
|
|
|
|
|
next.title = '100 RMB 月卡'
|
|
|
|
|
|
next.description = '每日 50 USD 独立额度,周期 1 个月'
|
|
|
|
|
|
next.daily_quota_enabled = true
|
|
|
|
|
|
next.daily_quota_usd = 50
|
|
|
|
|
|
next.max_active_per_user = 1
|
|
|
|
|
|
} else if (template === 'membership') {
|
|
|
|
|
|
next.title = 'Pro 月卡'
|
|
|
|
|
|
next.description = '动态授予 Pro 用户分组 1 个月'
|
|
|
|
|
|
next.membership_group_enabled = true
|
|
|
|
|
|
next.max_active_per_user = 1
|
|
|
|
|
|
} else {
|
|
|
|
|
|
next.title = 'Pro 混合月卡'
|
|
|
|
|
|
next.description = '每日额度和会员分组组合'
|
|
|
|
|
|
next.daily_quota_enabled = true
|
|
|
|
|
|
next.daily_quota_usd = 50
|
|
|
|
|
|
next.membership_group_enabled = true
|
|
|
|
|
|
next.max_active_per_user = 1
|
|
|
|
|
|
}
|
|
|
|
|
|
assignForm(next)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildEntitlements(): BillingEntitlement[] {
|
|
|
|
|
|
const entitlements: BillingEntitlement[] = []
|
|
|
|
|
|
if (form.wallet_credit_enabled) {
|
|
|
|
|
|
entitlements.push({
|
|
|
|
|
|
type: 'wallet_credit',
|
|
|
|
|
|
amount_usd: Number(form.wallet_credit_amount_usd),
|
|
|
|
|
|
balance_bucket: form.wallet_credit_balance_bucket,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
if (form.daily_quota_enabled) {
|
|
|
|
|
|
entitlements.push({
|
|
|
|
|
|
type: 'daily_quota',
|
|
|
|
|
|
daily_quota_usd: Number(form.daily_quota_usd),
|
|
|
|
|
|
reset_timezone: form.reset_timezone.trim() || 'Asia/Shanghai',
|
|
|
|
|
|
carry_over: false,
|
|
|
|
|
|
allow_wallet_overage: Boolean(form.allow_wallet_overage),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
if (form.membership_group_enabled) {
|
|
|
|
|
|
entitlements.push({
|
|
|
|
|
|
type: 'membership_group',
|
|
|
|
|
|
grant_user_groups: form.grant_user_groups.map((value) => value.trim()).filter(Boolean),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
return entitlements
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizePriceAmount() {
|
|
|
|
|
|
const value = Number(form.price_amount)
|
|
|
|
|
|
if (!Number.isFinite(value) || value <= 0) return
|
|
|
|
|
|
form.price_amount = Number(value.toFixed(2))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeDurationValue() {
|
|
|
|
|
|
const value = Number(form.duration_value)
|
|
|
|
|
|
if (!Number.isFinite(value) || value <= 0) return
|
|
|
|
|
|
form.duration_value = Math.floor(value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeActiveLimit() {
|
|
|
|
|
|
const value = Number(form.max_active_per_user)
|
|
|
|
|
|
if (!Number.isFinite(value) || value <= 0) return
|
|
|
|
|
|
form.max_active_per_user = Math.floor(value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function validatePlan(entitlements: BillingEntitlement[]): string | null {
|
|
|
|
|
|
if (!form.title.trim()) return '请输入套餐名称'
|
|
|
|
|
|
if (!Number.isFinite(Number(form.price_amount)) || Number(form.price_amount) <= 0) return '价格必须大于 0'
|
|
|
|
|
|
if (!hasValidPriceAmount.value) return '价格最多支持两位小数'
|
|
|
|
|
|
if (!form.price_currency.trim()) return '请输入价格币种'
|
|
|
|
|
|
if (!hasValidPurchaseLimitScope.value) return '重复购买限制必须是按周期限制、永久限制或不限购'
|
|
|
|
|
|
if (showPurchaseLimitPeriod.value && !hasValidDurationUnit.value) return '周期窗口单位必须是日/月/年'
|
|
|
|
|
|
if (showPurchaseLimitPeriod.value && !hasValidDuration.value) return '周期窗口必须是正整数'
|
|
|
|
|
|
if (showPurchaseLimitCount.value && !hasValidActiveLimit.value) {
|
|
|
|
|
|
return `${activeLimitFieldLabel.value}必须是正整数`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (entitlements.length === 0) return '至少启用一种权益'
|
|
|
|
|
|
if (!hasPackageEntitlement(entitlements)) return '套餐至少需要包含每日额度或会员分组;钱包充值请使用充值功能'
|
|
|
|
|
|
if (form.wallet_credit_enabled && Number(form.wallet_credit_amount_usd) <= 0) return '附赠余额金额必须大于 0'
|
|
|
|
|
|
if (form.daily_quota_enabled && Number(form.daily_quota_usd) <= 0) return '每日额度必须大于 0'
|
|
|
|
|
|
if (form.membership_group_enabled && form.grant_user_groups.length === 0) return '会员分组权益至少选择一个分组'
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildPlanPayload(): BillingPlanWriteRequest | null {
|
|
|
|
|
|
const entitlements = buildEntitlements()
|
|
|
|
|
|
const validationError = validatePlan(entitlements)
|
|
|
|
|
|
if (validationError) {
|
|
|
|
|
|
showError(validationError)
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
title: form.title.trim(),
|
|
|
|
|
|
description: form.description.trim() || null,
|
|
|
|
|
|
price_amount: Number(Number(form.price_amount).toFixed(2)),
|
|
|
|
|
|
price_currency: form.price_currency.trim().toUpperCase(),
|
|
|
|
|
|
duration_unit: hasValidDurationUnit.value ? form.duration_unit : 'month',
|
|
|
|
|
|
duration_value: hasValidDuration.value ? Number(form.duration_value) : 1,
|
|
|
|
|
|
enabled: form.enabled,
|
|
|
|
|
|
sort_order: Number(form.sort_order),
|
|
|
|
|
|
max_active_per_user: showPurchaseLimitCount.value ? Number(form.max_active_per_user) : 1,
|
|
|
|
|
|
purchase_limit_scope: form.purchase_limit_scope,
|
|
|
|
|
|
entitlements,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function savePlan() {
|
|
|
|
|
|
const payload = buildPlanPayload()
|
|
|
|
|
|
if (!payload) return
|
|
|
|
|
|
|
|
|
|
|
|
saving.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (editingPlan.value) {
|
|
|
|
|
|
await adminBillingPlansApi.update(editingPlan.value.id, payload)
|
|
|
|
|
|
success('套餐已更新')
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await adminBillingPlansApi.create(payload)
|
|
|
|
|
|
success('套餐已创建')
|
|
|
|
|
|
}
|
|
|
|
|
|
dialogOpen.value = false
|
|
|
|
|
|
await loadPlans()
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
log.error('保存套餐失败:', err)
|
|
|
|
|
|
showError(parseApiError(err, '保存套餐失败'))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
saving.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function togglePlanStatus(plan: BillingPlan) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await adminBillingPlansApi.setStatus(plan.id, !plan.enabled)
|
|
|
|
|
|
success(plan.enabled ? '套餐已停用' : '套餐已启用')
|
|
|
|
|
|
await loadPlans()
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
log.error('更新套餐状态失败:', err)
|
|
|
|
|
|
showError(parseApiError(err, '更新套餐状态失败'))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function deletePlan(plan: BillingPlan) {
|
|
|
|
|
|
if (deletingPlanId.value) return
|
|
|
|
|
|
const confirmed = window.confirm(
|
2026-06-30 14:14:12 +08:00
|
|
|
|
legacyT(`确定删除套餐「${plan.title}」吗?\n\n已有订单或权益的套餐不能删除,请改为停用。删除后无法恢复。`)
|
2026-05-13 01:18:38 +08:00
|
|
|
|
)
|
|
|
|
|
|
if (!confirmed) return
|
|
|
|
|
|
|
|
|
|
|
|
deletingPlanId.value = plan.id
|
|
|
|
|
|
try {
|
|
|
|
|
|
await adminBillingPlansApi.delete(plan.id)
|
|
|
|
|
|
success('套餐已删除')
|
|
|
|
|
|
await loadPlans()
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
log.error('删除套餐失败:', err)
|
|
|
|
|
|
showError(parseApiError(err, '删除套餐失败'))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
deletingPlanId.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function addManualGroup() {
|
|
|
|
|
|
const value = manualGroupId.value.trim()
|
|
|
|
|
|
if (!value) return
|
|
|
|
|
|
if (!form.grant_user_groups.includes(value)) {
|
|
|
|
|
|
form.grant_user_groups = [...form.grant_user_groups, value]
|
|
|
|
|
|
}
|
|
|
|
|
|
manualGroupId.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatPlanPriceAmount(plan: BillingPlan): string {
|
|
|
|
|
|
return Number(plan.price_amount || 0).toFixed(2)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function durationUnitLabel(unit: BillingDurationUnit): string {
|
|
|
|
|
|
const labels: Record<BillingDurationUnit, string> = {
|
|
|
|
|
|
day: '天',
|
|
|
|
|
|
month: '个月',
|
|
|
|
|
|
year: '年',
|
|
|
|
|
|
custom: '个自定义周期',
|
|
|
|
|
|
}
|
|
|
|
|
|
return labels[unit] || labels.month
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatDuration(unit: BillingDurationUnit, value: number): string {
|
|
|
|
|
|
return `${value}${durationUnitLabel(unit)}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatPlanPeriod(plan: BillingPlan): string {
|
|
|
|
|
|
if (plan.purchase_limit_scope === 'unlimited') return '不限购'
|
|
|
|
|
|
if (plan.purchase_limit_scope === 'lifetime') return '永久限购'
|
|
|
|
|
|
return formatDuration(plan.duration_unit, plan.duration_value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolvePlanModeFromEntitlements(entitlements: BillingEntitlement[] | undefined): PlanMode {
|
|
|
|
|
|
const items = entitlements || []
|
|
|
|
|
|
const hasWallet = items.some((entitlement) => entitlement.type === 'wallet_credit')
|
|
|
|
|
|
const hasDaily = items.some((entitlement) => entitlement.type === 'daily_quota')
|
|
|
|
|
|
const hasMembership = items.some((entitlement) => entitlement.type === 'membership_group')
|
|
|
|
|
|
const enabledCount = [hasWallet, hasDaily, hasMembership].filter(Boolean).length
|
|
|
|
|
|
|
|
|
|
|
|
if (enabledCount === 0) return 'empty'
|
|
|
|
|
|
if (enabledCount > 1) return 'mixed'
|
|
|
|
|
|
if (hasWallet) return 'wallet'
|
|
|
|
|
|
if (hasDaily) return 'daily'
|
|
|
|
|
|
return 'membership'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function planDurationHint(plan: BillingPlan): string {
|
|
|
|
|
|
if (plan.purchase_limit_scope === 'unlimited') return '不限制重复购买'
|
|
|
|
|
|
if (plan.purchase_limit_scope === 'lifetime') return '不按周期重置购买次数'
|
|
|
|
|
|
const mode = resolvePlanModeFromEntitlements(plan.entitlements)
|
|
|
|
|
|
if (mode === 'wallet') return '旧余额套餐,建议停用'
|
|
|
|
|
|
if (mode === 'daily') return '每日额度周期'
|
|
|
|
|
|
if (mode === 'membership') return '会员权限周期'
|
|
|
|
|
|
if (mode === 'mixed') return '组合权益周期'
|
|
|
|
|
|
return '未配置权益'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function groupName(groupId: string): string {
|
|
|
|
|
|
return userGroups.value.find((group) => group.id === groupId)?.name || groupId
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function entitlementBadges(plan: BillingPlan): string[] {
|
|
|
|
|
|
return (plan.entitlements || []).map((entitlement) => {
|
|
|
|
|
|
if (entitlement.type === 'wallet_credit') {
|
|
|
|
|
|
return `附赠余额 $${Number(entitlement.amount_usd || 0).toFixed(2)}`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (entitlement.type === 'daily_quota') {
|
|
|
|
|
|
return `每日 $${Number(entitlement.daily_quota_usd || 0).toFixed(2)}`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (entitlement.type === 'membership_group') {
|
|
|
|
|
|
const groups = entitlement.grant_user_groups.map(groupName).join(', ')
|
|
|
|
|
|
return `会员组 ${groups}`
|
|
|
|
|
|
}
|
|
|
|
|
|
return entitlement.type
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function hasPackageEntitlement(entitlements: BillingEntitlement[] | undefined): boolean {
|
|
|
|
|
|
return (entitlements || []).some((entitlement) =>
|
|
|
|
|
|
entitlement.type === 'daily_quota' || entitlement.type === 'membership_group'
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|