feat: add payment gateway and billing plans

This commit is contained in:
Entropy.Xu
2026-05-13 01:18:38 +08:00
parent 0fa97595bf
commit 10285c5eb9
97 changed files with 14797 additions and 404 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,387 @@
<template>
<PageContainer>
<PageHeader
title="支付配置"
description="配置易支付商户、回调地址、汇率和可用通道"
>
<template #actions>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
:disabled="testing || loading"
@click="testGateway"
>
<PlugZap class="mr-2 h-4 w-4" />
{{ testing ? '测试中...' : '测试配置' }}
</Button>
<Button
size="sm"
:disabled="saving || loading"
@click="saveConfig"
>
<Save class="mr-2 h-4 w-4" />
{{ saving ? '保存中...' : '保存' }}
</Button>
</div>
</template>
</PageHeader>
<div class="mt-6 space-y-6">
<div
v-if="loading"
class="py-16"
>
<LoadingState message="正在加载支付配置..." />
</div>
<template v-else>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Card class="p-5">
<div class="text-xs uppercase tracking-wider text-muted-foreground">
网关状态
</div>
<div class="mt-3 flex items-center gap-3">
<Badge :variant="form.enabled ? 'success' : 'secondary'">
{{ form.enabled ? '已启用' : '未启用' }}
</Badge>
<Switch v-model="form.enabled" />
</div>
</Card>
<Card class="p-5">
<div class="text-xs uppercase tracking-wider text-muted-foreground">
商户密钥
</div>
<div class="mt-3">
<Badge :variant="hasSecret ? 'success' : 'warning'">
{{ hasSecret ? '已保存' : '未设置' }}
</Badge>
</div>
</Card>
<Card class="p-5">
<div class="text-xs uppercase tracking-wider text-muted-foreground">
汇率
</div>
<div class="mt-2 text-2xl font-semibold tabular-nums">
1 USD = {{ Number(form.usd_exchange_rate || 0).toFixed(4) }} {{ form.pay_currency }}
</div>
</Card>
</div>
<CardSection
title="易支付商户"
description="密钥留空会保留原密钥;回调地址留空时后端会使用当前 API 访问地址,生产环境建议显式填写公网根地址"
>
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
<div class="space-y-1.5">
<Label for="epay-endpoint">易支付接口地址</Label>
<Input
id="epay-endpoint"
v-model="form.endpoint_url"
placeholder="https://pay.example.com/submit.php"
/>
</div>
<div class="space-y-1.5">
<Label for="epay-callback-base">回调站点根地址</Label>
<Input
id="epay-callback-base"
v-model="form.callback_base_url"
:placeholder="defaultCallbackBaseUrl || 'https://aether.example.com'"
/>
<p class="text-xs text-muted-foreground">
留空保存为空配置;下单时默认使用当前 API 地址或 AETHER_PUBLIC_BASE_URL。
</p>
</div>
<div class="space-y-1.5">
<Label for="epay-merchant-id">商户 ID</Label>
<Input
id="epay-merchant-id"
v-model="form.merchant_id"
placeholder="1000"
autocomplete="off"
/>
</div>
<div class="space-y-1.5">
<Label for="epay-merchant-key">
商户密钥
<span class="text-xs font-normal text-muted-foreground">
{{ hasSecret ? '(留空保持不变)' : '' }}
</span>
</Label>
<Input
id="epay-merchant-key"
v-model="form.merchant_key"
masked
:placeholder="hasSecret ? '已设置,输入新密钥后覆盖' : '请输入商户密钥'"
/>
</div>
</div>
</CardSection>
<CardSection
title="计费参数"
description="用户充值按美元金额下单,易支付按这里的币种和汇率收款"
>
<div class="grid grid-cols-1 gap-5 md:grid-cols-3">
<div class="space-y-1.5">
<Label for="epay-currency">支付币种</Label>
<Input
id="epay-currency"
v-model="form.pay_currency"
maxlength="16"
placeholder="CNY"
/>
</div>
<div class="space-y-1.5">
<Label for="epay-rate">USD 汇率</Label>
<Input
id="epay-rate"
v-model.number="form.usd_exchange_rate"
type="number"
min="0.0001"
step="0.0001"
/>
</div>
<div class="space-y-1.5">
<Label for="epay-min">最低充值金额 (USD)</Label>
<Input
id="epay-min"
v-model.number="form.min_recharge_usd"
type="number"
min="0.01"
step="0.01"
/>
</div>
</div>
</CardSection>
<CardSection
title="支付通道"
description="通道值会传给易支付 type 字段"
>
<template #actions>
<Button
variant="outline"
size="sm"
@click="addChannel"
>
<Plus class="mr-2 h-4 w-4" />
添加通道
</Button>
</template>
<div class="space-y-3">
<div
v-for="(channel, index) in form.channels"
:key="index"
class="grid grid-cols-1 gap-3 rounded-lg border border-border/60 bg-muted/20 p-3 md:grid-cols-[1fr_1fr_auto]"
>
<div class="space-y-1.5">
<Label :for="`epay-channel-${index}`">通道值</Label>
<Input
:id="`epay-channel-${index}`"
v-model="channel.channel"
placeholder="alipay"
/>
</div>
<div class="space-y-1.5">
<Label :for="`epay-channel-name-${index}`">显示名称</Label>
<Input
:id="`epay-channel-name-${index}`"
v-model="channel.display_name"
placeholder="支付宝"
/>
</div>
<div class="flex items-end">
<Button
variant="ghost"
size="icon"
title="移除通道"
:disabled="form.channels.length <= 1"
@click="removeChannel(index)"
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
</div>
</CardSection>
<p
v-if="updatedAtText"
class="text-xs text-muted-foreground"
>
最后更新:{{ updatedAtText }}
</p>
</template>
</div>
</PageContainer>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { PlugZap, Plus, Save, Trash2 } from 'lucide-vue-next'
import { epayGatewayApi, type EpayChannelConfig } from '@/api/billing'
import {
Badge,
Button,
Card,
Input,
Label,
Switch,
} from '@/components/ui'
import { LoadingState } from '@/components/common'
import { CardSection, PageContainer, PageHeader } from '@/components/layout'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger'
const { success, error: showError } = useToast()
const loading = ref(true)
const saving = ref(false)
const testing = ref(false)
const hasSecret = ref(false)
const updatedAt = ref<number | null>(null)
const form = reactive({
enabled: false,
endpoint_url: '',
callback_base_url: '',
merchant_id: '',
merchant_key: '',
pay_currency: 'CNY',
usd_exchange_rate: 7.2,
min_recharge_usd: 1,
channels: [
{ channel: 'alipay', display_name: '支付宝' },
{ channel: 'wxpay', display_name: '微信支付' },
] as EpayChannelConfig[],
})
const updatedAtText = computed(() => {
if (!updatedAt.value) return ''
return new Date(updatedAt.value * 1000).toLocaleString('zh-CN')
})
const defaultCallbackBaseUrl = computed(() => {
if (typeof window === 'undefined') return ''
return window.location.origin
})
onMounted(() => {
void loadConfig()
})
async function loadConfig() {
loading.value = true
try {
const config = await epayGatewayApi.get()
form.enabled = config.enabled
form.endpoint_url = config.endpoint_url || ''
form.callback_base_url = config.callback_base_url || ''
form.merchant_id = config.merchant_id || ''
form.merchant_key = ''
form.pay_currency = config.pay_currency || 'CNY'
form.usd_exchange_rate = Number(config.usd_exchange_rate || 7.2)
form.min_recharge_usd = Number(config.min_recharge_usd || 1)
form.channels = config.channels?.length
? config.channels.map((item) => ({ ...item }))
: [
{ channel: 'alipay', display_name: '支付宝' },
{ channel: 'wxpay', display_name: '微信支付' },
]
hasSecret.value = config.has_secret
updatedAt.value = config.updated_at ?? null
} catch (err) {
log.error('加载易支付配置失败:', err)
showError(parseApiError(err, '加载易支付配置失败'))
} finally {
loading.value = false
}
}
function normalizeChannels(): EpayChannelConfig[] {
return form.channels
.map((item) => ({
channel: item.channel.trim(),
display_name: item.display_name.trim(),
}))
.filter((item) => item.channel && item.display_name)
}
function validateForm(): string | null {
if (!form.endpoint_url.trim()) return '请输入易支付接口地址'
if (!form.merchant_id.trim()) return '请输入商户 ID'
if (!hasSecret.value && !form.merchant_key.trim()) return '首次配置需要填写商户密钥'
if (!form.pay_currency.trim()) return '请输入支付币种'
if (!Number.isFinite(Number(form.usd_exchange_rate)) || Number(form.usd_exchange_rate) <= 0) {
return 'USD 汇率必须大于 0'
}
if (!Number.isFinite(Number(form.min_recharge_usd)) || Number(form.min_recharge_usd) <= 0) {
return '最低充值金额必须大于 0'
}
if (normalizeChannels().length === 0) return '至少需要一个支付通道'
return null
}
async function saveConfig() {
const validationError = validateForm()
if (validationError) {
showError(validationError)
return
}
saving.value = true
try {
const callbackBaseUrl = form.callback_base_url.trim()
const payload = {
enabled: form.enabled,
endpoint_url: form.endpoint_url.trim(),
callback_base_url: callbackBaseUrl || null,
merchant_id: form.merchant_id.trim(),
pay_currency: form.pay_currency.trim().toUpperCase(),
usd_exchange_rate: Number(form.usd_exchange_rate),
min_recharge_usd: Number(form.min_recharge_usd),
channels: normalizeChannels(),
...(form.merchant_key.trim() ? { merchant_key: form.merchant_key.trim() } : {}),
}
const config = await epayGatewayApi.update(payload)
hasSecret.value = config.has_secret
updatedAt.value = config.updated_at ?? null
form.callback_base_url = config.callback_base_url || ''
form.merchant_key = ''
success('支付配置已保存')
} catch (err) {
log.error('保存易支付配置失败:', err)
showError(parseApiError(err, '保存易支付配置失败'))
} finally {
saving.value = false
}
}
async function testGateway() {
testing.value = true
try {
await epayGatewayApi.test()
success('易支付配置可用')
} catch (err) {
log.error('测试易支付配置失败:', err)
showError(parseApiError(err, '测试易支付配置失败'))
} finally {
testing.value = false
}
}
function addChannel() {
form.channels.push({ channel: '', display_name: '' })
}
function removeChannel(index: number) {
if (form.channels.length <= 1) return
form.channels.splice(index, 1)
}
</script>
+341 -5
View File
@@ -328,7 +328,7 @@
/>
</template>
</SortableTableHead>
<TableHead class="w-[220px] h-12 font-semibold text-center">
<TableHead class="w-[260px] h-12 font-semibold text-center">
操作
</TableHead>
</TableRow>
@@ -393,7 +393,7 @@
<TableCell class="py-4">
<div class="space-y-1.5">
<div class="flex items-center gap-1 text-[11px] text-muted-foreground">
<span>余额:</span>
<span>总可用:</span>
<Badge
v-if="isUserUnlimited(user)"
variant="secondary"
@@ -409,6 +409,13 @@
{{ formatCurrencyValue(getUserWalletTotalBalance(user), '-') }}
</span>
</div>
<div
v-if="!isUserUnlimited(user) && getUserWallet(user.id)"
class="text-[11px] text-muted-foreground"
>
套餐 {{ formatCurrencyValue(getUserPackageBalance(user), '$0.00') }}
· 钱包 {{ formatCurrencyValue(getUserWalletBalance(user), '$0.00') }}
</div>
<div class="flex items-center gap-2 text-[11px] text-muted-foreground flex-wrap">
<span>
已消费:
@@ -485,6 +492,15 @@
>
<DollarSign class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="套餐"
@click="manageUserPlans(user)"
>
<PackageCheck class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -638,7 +654,7 @@
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<p class="text-[11px] text-muted-foreground">
余额:
总可用:
</p>
<Badge
v-if="isUserUnlimited(user)"
@@ -654,6 +670,13 @@
>
{{ formatCurrencyValue(getUserWalletTotalBalance(user), '-') }}
</p>
<p
v-if="!isUserUnlimited(user) && getUserWallet(user.id)"
class="text-[11px] text-muted-foreground"
>
套餐 {{ formatCurrencyValue(getUserPackageBalance(user), '$0.00') }}
· 钱包 {{ formatCurrencyValue(getUserWalletBalance(user), '$0.00') }}
</p>
</div>
<div class="text-right">
<p class="text-[11px] text-muted-foreground">
@@ -711,6 +734,15 @@
<DollarSign class="mr-1.5 h-3.5 w-3.5" />
资金
</Button>
<Button
variant="outline"
size="sm"
class="h-8 text-xs"
@click="manageUserPlans(user)"
>
<PackageCheck class="mr-1.5 h-3.5 w-3.5" />
套餐
</Button>
<Button
variant="outline"
size="sm"
@@ -799,6 +831,172 @@
@changed="handleUserGroupsChanged"
/>
<Dialog
v-model="showUserPlansDialog"
size="xl"
>
<template #header>
<div class="border-b border-border px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-kraft/10">
<PackageCheck class="h-5 w-5 text-kraft" />
</div>
<div class="min-w-0 flex-1">
<h3 class="text-lg font-semibold leading-tight text-foreground">
用户套餐
</h3>
<p class="text-xs text-muted-foreground">
{{ selectedUser?.username || '-' }} · 查看当前套餐并手动发放
</p>
</div>
</div>
</div>
</template>
<div class="max-h-[64vh] space-y-4 overflow-y-auto">
<div class="rounded-lg border border-amber-500/20 bg-amber-500/10 px-3 py-2.5 text-xs text-amber-100/90">
后台发放会立即生效;如果新套餐包含每日额度或会员权益,用户已有的同类旧套餐会自动失效。
</div>
<section class="space-y-2.5">
<div class="flex items-center justify-between gap-3">
<h4 class="text-sm font-semibold text-foreground">
当前有效套餐
</h4>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-[11px]"
:disabled="loadingUserPlans || !selectedUser"
@click="selectedUser && loadUserPlanEntitlements(selectedUser.id)"
>
{{ loadingUserPlans ? '加载中...' : '刷新' }}
</Button>
</div>
<div
v-if="loadingUserPlans"
class="rounded-lg border border-dashed border-border/60 bg-muted/20 px-4 py-8 text-center text-sm text-muted-foreground"
>
正在加载用户套餐...
</div>
<div
v-else-if="userPlanEntitlements.length === 0"
class="rounded-lg border border-dashed border-border/60 bg-muted/20 px-4 py-8 text-center text-sm text-muted-foreground"
>
当前没有有效套餐
</div>
<div
v-else
class="space-y-2.5"
>
<div
v-for="item in userPlanEntitlements"
:key="item.id"
class="rounded-lg border border-border bg-card/80 p-3"
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-foreground">
{{ item.plan_title || item.plan?.title || item.plan_id }}
</span>
<Badge
:variant="item.active ? 'success' : 'secondary'"
class="h-5 px-1.5 py-0 text-[10px]"
>
{{ item.active ? '生效中' : item.status }}
</Badge>
</div>
<div class="mt-2 flex flex-wrap gap-1.5">
<Badge
v-for="label in entitlementLabels(item.entitlements)"
:key="label"
variant="outline"
class="h-5 px-1.5 py-0 text-[10px]"
>
{{ label }}
</Badge>
</div>
</div>
<div class="text-left text-[11px] text-muted-foreground sm:text-right">
<div>开始:{{ formatDateTime(item.starts_at) }}</div>
<div>到期:{{ formatDateTime(item.expires_at) }}</div>
</div>
</div>
</div>
</div>
</section>
<section class="space-y-3 rounded-lg border border-border bg-card/70 p-4">
<div class="space-y-1">
<h4 class="text-sm font-semibold text-foreground">
发放套餐
</h4>
<p class="text-xs text-muted-foreground">
仅发放套餐权益,不产生用户付款;同类旧套餐会按现有规则自动替换。
</p>
</div>
<Select v-model="selectedGrantPlanId">
<SelectTrigger
class="h-9 rounded-md bg-muted/50 px-3"
:disabled="loadingBillingPlans || grantableBillingPlans.length === 0"
>
<SelectValue :placeholder="loadingBillingPlans ? '加载套餐中...' : '选择要发放的套餐'" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="plan in grantableBillingPlans"
:key="plan.id"
:value="plan.id"
>
<div class="flex min-w-0 items-center gap-2">
<span class="truncate">{{ plan.title }}</span>
<span class="shrink-0 text-xs text-muted-foreground">
{{ formatPlanPrice(plan) }} · {{ formatPlanDuration(plan) }}
</span>
<span
v-if="!plan.enabled"
class="shrink-0 text-[10px] text-amber-400"
>
已下架
</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<Textarea
v-model="grantReason"
class="min-h-[60px] resize-y rounded-md bg-muted/50 text-sm"
maxlength="512"
placeholder="备注(可选,例如:人工补偿、活动赠送)"
/>
<div class="flex justify-end">
<Button
size="sm"
:disabled="grantingUserPlan || !selectedUser || !selectedGrantPlanId"
@click="grantPlanToSelectedUser"
>
{{ grantingUserPlan ? '发放中...' : '发放套餐' }}
</Button>
</div>
</section>
</div>
<template #footer>
<Button
variant="outline"
class="h-10 px-5"
@click="showUserPlansDialog = false"
>
关闭
</Button>
</template>
</Dialog>
<!-- API Keys 管理对话框 -->
<Dialog
v-model="showApiKeysDialog"
@@ -1222,9 +1420,10 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useUsersStore } from '@/stores/users'
import type { User, ApiKey, UserSession, UserBatchActionResponse, UserBatchSelectionFilters, UserGroup } from '@/api/users'
import { usersApi, type User, type ApiKey, type UserSession, type UserBatchActionResponse, type UserBatchSelectionFilters, type UserGroup, type AdminUserPlanEntitlement } from '@/api/users'
import { formatSessionMeta } from '@/types/session'
import { adminWalletApi, type AdminWallet } from '@/api/admin-wallets'
import { adminBillingPlansApi, type BillingEntitlement, type BillingPlan } from '@/api/billing'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { useClipboard } from '@/composables/useClipboard'
@@ -1239,6 +1438,7 @@ import {
Badge,
Input,
Label,
Textarea,
Select,
SelectTrigger,
SelectValue,
@@ -1274,6 +1474,7 @@ import {
LockOpen,
MonitorSmartphone,
FolderKanban,
PackageCheck,
} from 'lucide-vue-next'
// 功能组件
@@ -1300,14 +1501,22 @@ const userFormDialogRef = ref<InstanceType<typeof UserFormDialog>>()
// API Keys 对话框状态
const showApiKeysDialog = ref(false)
const showUserSessionsDialog = ref(false)
const showUserPlansDialog = ref(false)
const showNewApiKeyDialog = ref(false)
const showUserApiKeyFormDialog = ref(false)
const selectedUser = ref<User | null>(null)
const userApiKeys = ref<ApiKey[]>([])
const userSessions = ref<UserSession[]>([])
const userPlanEntitlements = ref<AdminUserPlanEntitlement[]>([])
const availableBillingPlans = ref<BillingPlan[]>([])
const selectedGrantPlanId = ref('')
const grantReason = ref('')
const newApiKey = ref('')
const creatingApiKey = ref(false)
const loadingUserSessions = ref(false)
const loadingUserPlans = ref(false)
const loadingBillingPlans = ref(false)
const grantingUserPlan = ref(false)
const sessionDialogActionLoading = ref<string | null>(null)
const apiKeyInput = ref<HTMLInputElement>()
const editingUserApiKey = ref<ApiKey | null>(null)
@@ -1423,6 +1632,10 @@ const batchSelectionFilters = computed<UserBatchSelectionFilters>(() => {
return filters
})
const grantableBillingPlans = computed(() =>
availableBillingPlans.value.filter((plan) => hasPackageEntitlement(plan.entitlements))
)
// Watch filter changes and reset to first page
watch([searchQuery, filterRole, filterStatus, filterGroup], () => {
currentPage.value = 1
@@ -1476,6 +1689,51 @@ function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString('zh-CN')
}
function formatDateTime(value?: string | null): string {
if (!value) return '-'
return new Date(value).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function formatPlanPrice(plan: BillingPlan): string {
return `${Number(plan.price_amount || 0).toFixed(2)} ${plan.price_currency || 'CNY'}`
}
function formatPlanDuration(plan: BillingPlan): string {
const labels: Record<string, string> = {
day: '天',
month: '个月',
year: '年',
custom: '天',
}
const unit = labels[plan.duration_unit] || '天'
return `${Number(plan.duration_value || 1)}${unit}`
}
function entitlementLabels(items: BillingEntitlement[] | undefined): 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 '会员权益'
}
return item.type
})
}
function hasPackageEntitlement(items: BillingEntitlement[] | undefined): boolean {
return (items || []).some((item) => item.type === 'daily_quota' || item.type === 'membership_group')
}
async function loadUserWallets(options: { cacheTtlMs?: number } = {}) {
const requestId = ++userWalletsRequestId
try {
@@ -1521,7 +1779,21 @@ function getUserWalletTotalBalance(user: User): number | null {
if (!wallet) {
return null
}
return wallet.balance
if (typeof wallet.total_available_balance === 'number' && Number.isFinite(wallet.total_available_balance)) {
return wallet.total_available_balance
}
return getUserWalletBalance(user) + getUserPackageBalance(user)
}
function getUserWalletBalance(user: User): number {
const wallet = getUserWallet(user.id)
const value = wallet?.wallet_balance ?? wallet?.balance ?? 0
return Number.isFinite(value) ? value : 0
}
function getUserPackageBalance(user: User): number {
const value = getUserWallet(user.id)?.package_balance ?? 0
return Number.isFinite(value) ? value : 0
}
function getUserWalletConsumed(user: User): number {
@@ -1674,6 +1946,70 @@ async function manageUserSessions(user: User) {
}
}
async function manageUserPlans(user: User) {
selectedUser.value = user
showUserPlansDialog.value = true
selectedGrantPlanId.value = ''
grantReason.value = ''
await Promise.all([
loadUserPlanEntitlements(user.id),
loadAvailableBillingPlans(),
])
if (!selectedGrantPlanId.value && grantableBillingPlans.value.length > 0) {
selectedGrantPlanId.value = grantableBillingPlans.value[0].id
}
}
async function loadUserPlanEntitlements(userId: string) {
loadingUserPlans.value = true
try {
const response = await usersApi.listUserPlanEntitlements(userId)
userPlanEntitlements.value = response.items
} catch (err) {
error(parseApiError(err, '加载用户套餐失败'))
userPlanEntitlements.value = []
} finally {
loadingUserPlans.value = false
}
}
async function loadAvailableBillingPlans() {
loadingBillingPlans.value = true
try {
const response = await adminBillingPlansApi.list()
availableBillingPlans.value = response.items
if (
selectedGrantPlanId.value
&& !response.items.some((plan) => plan.id === selectedGrantPlanId.value)
) {
selectedGrantPlanId.value = ''
}
} catch (err) {
error(parseApiError(err, '加载套餐列表失败'))
availableBillingPlans.value = []
} finally {
loadingBillingPlans.value = false
}
}
async function grantPlanToSelectedUser() {
if (!selectedUser.value || !selectedGrantPlanId.value) return
grantingUserPlan.value = true
try {
const response = await usersApi.grantUserPlan(selectedUser.value.id, {
plan_id: selectedGrantPlanId.value,
reason: grantReason.value.trim() || null,
})
userPlanEntitlements.value = response.items
grantReason.value = ''
success('套餐已发放')
} catch (err) {
error(parseApiError(err, '发放套餐失败'))
} finally {
grantingUserPlan.value = false
}
}
async function loadUserApiKeys(userId: string) {
try {
userApiKeys.value = await usersStore.getUserApiKeys(userId)