Files
Aether/frontend/src/views/admin/BillingPlansManagement.vue
T

1472 lines
53 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>
<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'
import { useI18n } from '@/i18n'
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()
const { legacyT } = useI18n()
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(
legacyT(`确定删除套餐「${plan.title}」吗?\n\n已有订单或权益的套餐不能删除,请改为停用。删除后无法恢复。`)
)
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>