mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
Merge remote-tracking branch 'entropy-xu/payment-billing-plans'
# Conflicts: # crates/aether-data/src/lifecycle/bootstrap/postgres.rs # crates/aether-data/src/lifecycle/migrate/tests.rs # frontend/src/views/admin/Users.vue
This commit is contained in:
1469
frontend/src/views/admin/BillingPlansManagement.vue
Normal file
1469
frontend/src/views/admin/BillingPlansManagement.vue
Normal file
File diff suppressed because it is too large
Load Diff
387
frontend/src/views/admin/PaymentGatewaySettings.vue
Normal file
387
frontend/src/views/admin/PaymentGatewaySettings.vue
Normal file
@@ -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>
|
||||
@@ -337,7 +337,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>
|
||||
@@ -402,7 +402,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"
|
||||
@@ -418,6 +418,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>
|
||||
已消费:
|
||||
@@ -498,6 +505,15 @@
|
||||
</Button>
|
||||
<Button
|
||||
v-if="authStore.canOperateAdmin"
|
||||
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"
|
||||
class="h-8 w-8"
|
||||
@@ -651,7 +667,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)"
|
||||
@@ -667,6 +683,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">
|
||||
@@ -728,6 +751,15 @@
|
||||
</Button>
|
||||
<Button
|
||||
v-if="authStore.canOperateAdmin"
|
||||
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"
|
||||
class="h-8 text-xs"
|
||||
@@ -816,6 +848,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"
|
||||
@@ -1254,9 +1452,10 @@
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
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'
|
||||
@@ -1271,6 +1470,7 @@ import {
|
||||
Badge,
|
||||
Input,
|
||||
Label,
|
||||
Textarea,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
@@ -1307,6 +1507,7 @@ import {
|
||||
LockOpen,
|
||||
MonitorSmartphone,
|
||||
FolderKanban,
|
||||
PackageCheck,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
// 功能组件
|
||||
@@ -1338,14 +1539,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)
|
||||
@@ -1464,6 +1673,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
|
||||
@@ -1527,6 +1740,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 {
|
||||
@@ -1572,7 +1830,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 {
|
||||
@@ -1728,6 +2000,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)
|
||||
|
||||
436
frontend/src/views/user/BillingPlans.vue
Normal file
436
frontend/src/views/user/BillingPlans.vue
Normal file
@@ -0,0 +1,436 @@
|
||||
<template>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="套餐中心"
|
||||
description="购买每日额度或会员权益"
|
||||
/>
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="py-16"
|
||||
>
|
||||
<LoadingState message="正在加载套餐..." />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<CardSection
|
||||
title="当前权益"
|
||||
description="只展示仍在有效期内的套餐权益"
|
||||
>
|
||||
<div
|
||||
v-if="activeEntitlements.length"
|
||||
class="grid grid-cols-1 gap-3 lg:grid-cols-2"
|
||||
>
|
||||
<div
|
||||
v-for="item in activeEntitlements"
|
||||
:key="item.id"
|
||||
class="rounded-lg border border-border/60 bg-muted/20 p-4"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-medium">
|
||||
{{ planTitle(item.plan_id) }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-muted-foreground">
|
||||
{{ formatDate(item.starts_at) }} - {{ formatDate(item.expires_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="success">
|
||||
生效中
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-1.5">
|
||||
<Badge
|
||||
v-for="label in entitlementLabels(item.entitlements)"
|
||||
:key="label"
|
||||
variant="outline"
|
||||
>
|
||||
{{ label }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
v-else
|
||||
title="暂无有效套餐"
|
||||
description="购买套餐后,有效权益会显示在这里"
|
||||
/>
|
||||
</CardSection>
|
||||
|
||||
<CardSection
|
||||
title="可购买套餐"
|
||||
description="支付成功后由回调自动发放权益"
|
||||
>
|
||||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
<Card
|
||||
v-for="plan in purchaseablePlans"
|
||||
:key="plan.id"
|
||||
class="flex flex-col p-5"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold">
|
||||
{{ plan.title }}
|
||||
</h3>
|
||||
<p class="mt-1 min-h-[32px] text-xs text-muted-foreground">
|
||||
{{ plan.description || '标准套餐' }}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
{{ formatDuration(plan.duration_unit, plan.duration_value) }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<span class="text-3xl font-semibold tabular-nums">
|
||||
{{ Number(plan.price_amount || 0).toFixed(2) }}
|
||||
</span>
|
||||
<span class="ml-1 text-sm text-muted-foreground">
|
||||
{{ plan.price_currency }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex flex-wrap gap-1.5">
|
||||
<Badge
|
||||
v-for="label in entitlementLabels(plan.entitlements)"
|
||||
:key="label"
|
||||
variant="outline"
|
||||
>
|
||||
{{ label }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="replacementNotice(plan)"
|
||||
class="mt-4 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs leading-5 text-amber-200"
|
||||
>
|
||||
{{ replacementNotice(plan) }}
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex-1" />
|
||||
|
||||
<div class="mt-5 space-y-3">
|
||||
<Select v-model="selectedChannel">
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
:placeholder="epayOptions.length ? '选择支付通道' : '暂无可用支付通道'"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="option in epayOptions"
|
||||
:key="`${option.payment_channel}-${option.display_name}`"
|
||||
:value="option.payment_channel || ''"
|
||||
>
|
||||
{{ option.display_name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
class="w-full"
|
||||
:disabled="
|
||||
checkoutLoadingPlanId === plan.id
|
||||
|| epayOptions.length === 0
|
||||
|| !selectedChannel
|
||||
"
|
||||
@click="checkoutPlan(plan)"
|
||||
>
|
||||
<CreditCard class="mr-2 h-4 w-4" />
|
||||
{{ checkoutLoadingPlanId === plan.id ? '创建订单中...' : '购买套餐' }}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div
|
||||
v-if="purchaseablePlans.length === 0"
|
||||
class="xl:col-span-3"
|
||||
>
|
||||
<EmptyState
|
||||
title="暂无可购买套餐"
|
||||
description="管理员上架套餐后会显示在这里"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<Card
|
||||
v-if="latestCheckout"
|
||||
class="p-4"
|
||||
>
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div class="text-sm font-medium">
|
||||
最新订单:<span class="font-mono">{{ latestCheckout.order.order_no }}</span>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-muted-foreground">
|
||||
应付 {{ latestCheckout.order.pay_amount ?? '-' }} {{ latestCheckout.order.pay_currency || '' }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="latestPaymentUrl"
|
||||
variant="outline"
|
||||
@click="openPaymentUrl(latestPaymentUrl)"
|
||||
>
|
||||
打开支付链接
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { CreditCard } from 'lucide-vue-next'
|
||||
import {
|
||||
billingApi,
|
||||
type BillingDurationUnit,
|
||||
type BillingEntitlement,
|
||||
type BillingCheckoutResponse,
|
||||
type BillingPlan,
|
||||
type UserPlanEntitlement,
|
||||
} from '@/api/billing'
|
||||
import { walletApi, type WalletRechargeOption } from '@/api/wallet'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui'
|
||||
import { EmptyState, 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 plans = ref<BillingPlan[]>([])
|
||||
const entitlements = ref<UserPlanEntitlement[]>([])
|
||||
const rechargeOptions = ref<WalletRechargeOption[]>([])
|
||||
const selectedChannel = ref('')
|
||||
const checkoutLoadingPlanId = ref<string | null>(null)
|
||||
const latestCheckout = ref<BillingCheckoutResponse | null>(null)
|
||||
|
||||
const epayOptions = computed(() =>
|
||||
rechargeOptions.value.filter((option) =>
|
||||
(option.payment_provider === 'epay' || option.payment_method === 'epay')
|
||||
&& Boolean(option.payment_channel)
|
||||
)
|
||||
)
|
||||
|
||||
const activeEntitlements = computed(() =>
|
||||
entitlements.value.filter((item) =>
|
||||
item.active !== false
|
||||
&& item.status === 'active'
|
||||
&& hasPackageEntitlement(item.entitlements)
|
||||
)
|
||||
)
|
||||
|
||||
const purchaseablePlans = computed(() =>
|
||||
plans.value.filter((plan) => hasPackageEntitlement(plan.entitlements))
|
||||
)
|
||||
|
||||
const latestPaymentUrl = computed(() => {
|
||||
const value = latestCheckout.value?.payment_instructions?.payment_url
|
||||
return typeof value === 'string' && value ? value : ''
|
||||
})
|
||||
|
||||
watch(epayOptions, (options) => {
|
||||
const channels = options
|
||||
.map(option => option.payment_channel || '')
|
||||
.filter(Boolean)
|
||||
if (!channels.includes(selectedChannel.value)) {
|
||||
selectedChannel.value = channels[0] || ''
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
loadPlans(),
|
||||
loadEntitlements(),
|
||||
loadRechargeOptions(),
|
||||
])
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
async function loadPlans() {
|
||||
try {
|
||||
const response = await billingApi.listPlans()
|
||||
plans.value = response.items
|
||||
} catch (err) {
|
||||
log.error('加载套餐失败:', err)
|
||||
showError(parseApiError(err, '加载套餐失败'))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEntitlements() {
|
||||
try {
|
||||
const response = await billingApi.listEntitlements()
|
||||
entitlements.value = response.items
|
||||
} catch (err) {
|
||||
log.error('加载套餐权益失败:', err)
|
||||
showError(parseApiError(err, '加载套餐权益失败'))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRechargeOptions() {
|
||||
try {
|
||||
const response = await walletApi.listRechargeOptions()
|
||||
rechargeOptions.value = response.items
|
||||
if (!selectedChannel.value && epayOptions.value.length > 0) {
|
||||
selectedChannel.value = epayOptions.value[0].payment_channel || ''
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('加载支付通道失败:', err)
|
||||
showError(parseApiError(err, '加载支付通道失败'))
|
||||
}
|
||||
}
|
||||
|
||||
async function checkoutPlan(plan: BillingPlan) {
|
||||
if (hasMatchingActivePlan(plan)) {
|
||||
const confirmed = window.confirm('购买成功后,同类旧套餐会自动失效。确定继续购买吗?')
|
||||
if (!confirmed) return
|
||||
}
|
||||
checkoutLoadingPlanId.value = plan.id
|
||||
try {
|
||||
const response = await billingApi.checkout(plan.id, {
|
||||
payment_method: 'epay',
|
||||
payment_provider: 'epay',
|
||||
payment_channel: selectedChannel.value,
|
||||
})
|
||||
latestCheckout.value = response
|
||||
success('套餐订单已创建')
|
||||
submitPaymentInstructions(response.payment_instructions)
|
||||
} catch (err) {
|
||||
log.error('创建套餐订单失败:', err)
|
||||
showError(parseApiError(err, '创建套餐订单失败'))
|
||||
} finally {
|
||||
checkoutLoadingPlanId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function openPaymentUrl(url: string) {
|
||||
submitPaymentInstructions(latestCheckout.value?.payment_instructions || { payment_url: url })
|
||||
}
|
||||
|
||||
function submitPaymentInstructions(instructions: Record<string, unknown> | null | undefined) {
|
||||
if (!instructions) return
|
||||
const paymentUrl = instructions.payment_url
|
||||
if (typeof paymentUrl !== 'string' || !paymentUrl) return
|
||||
const paymentParams = instructions.payment_params
|
||||
if (paymentParams && typeof paymentParams === 'object' && !Array.isArray(paymentParams)) {
|
||||
submitPaymentForm(paymentUrl, paymentParams as Record<string, unknown>)
|
||||
return
|
||||
}
|
||||
const opened = window.open(paymentUrl, '_blank', 'noopener,noreferrer')
|
||||
if (!opened) {
|
||||
window.location.href = paymentUrl
|
||||
}
|
||||
}
|
||||
|
||||
function submitPaymentForm(url: string, params: Record<string, unknown>) {
|
||||
const form = document.createElement('form')
|
||||
form.action = url
|
||||
form.method = 'POST'
|
||||
if (!isSafariBrowser()) {
|
||||
form.target = '_blank'
|
||||
}
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === null || value === undefined) return
|
||||
const input = document.createElement('input')
|
||||
input.type = 'hidden'
|
||||
input.name = key
|
||||
input.value = String(value)
|
||||
form.appendChild(input)
|
||||
})
|
||||
document.body.appendChild(form)
|
||||
form.submit()
|
||||
document.body.removeChild(form)
|
||||
}
|
||||
|
||||
function isSafariBrowser(): boolean {
|
||||
return navigator.userAgent.includes('Safari') && !navigator.userAgent.includes('Chrome')
|
||||
}
|
||||
|
||||
function planTitle(planId: string): string {
|
||||
return plans.value.find((plan) => plan.id === planId)?.title || planId
|
||||
}
|
||||
|
||||
function hasMatchingActivePlan(plan: BillingPlan): boolean {
|
||||
const replacesDailyQuota = hasDailyQuotaEntitlement(plan.entitlements)
|
||||
const replacesMembership = hasMembershipEntitlement(plan.entitlements)
|
||||
if (!replacesDailyQuota && !replacesMembership) return false
|
||||
return activeEntitlements.value.some((item) =>
|
||||
(replacesDailyQuota && hasDailyQuotaEntitlement(item.entitlements))
|
||||
|| (replacesMembership && hasMembershipEntitlement(item.entitlements))
|
||||
)
|
||||
}
|
||||
|
||||
function replacementNotice(plan: BillingPlan): string {
|
||||
const labels = replacementClassLabels(plan.entitlements)
|
||||
if (labels.length === 0) return ''
|
||||
if (hasMatchingActivePlan(plan)) {
|
||||
return `你已有有效${labels.join('和')},购买成功后旧同类套餐会自动失效。`
|
||||
}
|
||||
return `若已有有效${labels.join('和')},购买成功后旧同类套餐会自动失效。`
|
||||
}
|
||||
|
||||
function entitlementLabels(items: BillingEntitlement[]): string[] {
|
||||
return (items || []).map((item) => {
|
||||
if (item.type === 'wallet_credit') {
|
||||
return `附赠余额 $${Number(item.amount_usd || 0).toFixed(2)}`
|
||||
}
|
||||
if (item.type === 'daily_quota') {
|
||||
return `每日 $${Number(item.daily_quota_usd || 0).toFixed(2)}`
|
||||
}
|
||||
if (item.type === 'membership_group') {
|
||||
return `会员组 ${item.grant_user_groups.join(', ')}`
|
||||
}
|
||||
return item.type
|
||||
})
|
||||
}
|
||||
|
||||
function hasPackageEntitlement(items: BillingEntitlement[] | undefined): boolean {
|
||||
return (items || []).some((item) =>
|
||||
item.type === 'daily_quota' || item.type === 'membership_group'
|
||||
)
|
||||
}
|
||||
|
||||
function hasDailyQuotaEntitlement(items: BillingEntitlement[] | undefined): boolean {
|
||||
return (items || []).some((item) => item.type === 'daily_quota')
|
||||
}
|
||||
|
||||
function hasMembershipEntitlement(items: BillingEntitlement[] | undefined): boolean {
|
||||
return (items || []).some((item) => item.type === 'membership_group')
|
||||
}
|
||||
|
||||
function replacementClassLabels(items: BillingEntitlement[] | undefined): string[] {
|
||||
const labels: string[] = []
|
||||
if (hasDailyQuotaEntitlement(items)) labels.push('每日额度套餐')
|
||||
if (hasMembershipEntitlement(items)) labels.push('会员权益包')
|
||||
return labels
|
||||
}
|
||||
|
||||
function formatDuration(unit: BillingDurationUnit, value: number): string {
|
||||
const labels: Record<BillingDurationUnit, string> = {
|
||||
day: '天',
|
||||
month: '个月',
|
||||
year: '年',
|
||||
custom: '自定义周期',
|
||||
}
|
||||
return unit === 'custom' ? `${value} ${labels[unit]}` : `${value}${labels[unit]}`
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return new Date(value).toLocaleDateString('zh-CN')
|
||||
}
|
||||
</script>
|
||||
@@ -8,30 +8,65 @@
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
<Card class="p-5 space-y-2">
|
||||
<div class="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
可用余额
|
||||
总可用额度
|
||||
</div>
|
||||
<div class="text-3xl font-bold tabular-nums">
|
||||
{{ formatCurrency(walletBalance?.balance) }}
|
||||
{{ walletBalance?.unlimited ? '无限制' : formatCurrency(totalAvailableBalance) }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
充值余额: {{ formatCurrency(walletBalance?.wallet?.recharge_balance) }} · 赠款余额: {{ formatCurrency(walletBalance?.wallet?.gift_balance) }}
|
||||
套餐额度: {{ formatCurrency(packageBalance) }} · 钱包余额: {{ formatCurrency(walletOnlyBalance) }}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-5 space-y-3">
|
||||
<div class="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
套餐今日额度
|
||||
</div>
|
||||
<div class="text-2xl font-bold tabular-nums">
|
||||
<template v-if="hasActiveDailyQuota">
|
||||
{{ formatCurrency(packageBalance) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
未开通
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasActiveDailyQuota"
|
||||
class="space-y-1.5"
|
||||
>
|
||||
<div class="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full rounded-full bg-primary transition-all"
|
||||
:style="{ width: `${dailyQuotaRemainingPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
已用 {{ formatCurrency(dailyQuotaUsed) }} / 每日 {{ formatCurrency(dailyQuotaTotal) }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ dailyQuota?.allow_wallet_overage ? '套餐不足时继续扣钱包余额' : '套餐额度不足时会拒绝请求' }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
开通每日额度套餐后会优先消耗这里的额度。
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-5 space-y-2">
|
||||
<div class="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
累计充值 / 消费
|
||||
钱包余额
|
||||
</div>
|
||||
<div class="text-lg font-semibold tabular-nums">
|
||||
{{ formatCurrency(walletBalance?.wallet?.total_recharged) }}
|
||||
<span class="text-muted-foreground font-normal mx-1">/</span>
|
||||
{{ formatCurrency(walletBalance?.wallet?.total_consumed) }}
|
||||
<div class="text-2xl font-semibold tabular-nums">
|
||||
{{ formatCurrency(walletOnlyBalance) }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
累计退款: {{ formatCurrency(walletBalance?.wallet?.total_refunded) }} · 可退款余额: {{ formatCurrency(walletBalance?.wallet?.refundable_balance) }}
|
||||
充值余额: {{ formatCurrency(walletBalance?.wallet?.recharge_balance) }} · 赠款余额: {{ formatCurrency(walletBalance?.wallet?.gift_balance) }}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -44,6 +79,15 @@
|
||||
{{ walletStatusLabel(walletBalance?.wallet?.status) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
累计充值 / 消费:
|
||||
{{ formatCurrency(walletBalance?.wallet?.total_recharged) }}
|
||||
<span class="text-muted-foreground font-normal mx-1">/</span>
|
||||
{{ formatCurrency(walletBalance?.wallet?.total_consumed) }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
累计退款: {{ formatCurrency(walletBalance?.wallet?.total_refunded) }} · 可退款余额: {{ formatCurrency(walletBalance?.wallet?.refundable_balance) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="walletBalance?.unlimited"
|
||||
class="text-xs text-amber-600 dark:text-amber-400"
|
||||
@@ -132,25 +176,47 @@
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>支付方式</Label>
|
||||
<Select v-model="rechargeForm.payment_method">
|
||||
<Select v-model="rechargeForm.payment_option_key">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择支付方式" />
|
||||
<SelectValue
|
||||
:placeholder="rechargeOptionsWithKey.length ? '选择支付方式' : '暂无可用支付方式'"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="alipay">
|
||||
支付宝
|
||||
</SelectItem>
|
||||
<SelectItem value="wechat">
|
||||
微信支付
|
||||
<SelectItem
|
||||
v-for="option in rechargeOptionsWithKey"
|
||||
:key="option.key"
|
||||
:value="option.key"
|
||||
>
|
||||
{{ option.display_name }}
|
||||
<span
|
||||
v-if="option.pay_currency && option.usd_exchange_rate"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
· {{ option.pay_currency }}
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedRechargeOption?.usd_exchange_rate"
|
||||
class="rounded-xl border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground"
|
||||
>
|
||||
预计支付:
|
||||
<span class="font-medium text-foreground">
|
||||
{{ estimatedRechargePayAmount }}
|
||||
{{ selectedRechargeOption.pay_currency || 'CNY' }}
|
||||
</span>
|
||||
· 1 USD = {{ Number(selectedRechargeOption.usd_exchange_rate).toFixed(4) }}
|
||||
{{ selectedRechargeOption.pay_currency || 'CNY' }}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
class="w-full"
|
||||
:disabled="submittingRecharge"
|
||||
:disabled="submittingRecharge || rechargeOptionsWithKey.length === 0"
|
||||
@click="submitRecharge"
|
||||
>
|
||||
{{ submittingRecharge ? '创建订单中...' : '创建充值订单' }}
|
||||
@@ -178,6 +244,7 @@
|
||||
:href="String(latestRecharge.payment_instructions.payment_url)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@click.prevent="submitPaymentInstructions(latestRecharge.payment_instructions)"
|
||||
>
|
||||
打开支付链接
|
||||
</a>
|
||||
@@ -619,6 +686,7 @@ import {
|
||||
type RefundRequest,
|
||||
type WalletBalanceResponse,
|
||||
type WalletRedeemResponse,
|
||||
type WalletRechargeOption,
|
||||
} from '@/api/wallet'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
@@ -641,8 +709,7 @@ import {
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
// TODO(wallet): 充值和退款前台入口尚未正式启用;联调完成后改为 true 即可恢复显示。
|
||||
const ENABLE_WALLET_ACTION_FORMS = false
|
||||
const ENABLE_WALLET_ACTION_FORMS = true
|
||||
|
||||
const loadingInitial = ref(true)
|
||||
const loadingTransactions = ref(false)
|
||||
@@ -655,6 +722,7 @@ const submittingRefund = ref(false)
|
||||
const walletBalance = ref<WalletBalanceResponse | null>(null)
|
||||
const latestRecharge = ref<{ order: PaymentOrder; payment_instructions: Record<string, unknown> } | null>(null)
|
||||
const latestRedeem = ref<WalletRedeemResponse | null>(null)
|
||||
const rechargeOptions = ref<WalletRechargeOption[]>([])
|
||||
|
||||
const flowItems = ref<FlowItem[]>([])
|
||||
const todayUsage = ref<DailyUsageRecord | null>(null)
|
||||
@@ -677,7 +745,7 @@ let todayCostPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const rechargeForm = reactive({
|
||||
amount_usd: 10,
|
||||
payment_method: 'alipay',
|
||||
payment_option_key: '',
|
||||
})
|
||||
|
||||
const refundForm = reactive({
|
||||
@@ -695,6 +763,70 @@ const refundableOrders = computed(() =>
|
||||
rechargeOrders.value.filter(o => (o.refundable_amount_usd || 0) > 0)
|
||||
)
|
||||
|
||||
const rechargeOptionsWithKey = computed(() =>
|
||||
rechargeOptions.value.map((option, index) => ({
|
||||
...option,
|
||||
key: [
|
||||
option.payment_provider || option.provider || option.payment_method,
|
||||
option.payment_method,
|
||||
option.payment_channel || '',
|
||||
index,
|
||||
].join(':'),
|
||||
}))
|
||||
)
|
||||
|
||||
const selectedRechargeOption = computed(() => {
|
||||
if (rechargeOptionsWithKey.value.length === 0) return null
|
||||
return rechargeOptionsWithKey.value.find(option => option.key === rechargeForm.payment_option_key)
|
||||
|| rechargeOptionsWithKey.value[0]
|
||||
})
|
||||
|
||||
const estimatedRechargePayAmount = computed(() => {
|
||||
const rate = Number(selectedRechargeOption.value?.usd_exchange_rate || 0)
|
||||
if (!Number.isFinite(rate) || rate <= 0) return '-'
|
||||
return (Number(rechargeForm.amount_usd || 0) * rate).toFixed(2)
|
||||
})
|
||||
|
||||
const dailyQuota = computed(() => walletBalance.value?.daily_quota ?? null)
|
||||
const hasActiveDailyQuota = computed(() => Boolean(dailyQuota.value?.has_active))
|
||||
const walletOnlyBalance = computed(() => {
|
||||
const explicitBalance = walletBalance.value?.wallet_balance
|
||||
if (typeof explicitBalance === 'number' && Number.isFinite(explicitBalance)) {
|
||||
return explicitBalance
|
||||
}
|
||||
return Number(walletBalance.value?.balance ?? 0)
|
||||
})
|
||||
const packageBalance = computed(() => {
|
||||
const quotaRemaining = dailyQuota.value?.remaining_usd
|
||||
if (hasActiveDailyQuota.value && typeof quotaRemaining === 'number' && Number.isFinite(quotaRemaining)) {
|
||||
return Math.max(0, quotaRemaining)
|
||||
}
|
||||
const explicitBalance = walletBalance.value?.package_balance
|
||||
if (typeof explicitBalance === 'number' && Number.isFinite(explicitBalance)) {
|
||||
return Math.max(0, explicitBalance)
|
||||
}
|
||||
return 0
|
||||
})
|
||||
const totalAvailableBalance = computed(() => {
|
||||
const explicitBalance = walletBalance.value?.total_available_balance
|
||||
if (typeof explicitBalance === 'number' && Number.isFinite(explicitBalance)) {
|
||||
return explicitBalance
|
||||
}
|
||||
return walletOnlyBalance.value + packageBalance.value
|
||||
})
|
||||
const dailyQuotaTotal = computed(() => {
|
||||
const value = dailyQuota.value?.total_usd
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0
|
||||
})
|
||||
const dailyQuotaUsed = computed(() => {
|
||||
const value = dailyQuota.value?.used_usd
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0
|
||||
})
|
||||
const dailyQuotaRemainingPercent = computed(() => {
|
||||
if (!hasActiveDailyQuota.value || dailyQuotaTotal.value <= 0) return 0
|
||||
return Math.min(100, Math.max(0, (packageBalance.value / dailyQuotaTotal.value) * 100))
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
try {
|
||||
@@ -704,6 +836,7 @@ onMounted(async () => {
|
||||
loadTodayCost(),
|
||||
loadOrders(),
|
||||
loadRefunds(),
|
||||
loadRechargeOptions(),
|
||||
])
|
||||
syncTodayCostPolling()
|
||||
} finally {
|
||||
@@ -724,6 +857,21 @@ async function loadBalance() {
|
||||
walletBalance.value = await walletApi.getBalance()
|
||||
}
|
||||
|
||||
async function loadRechargeOptions() {
|
||||
try {
|
||||
const response = await walletApi.listRechargeOptions()
|
||||
rechargeOptions.value = response.items
|
||||
if (!rechargeForm.payment_option_key && rechargeOptionsWithKey.value.length > 0) {
|
||||
const preferred = rechargeOptionsWithKey.value.find(option => option.payment_provider === 'epay')
|
||||
|| rechargeOptionsWithKey.value[0]
|
||||
rechargeForm.payment_option_key = preferred.key
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('加载充值方式失败:', error)
|
||||
showError(parseApiError(error, '加载充值方式失败'))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTransactions() {
|
||||
loadingTransactions.value = true
|
||||
try {
|
||||
@@ -831,16 +979,28 @@ async function submitRecharge() {
|
||||
showError('请输入有效的充值金额')
|
||||
return
|
||||
}
|
||||
const option = selectedRechargeOption.value
|
||||
if (!option) {
|
||||
showError('请选择支付方式')
|
||||
return
|
||||
}
|
||||
if (option.min_recharge_usd && rechargeForm.amount_usd < option.min_recharge_usd) {
|
||||
showError(`充值金额不能低于 ${formatCurrency(option.min_recharge_usd)}`)
|
||||
return
|
||||
}
|
||||
|
||||
submittingRecharge.value = true
|
||||
try {
|
||||
latestRecharge.value = await walletApi.createRechargeOrder({
|
||||
amount_usd: rechargeForm.amount_usd,
|
||||
payment_method: rechargeForm.payment_method,
|
||||
payment_method: option.payment_method,
|
||||
payment_provider: option.payment_provider,
|
||||
payment_channel: option.payment_channel,
|
||||
})
|
||||
success('充值订单创建成功')
|
||||
await Promise.all([loadOrders(), loadBalance()])
|
||||
activeTab.value = 'orders'
|
||||
submitPaymentInstructions(latestRecharge.value.payment_instructions)
|
||||
} catch (error) {
|
||||
log.error('创建充值订单失败:', error)
|
||||
showError(parseApiError(error, '创建充值订单失败'))
|
||||
@@ -849,6 +1009,42 @@ async function submitRecharge() {
|
||||
}
|
||||
}
|
||||
|
||||
function submitPaymentInstructions(instructions: Record<string, unknown> | null | undefined) {
|
||||
if (!instructions) return
|
||||
const paymentUrl = instructions.payment_url
|
||||
if (typeof paymentUrl !== 'string' || !paymentUrl) return
|
||||
const paymentParams = instructions.payment_params
|
||||
if (paymentParams && typeof paymentParams === 'object' && !Array.isArray(paymentParams)) {
|
||||
submitPaymentForm(paymentUrl, paymentParams as Record<string, unknown>)
|
||||
return
|
||||
}
|
||||
window.open(paymentUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
function submitPaymentForm(url: string, params: Record<string, unknown>) {
|
||||
const form = document.createElement('form')
|
||||
form.action = url
|
||||
form.method = 'POST'
|
||||
if (!isSafariBrowser()) {
|
||||
form.target = '_blank'
|
||||
}
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === null || value === undefined) return
|
||||
const input = document.createElement('input')
|
||||
input.type = 'hidden'
|
||||
input.name = key
|
||||
input.value = String(value)
|
||||
form.appendChild(input)
|
||||
})
|
||||
document.body.appendChild(form)
|
||||
form.submit()
|
||||
document.body.removeChild(form)
|
||||
}
|
||||
|
||||
function isSafariBrowser(): boolean {
|
||||
return navigator.userAgent.includes('Safari') && !navigator.userAgent.includes('Chrome')
|
||||
}
|
||||
|
||||
async function submitRefund() {
|
||||
if (!refundForm.amount_usd || refundForm.amount_usd <= 0) {
|
||||
showError('请输入有效的退款金额')
|
||||
|
||||
Reference in New Issue
Block a user